diff --git a/deploy/dalidou/graduation-watcher.sh b/deploy/dalidou/graduation-watcher.sh new file mode 100644 index 0000000..9bd5643 --- /dev/null +++ b/deploy/dalidou/graduation-watcher.sh @@ -0,0 +1,117 @@ +#!/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 diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index 30aa98e..3487994 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -1358,6 +1358,77 @@ def api_resolve_conflict(conflict_id: str, req: ConflictResolveRequest) -> dict: return {"status": "resolved", "id": conflict_id, "action": req.action} +class GraduationRequestBody(BaseModel): + project: str = "" + limit: int = 30 + + +@router.post("/admin/graduation/request") +def api_request_graduation(req: GraduationRequestBody) -> dict: + """Request a host-side memory-graduation run. + + Writes a flag in project_state with project + limit. A host cron + watcher picks it up within ~2 min and runs graduate_memories.py. + Mirrors the /admin/triage/request-drain pattern (bridges container + β host because claude CLI lives on host, not container). + """ + 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") + payload = json.dumps({ + "project": (req.project or "").strip(), + "limit": max(1, min(req.limit, 500)), + "requested_at": now, + }) + set_state( + project_name="atocore", + category="config", + key="graduation_requested_at", + value=payload, + source="admin ui", + ) + return { + "requested_at": now, + "project": req.project, + "limit": req.limit, + "note": "Host watcher picks up within ~2 min. Poll /admin/graduation/status for progress.", + } + + +@router.get("/admin/graduation/status") +def api_graduation_status() -> dict: + """State of the graduation pipeline (UI polling).""" + from atocore.context.project_state import get_state + out = { + "requested": 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.category != "status": + continue + if e.key == "graduation_requested_at": + try: + out["requested"] = json.loads(e.value) + except Exception: + out["requested"] = {"raw": e.value} + elif e.key == "graduation_last_started_at": + out["last_started_at"] = e.value + elif e.key == "graduation_last_finished_at": + out["last_finished_at"] = e.value + elif e.key == "graduation_last_result": + out["last_result"] = e.value + elif e.key == "graduation_running": + out["is_running"] = (e.value == "1") + except Exception: + pass + return out + + @router.get("/admin/graduation/stats") def api_graduation_stats() -> dict: """Phase 5F graduation stats for dashboard.""" diff --git a/src/atocore/engineering/triage_ui.py b/src/atocore/engineering/triage_ui.py index 352e23d..696e329 100644 --- a/src/atocore/engineering/triage_ui.py +++ b/src/atocore/engineering/triage_ui.py @@ -377,6 +377,92 @@ _ENTITY_TRIAGE_CSS = """ """ +def _render_graduation_bar() -> str: + """The 'Graduate memories β entity candidates' control bar.""" + from atocore.projects.registry import load_project_registry + try: + projects = load_project_registry() + options = '' + "".join( + f'' + for p in projects + ) + except Exception: + options = '' + + return f""" +
+""" + + +_GRADUATION_SCRIPT = """ + +""" + + def render_triage_page(limit: int = 100) -> str: """Render the full triage page with pending memory + entity candidates.""" from atocore.engineering.service import get_entities @@ -393,17 +479,20 @@ def render_triage_page(limit: int = 100) -> str: entity_candidates = [] total = len(mem_candidates) + len(entity_candidates) + graduation_bar = _render_graduation_bar() if total == 0: - body = _TRIAGE_CSS + _ENTITY_TRIAGE_CSS + """ + body = _TRIAGE_CSS + _ENTITY_TRIAGE_CSS + f"""π No candidates to review.
The auto-triage pipeline keeps this queue empty unless something needs your judgment.
+Use the π Graduate memories button above to propose new entity candidates from existing memories.