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>
This commit is contained in:
2026-04-17 09:45:12 -04:00
parent 3ca19724a5
commit 0dfecb3c14
3 changed files with 283 additions and 5 deletions

View File

@@ -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."""