Files
ATOCore/deploy/dalidou/graduation-watcher.sh
Anto01 0dfecb3c14 feat: one-click memory graduation button + host watcher
Closes the graduation UX loop: no more SSH required to populate the
entity graph from memories. Click button → host watcher picks up
→ graduation runs → entity candidates appear in the same triage UI.

New API endpoints (src/atocore/api/routes.py):
- POST /admin/graduation/request: takes {project, limit}, writes flag
  to project_state. Host watcher picks up within 2 min.
- GET /admin/graduation/status: returns requested/running/last_result
  fields for UI polling.

Triage UI (src/atocore/engineering/triage_ui.py):
- Graduation bar with:
  - 🎓 Graduate memories button
  - Project selector populated from registry (or "all projects")
  - Limit number input (default 30, max 200)
  - Status message area
- Poll every 10s until is_running=false, then auto-reload the page to
  show new entity candidates in the Entity section below
- Graduation bar appears on both populated and empty triage page
  states so you can kick off graduation from either

Host watcher (deploy/dalidou/graduation-watcher.sh):
- Mirrors auto-triage-watcher.sh pattern: poll, lock, clear flag,
  run, record result, unlock
- Parses {project, limit} JSON from the flag payload
- Runs graduate_memories.py with those args
- Records graduation_running/started/finished/last_result in project
  state for the UI to display
- Lock file prevents concurrent runs

Install on host (one-time, via cron):
  */2 * * * * /srv/storage/atocore/app/deploy/dalidou/graduation-watcher.sh \
    >> /home/papa/atocore-logs/graduation-watcher.log 2>&1

This completes the Phase 5 self-service loop: queue triage happens
autonomously via the 3-tier escalation (shipped in 3ca1972); entity
graph population happens autonomously via a button click. No shell
required for daily use.

Tests: 366 passing (no new tests — UI + shell are integration-level).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 09:45:12 -04:00

118 lines
4.8 KiB
Bash

#!/usr/bin/env bash
#
# deploy/dalidou/graduation-watcher.sh
# ------------------------------------
# Host-side watcher for on-demand memory→entity graduation from the web UI.
#
# The /admin/triage page has a "🎓 Graduate memories" button that POSTs
# to /admin/graduation/request with {project, limit}. The container
# writes this to project_state (atocore/config/graduation_requested_at).
#
# This script runs on the Dalidou HOST (where claude CLI lives), polls
# for the flag, and runs graduate_memories.py when seen.
#
# Installed via cron every 2 minutes:
# */2 * * * * /srv/storage/atocore/app/deploy/dalidou/graduation-watcher.sh \
# >> /home/papa/atocore-logs/graduation-watcher.log 2>&1
#
# Safety:
# - Lock file prevents concurrent runs
# - Flag cleared before processing so duplicate clicks queue at most one re-run
# - Fail-open: any error logs but doesn't break the host
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-graduation.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 flag via API
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') == 'graduation_requested_at':
print(e.get('value', ''))
break
except Exception:
pass
" 2>/dev/null || echo "")
if [[ -z "$REQUESTED" ]]; then
exit 0
fi
# Parse JSON: {project, limit, requested_at}
PROJECT=$(echo "$REQUESTED" | python3 -c "import sys,json; d=json.load(sys.stdin) if '{' in sys.stdin.buffer.peek().decode(errors='ignore') else None; print((d or {}).get('project',''))" 2>/dev/null || echo "")
# Fallback: python inline above can be flaky; just re-parse
PROJECT=$(echo "$REQUESTED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('project',''))" 2>/dev/null || echo "")
LIMIT=$(echo "$REQUESTED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('limit',30))" 2>/dev/null || echo "30")
# Acquire lock
exec 9>"$LOCK_FILE" || exit 0
if ! flock -n 9; then
log "graduation already running, skipping"
exit 0
fi
# Mark running
curl -sSf -X POST "$ATOCORE_URL/project/state" \
-H 'Content-Type: application/json' \
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_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\":\"graduation_last_started_at\",\"value\":\"$TS\",\"source\":\"host watcher\"}" \
>/dev/null 2>&1 || true
LOG_FILE="$LOG_DIR/graduation-ondemand-$(date -u +%Y%m%d-%H%M%S).log"
log "Starting graduation (project='$PROJECT' limit=$LIMIT, log: $LOG_FILE)"
# Clear the flag BEFORE running so duplicate clicks queue at most one
curl -sSf -X DELETE "$ATOCORE_URL/project/state" \
-H 'Content-Type: application/json' \
-d "{\"project\":\"atocore\",\"category\":\"config\",\"key\":\"graduation_requested_at\"}" \
>/dev/null 2>&1 || true
# Build script args
cd "$APP_DIR"
export PYTHONPATH="$APP_DIR/src:${PYTHONPATH:-}"
ARGS=(--base-url "$ATOCORE_URL" --limit "$LIMIT")
if [[ -n "$PROJECT" ]]; then
ARGS+=(--project "$PROJECT")
fi
if python3 scripts/graduate_memories.py "${ARGS[@]}" >> "$LOG_FILE" 2>&1; then
RESULT=$(tail -3 "$LOG_FILE" | grep "^total:" | tail -1 || tail -1 "$LOG_FILE")
RESULT="${RESULT:-completed}"
log "graduation finished: $RESULT"
else
RESULT="ERROR — see $LOG_FILE"
log "graduation FAILED"
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\":\"graduation_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\":\"graduation_last_finished_at\",\"value\":\"$FINISH_TS\",\"source\":\"host watcher\"}" \
>/dev/null 2>&1 || true
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\":\"graduation_last_result\",\"value\":\"$SAFE_RESULT\",\"source\":\"host watcher\"}" \
>/dev/null 2>&1 || true