Loom / dataset_persistence.py
deploy-space action
deploy 6158a00 (c)
b972454
Raw
History Blame Contribute Delete
24.1 kB
"""HF Dataset persistent storage + OAuth for the loom critique service.
Persists the SQLite DB to a private HF Dataset so data survives Space rebuilds.
Also provides HF OAuth helpers so users can authorize dataset access.
Tier 3 extension: also persists the ``.brain/`` folder for each active
Conscious workspace as ``brains/<workspace_id>.tar.gz`` (one tarball per
workspace, uploaded every 120s alongside ``loom.db``; re-downloaded on boot).
See TIER3_PLAN.md §7.2 for the spec.
Env vars required:
HF_CLIENT_ID — HF OAuth App client ID
HF_CLIENT_SECRET — HF OAuth App client secret
ENCRYPTION_KEY — Fernet key for token encryption (auto-generated if absent)
Env vars optional:
LOOM_DATASET_SUFFIX — suffix for the dataset name (default: main)
The full name is {hf_username}/loom-priv-{suffix}
"""
from __future__ import annotations
import io
import json
import os
import tarfile
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone, timedelta
from pathlib import Path
from urllib.parse import urlencode
import crypto
import db
import jwt_auth
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
HF_AUTHORIZE_URL = "https://huggingface.co/oauth/authorize"
HF_TOKEN_URL = "https://huggingface.co/oauth/token"
HF_API_BASE = "https://huggingface.co"
HF_SCOPES = "openid profile write-repos"
# ---------------------------------------------------------------------------
# HF API helpers
# ---------------------------------------------------------------------------
def _hf_api(path: str, *, token: str = "", method: str = "GET",
body: dict | None = None) -> dict:
"""Raw HF Hub API call via stdlib urllib."""
url = f"{HF_API_BASE}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode()
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")[:400]
raise RuntimeError(f"HF API {path} failed (HTTP {exc.code}): {detail}") from exc
def _hf_configured() -> bool:
return bool(
os.environ.get("HF_CLIENT_ID", "").strip()
and os.environ.get("HF_CLIENT_SECRET", "").strip()
)
# ---------------------------------------------------------------------------
# Space name detection
# ---------------------------------------------------------------------------
def _space_name() -> str:
"""Return the Space repo name for dataset naming."""
name = os.environ.get("SPACE_NAME", "").strip()
if name:
return name
sid = os.environ.get("SPACE_ID", "").strip()
if sid and "/" in sid:
return sid.split("/")[-1]
return "main"
# ---------------------------------------------------------------------------
# HF OAuth flow
# ---------------------------------------------------------------------------
def make_hf_authorize_url(state: str, redirect_uri: str = "") -> str:
"""Build the HF OAuth authorize URL with state token.
``redirect_uri`` must match the registered callback URL exactly.
"""
params = urlencode({
"client_id": os.environ.get("HF_CLIENT_ID", ""),
"redirect_uri": redirect_uri,
"scope": HF_SCOPES,
"response_type": "code",
"state": state,
})
return f"{HF_AUTHORIZE_URL}?{params}"
def exchange_hf_code(code: str, redirect_uri: str = "") -> dict:
"""Exchange OAuth code for tokens. Returns dict with access_token, refresh_token, expires_in.
``redirect_uri`` must match the value used in the authorize request.
Raises RuntimeError on failure.
"""
body = urlencode({
"client_id": os.environ.get("HF_CLIENT_ID", ""),
"client_secret": os.environ.get("HF_CLIENT_SECRET", ""),
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri,
}).encode()
req = urllib.request.Request(HF_TOKEN_URL, data=body, method="POST")
req.add_header("Accept", "application/json")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")[:200]
# don't include the full response — it may contain tokens (audit H4)
raise RuntimeError(f"HF token exchange failed (HTTP {exc.code})") from exc
token = data.get("access_token", "").strip()
if not token:
# log only the keys present, not the values (which may contain tokens)
raise RuntimeError(f"HF token exchange returned no access_token (keys: {list(data.keys())})")
return data
def get_hf_user(token: str) -> dict:
"""Fetch authenticated user info from HF API."""
return _hf_api("/api/whoami-v2", token=token)
def _valid_user_id(user_id: str | None) -> bool:
"""Return True if user_id looks like a valid 16-char hex ID.
Rejects JWT strings (contain dots) and other garbage that might
be accidentally passed by the frontend.
"""
if not user_id or not isinstance(user_id, str):
return False
if len(user_id) != 16:
return False
if "." in user_id:
return False
try:
int(user_id, 16)
return True
except ValueError:
return False
def upsert_user_from_hf(token_data: dict, user_id: str | None = None) -> dict:
"""Fetch HF user info, upsert into DB, return the user row.
``token_data`` must contain ``access_token``, and may contain
``refresh_token`` and ``expires_in`` from the token exchange response.
If ``user_id`` is provided, look up existing user by that internal UUID first.
If found, use their github_id for the upsert to ensure merge works.
If ``user_id`` is None, derives a deterministic ID from the HF username.
"""
hf_token = token_data["access_token"]
info = get_hf_user(hf_token)
encrypted = crypto.encrypt_token(hf_token)
refresh_encrypted = crypto.encrypt_token(token_data["refresh_token"]) if token_data.get("refresh_token") else None
expires_at = ""
if token_data.get("expires_in"):
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=int(token_data["expires_in"]))).isoformat()
# Derive a deterministic ID if none provided
if user_id is None or not _valid_user_id(user_id):
user_id = jwt_auth.derive_user_id_from_hf(info["name"])
github_id = None
if user_id:
existing = db.get_user(user_id)
if existing:
github_id = existing.get("github_id")
return db.upsert_user(
user_id=user_id,
github_id=github_id,
hf_id=info["name"],
hf_username=info["name"],
hf_token_encrypted=encrypted,
hf_refresh_token_encrypted=refresh_encrypted,
hf_token_expires_at=expires_at,
)
def _hf_token_for_user(user_id: str) -> str:
"""Retrieve and decrypt a user's stored HF token.
Auto-refreshes the token if it expires within 7 days and a refresh
token is available. Raises RuntimeError if no token is stored.
"""
user = db.get_user(user_id)
if not user or not user.get("hf_token_encrypted"):
raise RuntimeError("HF not connected — please link your Hugging Face account")
expires_at = user.get("hf_token_expires_at") or ""
if _hf_token_expires_soon(expires_at) and user.get("hf_refresh_token_encrypted"):
try:
_refresh_hf_token(user_id, user)
except Exception as exc:
print(f"[dataset_persistence] token refresh failed: {type(exc).__name__}", flush=True)
user = db.get_user(user_id)
return crypto.decrypt_token(user["hf_token_encrypted"])
def _hf_token_expires_soon(expires_at: str, within_days: int = 7) -> bool:
"""Return True if the token expires within ``within_days`` or is already expired."""
if not expires_at:
return True
try:
expiry = datetime.fromisoformat(expires_at)
return expiry - timedelta(days=within_days) < datetime.now(timezone.utc)
except (ValueError, TypeError):
return True
def _refresh_hf_token(user_id: str, user: dict) -> None:
"""Refresh a user's HF token using the stored refresh token.
Updates the DB with new access/refresh tokens and expiry.
"""
refresh_encrypted = user.get("hf_refresh_token_encrypted")
if not refresh_encrypted:
raise RuntimeError("no refresh token stored")
old_refresh = crypto.decrypt_token(refresh_encrypted)
body = urlencode({
"client_id": os.environ.get("HF_CLIENT_ID", ""),
"client_secret": os.environ.get("HF_CLIENT_SECRET", ""),
"grant_type": "refresh_token",
"refresh_token": old_refresh,
}).encode()
req = urllib.request.Request(HF_TOKEN_URL, data=body, method="POST")
req.add_header("Accept", "application/json")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")[:400]
raise RuntimeError(f"HF token refresh failed (HTTP {exc.code}): {detail}") from exc
new_token = data.get("access_token", "").strip()
if not new_token:
raise RuntimeError(f"HF token refresh returned no access_token: {data}")
encrypted = crypto.encrypt_token(new_token)
new_refresh_encrypted = None
if data.get("refresh_token"):
new_refresh_encrypted = crypto.encrypt_token(data["refresh_token"])
expires_at = ""
if data.get("expires_in"):
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=int(data["expires_in"]))).isoformat()
db.upsert_user(
hf_id=user["hf_id"],
hf_token_encrypted=encrypted,
hf_refresh_token_encrypted=new_refresh_encrypted,
hf_token_expires_at=expires_at,
)
# ---------------------------------------------------------------------------
# Dataset persistence
# ---------------------------------------------------------------------------
_upload_scheduler: threading.Thread | None = None
_shutdown_event = threading.Event()
_current_hf_token: str = ""
def dataset_name(hf_username: str) -> str:
"""Return the full dataset repo name for a user.
Format: {hf_username}/loom-priv-{suffix}
"""
suffix = os.environ.get("LOOM_DATASET_SUFFIX", "").strip() or _space_name()
return f"{hf_username}/loom-priv-{suffix}"
def init_persistence(user_id: str) -> None:
"""Initialize dataset persistence for the current user.
Ensures the private dataset repo exists, downloads the latest DB,
and starts the periodic upload scheduler.
Tier 3 extension: after the DB download, also restores every active
Conscious's ``.brain/`` folder (§7.4). If the workspace sandbox doesn't
exist (Space was rebuilt), it's recreated from the workspace's
``source_repo`` via the existing token-safe ``_clone_repo`` (Tier 1).
"""
global _current_hf_token
token = _hf_token_for_user(user_id)
_current_hf_token = token
user = db.get_user(user_id)
hf_user = user.get("hf_username", "") if user else ""
if not hf_user:
info = get_hf_user(token)
hf_user = info.get("name", "")
ds_name = dataset_name(hf_user)
try:
_hf_api(f"/api/repos/{ds_name}", token=token)
except RuntimeError as exc:
if "404" in str(exc):
_hf_api("/api/repos", method="POST", token=token, body={
"name": ds_name,
"private": True,
"type": "dataset",
})
else:
raise
_download_db(token, ds_name)
# Tier 3 — restore .brain/ folders for all active Conscious workspaces.
_restore_all_brains(token, ds_name)
_start_upload_scheduler(token, ds_name)
def _download_db(token: str, ds_name: str) -> None:
"""Download loom.db from the dataset repo to /data/loom.db if it exists."""
from huggingface_hub import hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError, RevisionNotFoundError
try:
path = hf_hub_download(
repo_id=ds_name,
filename="loom.db",
token=token,
repo_type="dataset",
)
import shutil
shutil.copy2(path, db.DB_PATH)
except (RepositoryNotFoundError, RevisionNotFoundError, OSError):
pass
def upload_db(token: str, ds_name: str) -> None:
"""Upload the current loom.db to the dataset repo."""
if not db.DB_PATH.exists():
return
from huggingface_hub import upload_file
from huggingface_hub.utils import HfHubHTTPError
try:
upload_file(
path_or_fileobj=str(db.DB_PATH),
path_in_repo="loom.db",
repo_id=ds_name,
repo_type="dataset",
token=token,
commit_message="auto-sync loom.db",
)
except HfHubHTTPError as exc:
print(f"[dataset_persistence] upload failed: {type(exc).__name__}", flush=True)
def _start_upload_scheduler(token: str, ds_name: str, interval: int = 120) -> None:
"""Start a daemon thread that uploads the DB every ``interval`` seconds.
Tier 3 extension: also uploads every active Conscious's ``.brain/`` folder
(one tarball per workspace). Single-threaded; no new locks. Failures in
one workspace don't block others (each wrapped in try/except, logged).
"""
global _upload_scheduler
if _upload_scheduler is not None:
return
def loop():
while not _shutdown_event.is_set():
if _shutdown_event.wait(interval):
break
try:
upload_db(token, ds_name)
except Exception:
pass
# Tier 3 — also sync brains
try:
_upload_all_brains(token, ds_name)
except Exception as exc:
print(f"[dataset_persistence] brain sync failed: {type(exc).__name__}", flush=True)
_upload_scheduler = threading.Thread(target=loop, daemon=True)
_upload_scheduler.start()
# ---------------------------------------------------------------------------
# Tier 3 — .brain/ folder sync (§7.2, §7.4)
# ---------------------------------------------------------------------------
BRAIN_SYNC_INTERVAL_S = 120 # same as DB; shares the scheduler
def _tar_brain(brain_path: Path) -> bytes:
"""Pack a .brain/ folder into a tar.gz bytes blob. Skips if path missing."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
tar.add(str(brain_path), arcname=".brain")
return buf.getvalue()
def _untar_brain(blob: bytes, dest_workspace: Path) -> None:
"""Extract a tar.gz brain blob into ``dest_workspace`` (.brain/ lands there).
Phase 5 security: uses ``filter='data'`` (Python 3.12+) to prevent
path-traversal via malicious tarball entries (audit L4). On older Pythons
where the filter param doesn't exist, falls back to manual member
validation.
"""
buf = io.BytesIO(blob)
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
try:
# Python 3.12+ — built-in data filter blocks path traversal
tar.extractall(str(dest_workspace), filter="data")
except TypeError:
# older Python — manual validation: reject members with .. or abs paths
dest = dest_workspace.resolve()
for member in tar.getmembers():
member_path = (dest_workspace / member.name).resolve()
if not str(member_path).startswith(str(dest)):
raise RuntimeError(f"tar member escapes dest: {member.name}")
tar.extractall(str(dest_workspace))
def upload_brain(token: str, ds_name: str, workspace_id: str,
brain_path: Path) -> None:
"""Upload the .brain/ folder for one workspace to the HF Dataset.
Packs .brain/ as a tar.gz, uploads as ``brains/<workspace_id>.tar.gz``.
Idempotent: overwrites the previous upload. Skips if brain_path missing.
Per TIER3_PLAN §7.2: one tarball per workspace per 120s tick (not per-file)
to keep HF Dataset upload calls bounded.
Phase 3: after a successful upload, stamps ``conscious.last_synced_at`` for
every active conscious in this workspace so the API can surface stale
syncs (>10min) — TIER3_PLAN §17 risk mitigation.
Phase 5 security: scans .brain/ for known secret patterns BEFORE upload.
If secrets are found, the upload is REFUSED (TIER3_PLAN §17: "refuse to
commit if any file in .brain/ matches a secret pattern"). This is the
second defense layer — commit_brain scans first, this scans before HF.
"""
if not brain_path.is_dir():
return
# Security: scan for secrets before uploading to HF (audit C1)
try:
import brain as _brain_mod
findings = _brain_mod.scan_for_secrets(brain_path)
if findings:
print(f"[dataset_persistence] BRAIN UPLOAD BLOCKED for {workspace_id}: "
f"{len(findings)} secret(s) detected in .brain/", flush=True)
return # refuse to upload
except Exception:
pass # scan failure must never block — but log it
try:
blob = _tar_brain(brain_path)
except Exception as exc:
print(f"[dataset_persistence] tar brain failed for {workspace_id}: "
f"{type(exc).__name__}", # don't leak details (audit H4)
flush=True)
return
from huggingface_hub import upload_file
from huggingface_hub.utils import HfHubHTTPError
try:
upload_file(
path_or_fileobj=blob,
path_in_repo=f"brains/{workspace_id}.tar.gz",
repo_id=ds_name,
repo_type="dataset",
token=token,
commit_message=f"auto-sync brain {workspace_id}",
)
except HfHubHTTPError as exc:
print(f"[dataset_persistence] brain upload failed for {workspace_id}: {type(exc).__name__}",
flush=True)
return
# Phase 3 — stamp last_synced_at on every active conscious in this workspace
try:
import conscious_db
now = conscious_db._iso_now()
with _db_write_lock_for_sync():
_db_for_sync().execute(
"UPDATE conscious SET last_synced_at = ?, updated_at = ? "
"WHERE workspace_id = ? AND status = 'active'",
(now, now, workspace_id))
_db_for_sync().commit()
except Exception as exc:
print(f"[dataset_persistence] stamp last_synced_at failed for {workspace_id}: {type(exc).__name__}",
flush=True)
# Phase 3 — local helpers to avoid circular import of db._write_lock at module
# load time. conscious_db imports db, and dataset_persistence imports
# conscious_db lazily inside functions; these helpers mirror that pattern.
def _db_for_sync():
import db as _db
return _db._db()
def _db_write_lock_for_sync():
import db as _db
return _db._write_lock
def download_brain(token: str, ds_name: str, workspace_id: str,
dest_workspace: Path) -> None:
"""Download and extract ``brains/<workspace_id>.tar.gz`` into dest_workspace.
No-op if the file doesn't exist in the dataset (first run for that workspace).
"""
from huggingface_hub import hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError, RevisionNotFoundError, EntryNotFoundError
try:
path = hf_hub_download(
repo_id=ds_name,
filename=f"brains/{workspace_id}.tar.gz",
token=token,
repo_type="dataset",
)
except (RepositoryNotFoundError, RevisionNotFoundError, EntryNotFoundError, OSError):
return # first run — no brain yet
try:
with open(path, "rb") as f:
blob = f.read()
dest_workspace.mkdir(parents=True, exist_ok=True)
_untar_brain(blob, dest_workspace)
except Exception as exc:
print(f"[dataset_persistence] brain extract failed for {workspace_id}: {type(exc).__name__}",
flush=True)
def _upload_all_brains(token: str, ds_name: str) -> None:
"""Walk all active Conscious workspaces and upload each .brain/ folder."""
try:
import conscious_db
items = conscious_db.list_active_conscious_workspaces()
except Exception:
return # conscious tables not initialized yet
for item in items:
ws_id = item["workspace_id"]
sandbox = item.get("sandbox_path") or ""
if not sandbox:
continue
brain_path = Path(sandbox) / ".brain"
try:
upload_brain(token, ds_name, ws_id, brain_path)
except Exception as exc:
print(f"[dataset_persistence] brain upload error for {ws_id}: {type(exc).__name__}",
flush=True)
continue
def _restore_all_brains(token: str, ds_name: str) -> None:
"""On boot, restore .brain/ for every Conscious in the restored DB.
For each Conscious row: if the workspace sandbox doesn't exist, recreate
it from the workspace's source_repo via Tier 1's token-safe ``_clone_repo``
(in github_integration); then extract the brain tarball into it.
"""
try:
import conscious_db
items = conscious_db.list_active_conscious_workspaces()
except Exception:
return # conscious tables not initialized
for item in items:
ws_id = item["workspace_id"]
sandbox = item.get("sandbox_path") or ""
if not sandbox:
continue
sandbox_path = Path(sandbox)
try:
if not sandbox_path.is_dir():
# recreate the workspace from source_repo (Tier 1 token-safe clone)
ws = db.get_workspace(ws_id)
if ws and ws.get("source_repo") and ws.get("user_id"):
try:
import github_integration
github_integration._clone_repo(
ws["user_id"], ws["source_repo"],
branch=ws.get("source_branch"),
dest=str(sandbox_path),
branches=None)
except Exception as exc:
print(f"[dataset_persistence] workspace re-clone failed "
f"for {ws_id}: {type(exc).__name__}", flush=True)
continue
else:
continue
download_brain(token, ds_name, ws_id, sandbox_path)
except Exception as exc:
print(f"[dataset_persistence] brain restore error for {ws_id}: {type(exc).__name__}",
flush=True)
continue
def shutdown_upload() -> None:
"""Signal the upload scheduler to stop and do one final upload.
Tier 3: also does a final brain sync so the latest brain state survives
a Space rebuild.
"""
global _upload_scheduler
_shutdown_event.set()
if _upload_scheduler is not None:
_upload_scheduler.join(timeout=10)
_upload_scheduler = None
if _current_hf_token:
try:
info = get_hf_user(_current_hf_token)
ds_name = dataset_name(info.get("name", ""))
upload_db(_current_hf_token, ds_name)
_upload_all_brains(_current_hf_token, ds_name)
except Exception:
pass