#!/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