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) <noreply@anthropic.com>
This commit is contained in:
108
deploy/dalidou/auto-triage-watcher.sh
Normal file
108
deploy/dalidou/auto-triage-watcher.sh
Normal file
@@ -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
|
||||||
@@ -130,6 +130,57 @@ def admin_triage(limit: int = 100) -> HTMLResponse:
|
|||||||
return HTMLResponse(content=render_triage_page(limit=limit))
|
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 ---
|
# --- Request/Response models ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,10 +74,54 @@ async function apiCall(url, method, body) {
|
|||||||
opts.body = JSON.stringify(body);
|
opts.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
const res = await fetch(url, opts);
|
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) }; }
|
} 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) {
|
function setStatus(id, msg, ok) {
|
||||||
const el = document.getElementById('status-' + id);
|
const el = document.getElementById('status-' + id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -185,6 +229,13 @@ _TRIAGE_CSS = """
|
|||||||
.cand-status.ok { color:#059669; }
|
.cand-status.ok { color:#059669; }
|
||||||
.cand-status.err { color:#dc2626; }
|
.cand-status.err { color:#dc2626; }
|
||||||
.empty { text-align:center; padding:3rem; opacity:0.6; }
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -221,6 +272,15 @@ def render_triage_page(limit: int = 100) -> str:
|
|||||||
if needed, then promote or reject. Shortcuts: <kbd>Y</kbd> promote · <kbd>N</kbd>
|
if needed, then promote or reject. Shortcuts: <kbd>Y</kbd> promote · <kbd>N</kbd>
|
||||||
reject · <kbd>E</kbd> edit · <kbd>S</kbd> scroll to next.
|
reject · <kbd>E</kbd> edit · <kbd>S</kbd> scroll to next.
|
||||||
</div>
|
</div>
|
||||||
|
<div class="auto-triage-bar">
|
||||||
|
<button id="auto-triage-btn" onclick="requestAutoTriage()" title="Run auto_triage on Dalidou host">
|
||||||
|
🤖 Auto-process queue
|
||||||
|
</button>
|
||||||
|
<span id="auto-triage-status" class="auto-triage-msg">
|
||||||
|
Sends the full queue through LLM triage on the host. Promotes durable facts,
|
||||||
|
rejects noise, leaves only ambiguous items here for you.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{cards_html}
|
{cards_html}
|
||||||
""" + _TRIAGE_SCRIPT
|
""" + _TRIAGE_SCRIPT
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user