From 3ebc9836de4c32f10ef25ebb8161c759870af33c Mon Sep 17 00:00:00 2001 From: Marcello Alarcon Date: Wed, 13 May 2026 16:09:57 -0300 Subject: [PATCH] fix(brain-repo): allow restore wizard to list snapshots before connect The restore wizard ran before BrainRepoConfig was persisted, so /api/brain-repo/snapshots and /api/brain-repo/restore/start aborted with "Brain repo not connected". The only workaround was inserting config directly via Python (see [C]contabo-brain-restore.py). Both endpoints now accept an optional token + repo_url pair (query string on snapshots, JSON body on restore/start). When provided they bypass the persisted-config requirement; when absent the legacy fallback to BrainRepoConfig is preserved, so /settings/brain-repo and reconnect flows are unaffected. Wizard now propagates the PAT from RestoreSelectRepo through RestoreFlow into both RestoreSelectSnapshot and RestoreExecute. Co-Authored-By: Claude Opus 4.7 --- dashboard/backend/routes/brain_repo.py | 58 ++++++++++++++++--- .../onboarding/restore/RestoreExecute.tsx | 16 ++++- .../pages/onboarding/restore/RestoreFlow.tsx | 11 +++- .../onboarding/restore/RestoreSelectRepo.tsx | 4 +- .../restore/RestoreSelectSnapshot.tsx | 15 ++++- 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/dashboard/backend/routes/brain_repo.py b/dashboard/backend/routes/brain_repo.py index 5bd8dd03..fb05f264 100644 --- a/dashboard/backend/routes/brain_repo.py +++ b/dashboard/backend/routes/brain_repo.py @@ -518,7 +518,39 @@ def detect(): @bp.route("/api/brain-repo/snapshots") @login_required def snapshots(): - """List available restore snapshots (daily / weekly / milestones / head).""" + """List available restore snapshots (daily / weekly / milestones / head). + + Accepts an explicit ``token`` + ``repo_url`` pair (query string) for the + restore-wizard preview flow, which lists snapshots *before* persisting a + BrainRepoConfig. When either is missing it falls back to the stored + config — the path used by ``/settings/brain-repo`` and reconnect flows. + + This mirrors the same opt-in token override that ``/api/brain-repo/detect`` + already accepts, and unblocks the wizard catch-22 where snapshot preview + used to require a persisted config that the wizard never wrote. + """ + override_token = request.args.get("token", "").strip() + override_repo_url = request.args.get("repo_url", "").strip() + + if override_token and override_repo_url: + try: + from brain_repo.github_api import get_repo_info, list_snapshots + except ImportError: + return jsonify({"daily": [], "weekly": [], "milestones": [], "head": None}) + + _ok, repo_info = get_repo_info(override_token, override_repo_url) + owner = (repo_info.get("owner", {}) or {}).get("login", "") + name = repo_info.get("name", "") + if not owner or not name: + abort(400, description="repo_url did not resolve to a GitHub repo for this token") + + try: + result = list_snapshots(override_token, owner, name) + except Exception as exc: + log.warning("snapshots: list_snapshots failed (override): %s", exc) + abort(400, description=f"Could not list snapshots: {exc}") + return jsonify(result) + config = _get_config() if not config or not config.github_token_encrypted: abort(400, description="Brain repo not connected") @@ -557,20 +589,28 @@ def restore_start(): # master key") — when False, KB import silently degrades to metadata-only, # which is the safe default. kb_key_matches = bool(data.get("kb_key_matches", False)) + override_token = (data.get("token") or "").strip() + override_repo_url = (data.get("repo_url") or "").strip() if not ref: abort(400, description="ref required") - config = _get_config() - if not config or not config.github_token_encrypted: - abort(400, description="Brain repo not connected") + # The wizard restore flow runs before a BrainRepoConfig is persisted, so it + # passes token + repo_url explicitly. /settings/brain-repo and reconnect + # paths still rely on the stored config (no override → falls through). + if override_token and override_repo_url: + token = override_token + repo_url = override_repo_url + else: + config = _get_config() + if not config or not config.github_token_encrypted: + abort(400, description="Brain repo not connected") - token = _decrypt_token(config) - if not token: - abort(400, description="Could not decrypt stored token") + token = _decrypt_token(config) + if not token: + abort(400, description="Could not decrypt stored token") - # Capture needed values before entering generator (avoids app context issues) - repo_url = config.repo_url + repo_url = config.repo_url # install_dir is where SWAP_DIRS (memory/workspace/customizations/config-safe) # get replaced — i.e. the EvoNexus workspace root, NOT the brain-repo clone # path. Confusing these two is what broke the restore endpoint. diff --git a/dashboard/frontend/src/pages/onboarding/restore/RestoreExecute.tsx b/dashboard/frontend/src/pages/onboarding/restore/RestoreExecute.tsx index f2423563..7ae8c6c7 100644 --- a/dashboard/frontend/src/pages/onboarding/restore/RestoreExecute.tsx +++ b/dashboard/frontend/src/pages/onboarding/restore/RestoreExecute.tsx @@ -17,13 +17,15 @@ interface RestoreStep { interface RestoreExecuteProps { snapshot: SelectedSnapshot + token: string + repoUrl: string onComplete: () => void onRetry: () => void } const API = import.meta.env.DEV ? 'http://localhost:8080' : '' -export default function RestoreExecute({ snapshot, onComplete, onRetry }: RestoreExecuteProps) { +export default function RestoreExecute({ snapshot, token, repoUrl, onComplete, onRetry }: RestoreExecuteProps) { const { t } = useTranslation() const [progress, setProgress] = useState(0) const [steps, setSteps] = useState([]) @@ -38,11 +40,19 @@ export default function RestoreExecute({ snapshot, onComplete, onRetry }: Restor const run = async () => { try { + // Forward token + repoUrl alongside the snapshot ref so the backend + // can resolve the GitHub remote without a persisted BrainRepoConfig + // (the wizard runs before /connect has been called — catch-22 fix). const res = await fetch(`${API}/api/brain-repo/restore/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'include', - body: JSON.stringify({ ref: snapshot.ref, include_kb: snapshot.includeKb }), + body: JSON.stringify({ + ref: snapshot.ref, + include_kb: snapshot.includeKb, + token, + repo_url: repoUrl, + }), signal: ctrl.signal, }) @@ -135,7 +145,7 @@ export default function RestoreExecute({ snapshot, onComplete, onRetry }: Restor run() return () => ctrl.abort() - }, [snapshot, onComplete, t]) + }, [snapshot, token, repoUrl, onComplete, t]) return (
diff --git a/dashboard/frontend/src/pages/onboarding/restore/RestoreFlow.tsx b/dashboard/frontend/src/pages/onboarding/restore/RestoreFlow.tsx index 7f9d5f6f..3a851b7b 100644 --- a/dashboard/frontend/src/pages/onboarding/restore/RestoreFlow.tsx +++ b/dashboard/frontend/src/pages/onboarding/restore/RestoreFlow.tsx @@ -20,13 +20,19 @@ interface RestoreFlowProps { export default function RestoreFlow({ onComplete, onBack }: RestoreFlowProps) { const [step, setStep] = useState('select-repo') const [repoUrl, setRepoUrl] = useState('') + // Token + repoUrl flow through every step because /api/brain-repo/snapshots + // and /api/brain-repo/restore/start need them: in the wizard there is no + // persisted BrainRepoConfig yet, so the endpoints accept the pair explicitly + // (catch-22 fix — see routes/brain_repo.py). + const [token, setToken] = useState('') const [snapshot, setSnapshot] = useState(null) if (step === 'select-repo') { return ( { + onNext={(url: string, t: string) => { setRepoUrl(url) + setToken(t) setStep('select-snapshot') }} onBack={onBack} @@ -38,6 +44,7 @@ export default function RestoreFlow({ onComplete, onBack }: RestoreFlowProps) { return ( { setSnapshot(s) setStep('confirm') @@ -61,6 +68,8 @@ export default function RestoreFlow({ onComplete, onBack }: RestoreFlowProps) { return ( setStep('confirm')} /> diff --git a/dashboard/frontend/src/pages/onboarding/restore/RestoreSelectRepo.tsx b/dashboard/frontend/src/pages/onboarding/restore/RestoreSelectRepo.tsx index a7dfa790..4b4feac4 100644 --- a/dashboard/frontend/src/pages/onboarding/restore/RestoreSelectRepo.tsx +++ b/dashboard/frontend/src/pages/onboarding/restore/RestoreSelectRepo.tsx @@ -12,7 +12,7 @@ interface Repo { } interface RestoreSelectRepoProps { - onNext: (repoUrl: string) => void + onNext: (repoUrl: string, token: string) => void onBack: () => void } @@ -65,7 +65,7 @@ export default function RestoreSelectRepo({ onNext, onBack }: RestoreSelectRepoP setError(t('restore.selectRepo.selectRepo')) return } - onNext(selectedRepo.html_url) + onNext(selectedRepo.html_url, token.trim()) } return ( diff --git a/dashboard/frontend/src/pages/onboarding/restore/RestoreSelectSnapshot.tsx b/dashboard/frontend/src/pages/onboarding/restore/RestoreSelectSnapshot.tsx index 4822c17b..48fd604f 100644 --- a/dashboard/frontend/src/pages/onboarding/restore/RestoreSelectSnapshot.tsx +++ b/dashboard/frontend/src/pages/onboarding/restore/RestoreSelectSnapshot.tsx @@ -25,6 +25,7 @@ interface SelectedSnapshot { interface RestoreSelectSnapshotProps { repoUrl: string + token: string onNext: (snapshot: SelectedSnapshot) => void onBack: () => void } @@ -62,7 +63,7 @@ function SnapshotItem({ ) } -export default function RestoreSelectSnapshot({ repoUrl, onNext, onBack }: RestoreSelectSnapshotProps) { +export default function RestoreSelectSnapshot({ repoUrl, token, onNext, onBack }: RestoreSelectSnapshotProps) { const { t } = useTranslation() const [data, setData] = useState(null) const [loading, setLoading] = useState(true) @@ -71,11 +72,19 @@ export default function RestoreSelectSnapshot({ repoUrl, onNext, onBack }: Resto const [error, setError] = useState('') useEffect(() => { - api.get('/brain-repo/snapshots') + // Pass token + repo_url explicitly because the wizard runs before a + // BrainRepoConfig is persisted (restore is a first-run path). The + // endpoint still falls back to stored config when these are absent. + const params = new URLSearchParams() + if (token) params.set('token', token) + if (repoUrl) params.set('repo_url', repoUrl) + const qs = params.toString() + const url = qs ? `/brain-repo/snapshots?${qs}` : '/brain-repo/snapshots' + api.get(url) .then((d: SnapshotData) => setData(d)) .catch(() => setError(t('restore.selectSnapshot.failed'))) .finally(() => setLoading(false)) - }, [repoUrl, t]) + }, [repoUrl, token, t]) const handleNext = () => { if (!selected) {