Files
ALwrity/backend/services/startup_health.py
ajaysi 928c2f20aa fix: WYSIWYG editor, content generation, and writing assistant bug fixes
- Fix text selection menu not showing: wire contentRef via inputRef on multiline TextField
- Fix blog title not truncating: add min-w-0 for flex item overflow
- Fix outline generation 500: escape curly braces in f-string prompt template
- Fix content generation 'NoneType not callable': replace SessionLocal() with get_session_for_user(), add db param to MediumBlogGenerator, fix signature mismatch in database_task_manager
- Fix writing assistant suggest 500: add auth + user_id to API endpoint and service, replace sync requests with httpx.AsyncClient
- Fix hallucination detector 404: explicitly include router in main.py and app.py
- Fix missing error_data in task failure responses
- Hide CopilotKit web inspector button
- Remove hardcoded fallback suggestions from SmartTypingAssist
- Fix stale closure refs in SmartTypingAssist handleTypingChange
- Add two-column editor layout, stats bar, section hover menu
- Various subscription, billing, and research module improvements
2026-05-14 09:11:51 +05:30

337 lines
12 KiB
Python

import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import FastAPI
from fastapi.routing import APIRoute
from loguru import logger
from sqlalchemy import inspect, text
from services.database import (
WORKSPACE_DIR,
get_all_user_ids,
get_engine_for_user,
get_session_for_user,
get_user_db_path,
init_database,
default_engine,
)
from services.user_api_key_context import get_user_api_keys
_REQUIRED_SCHEMA: Dict[str, List[str]] = {
"onboarding_sessions": ["id", "user_id", "updated_at"],
"daily_workflow_plans": ["id", "user_id", "generation_mode", "fallback_used"],
}
_STARTUP_STATUS: Dict[str, Any] = {
"status": "unknown",
"mode": "multi_tenant" if default_engine is None else "single_tenant",
"checks": [],
"errors": [],
"warnings": [],
"checked_at": None,
}
def _env_true(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
def should_fail_fast() -> bool:
if os.getenv("ALWRITY_FAIL_FAST_STARTUP") is not None:
return _env_true("ALWRITY_FAIL_FAST_STARTUP", default=False)
app_env = os.getenv("APP_ENV", os.getenv("ENV", "")).strip().lower()
return app_env in {"prod", "production"}
def _record_check(checks: List[Dict[str, Any]], name: str, ok: bool, detail: str) -> None:
checks.append({"name": name, "ok": ok, "detail": detail})
def _check_workspace_root(checks: List[Dict[str, Any]], errors: List[str]) -> None:
workspace = Path(WORKSPACE_DIR)
if not workspace.exists():
errors.append(f"Workspace root does not exist: {workspace}")
_record_check(checks, "workspace_root_exists", False, str(workspace))
return
_record_check(checks, "workspace_root_exists", True, str(workspace))
if not os.access(workspace, os.W_OK):
errors.append(f"Workspace root is not writable: {workspace}")
_record_check(checks, "workspace_root_writable", False, str(workspace))
return
probe_file = workspace / ".startup_health_write_probe"
try:
probe_file.write_text("ok", encoding="utf-8")
probe_file.unlink(missing_ok=True)
_record_check(checks, "workspace_root_writable", True, "write probe passed")
except Exception as exc:
errors.append(f"Workspace root write probe failed: {exc}")
_record_check(checks, "workspace_root_writable", False, f"write probe failed: {exc}")
def _check_schema_for_user(user_id: str, checks: List[Dict[str, Any]], errors: List[str]) -> None:
engine = get_engine_for_user(user_id)
inspector = inspect(engine)
for table, columns in _REQUIRED_SCHEMA.items():
if not inspector.has_table(table):
errors.append(f"Missing required table '{table}' in tenant DB for user '{user_id}'")
_record_check(checks, f"schema_{table}", False, f"table missing for {user_id}")
continue
existing_columns = {col["name"] for col in inspector.get_columns(table)}
missing_columns = [col for col in columns if col not in existing_columns]
if missing_columns:
errors.append(
f"Missing required columns in '{table}' for user '{user_id}': {', '.join(missing_columns)}"
)
_record_check(
checks,
f"schema_{table}",
False,
f"missing columns for {user_id}: {', '.join(missing_columns)}",
)
else:
_record_check(checks, f"schema_{table}", True, f"schema ok for {user_id}")
def _check_db_access(checks: List[Dict[str, Any]], errors: List[str], warnings: List[str]) -> Optional[str]:
if default_engine is not None:
try:
init_database()
with default_engine.connect() as conn:
conn.execute(text("SELECT 1"))
_record_check(checks, "single_tenant_db_connectivity", True, "SELECT 1 succeeded")
return "single_tenant"
except Exception as exc:
errors.append(f"Single-tenant database check failed: {exc}")
_record_check(checks, "single_tenant_db_connectivity", False, str(exc))
return None
user_ids = get_all_user_ids()
candidate_user = user_ids[0] if user_ids else "startup_synthetic"
try:
db_path = get_user_db_path(candidate_user)
_record_check(checks, "tenant_db_path_resolution", True, f"{candidate_user} -> {db_path}")
except Exception as exc:
errors.append(f"Tenant DB path resolution failed: {exc}")
_record_check(checks, "tenant_db_path_resolution", False, str(exc))
return None
try:
session = get_session_for_user(candidate_user)
if not session:
raise RuntimeError("session creation returned None")
session.execute(text("SELECT 1"))
_record_check(checks, "tenant_session_create", True, f"session opened for {candidate_user}")
session.close()
except Exception as exc:
errors.append(f"Tenant DB open/create check failed for '{candidate_user}': {exc}")
_record_check(checks, "tenant_session_create", False, str(exc))
return None
if not user_ids:
warnings.append(
"No existing tenant workspace found during startup; synthetic tenant DB path was used for readiness validation."
)
_check_schema_for_user(candidate_user, checks, errors)
return candidate_user
def _check_production_api_key_loading(
checks: List[Dict[str, Any]],
errors: List[str],
warnings: List[str],
) -> None:
deploy_env = os.getenv("DEPLOY_ENV", "local").strip().lower()
if deploy_env == "local":
_record_check(checks, "production_api_key_loading", True, "skipped in local deploy mode")
return
# Skip when in feature-limited mode (no production API keys needed)
enabled_features = os.getenv("ALWRITY_ENABLED_FEATURES", "all").strip().lower()
if enabled_features and enabled_features not in ("", "all"):
_record_check(checks, "production_api_key_loading", True, f"skipped in feature-limited mode: {enabled_features}")
return
test_tenant_id = os.getenv("ALWRITY_STARTUP_TEST_TENANT_ID", "").strip()
if not test_tenant_id:
message = (
"Missing ALWRITY_STARTUP_TEST_TENANT_ID for production API key startup check."
)
errors.append(message)
_record_check(checks, "production_api_key_loading", False, message)
return
try:
keys = get_user_api_keys(test_tenant_id)
except Exception as exc:
errors.append(
f"Failed to load API keys for startup test tenant '{test_tenant_id}': {exc}"
)
_record_check(checks, "production_api_key_loading", False, str(exc))
return
if not isinstance(keys, dict):
errors.append(
f"API key loader returned invalid payload type for startup test tenant '{test_tenant_id}'."
)
_record_check(checks, "production_api_key_loading", False, "invalid payload type")
return
non_empty_keys = [provider for provider, value in keys.items() if value]
if not non_empty_keys:
errors.append(
f"No API keys could be loaded for startup test tenant '{test_tenant_id}'."
)
_record_check(checks, "production_api_key_loading", False, "no non-empty keys loaded")
return
warning = None
if len(non_empty_keys) < len(keys):
warning = (
f"Startup test tenant '{test_tenant_id}' has {len(non_empty_keys)}/{len(keys)} non-empty API keys."
)
warnings.append(warning)
detail = f"loaded {len(non_empty_keys)} non-empty keys for tenant {test_tenant_id}"
if warning:
detail = f"{detail}; {warning}"
_record_check(checks, "production_api_key_loading", True, detail)
def _is_demo_mode() -> bool:
app_env = os.getenv("APP_ENV", os.getenv("ENV", os.getenv("DEPLOY_ENV", ""))).strip().lower()
if app_env == "demo":
return True
return _env_true("ALWRITY_DEMO_MODE", default=False)
def _check_required_demo_routes(
app: Optional[FastAPI],
checks: List[Dict[str, Any]],
errors: List[str],
) -> None:
if not _is_demo_mode():
_record_check(
checks,
"demo_required_routes",
True,
"Skipped (not in demo mode). Set APP_ENV=demo or ALWRITY_DEMO_MODE=true to enforce.",
)
return
if app is None:
errors.append(
"Demo startup route check could not run because FastAPI app context was not provided to startup health routine."
)
_record_check(checks, "demo_required_routes_context", False, "missing app context")
return
required_routes = {
"/api/subscription/plans": "GET",
"/api/podcast/projects": "GET",
}
available_routes = {
(route.path, method)
for route in app.router.routes
if isinstance(route, APIRoute)
for method in route.methods
}
missing: List[str] = []
for path, method in required_routes.items():
if (path, method) in available_routes:
_record_check(checks, f"demo_route_{path}_{method}", True, "route registered")
else:
missing.append(f"{method} {path}")
_record_check(checks, f"demo_route_{path}_{method}", False, "route missing")
if missing:
errors.append(
"Demo mode startup check failed. Missing required API endpoints: "
f"{', '.join(missing)}. Ensure subscription and podcast routers are imported and included during app setup."
)
def run_startup_health_routine(app: Optional[FastAPI] = None) -> Dict[str, Any]:
checks: List[Dict[str, Any]] = []
errors: List[str] = []
warnings: List[str] = []
_check_workspace_root(checks, errors)
if not errors:
_check_db_access(checks, errors, warnings)
_check_required_demo_routes(app, checks, errors)
if not errors:
_check_production_api_key_loading(checks, errors, warnings)
status = "healthy" if not errors else "failed"
report = {
"status": status,
"mode": "multi_tenant" if default_engine is None else "single_tenant",
"checks": checks,
"errors": errors,
"warnings": warnings,
"checked_at": datetime.now(timezone.utc).isoformat(),
}
_STARTUP_STATUS.update(report)
if errors:
for message in errors:
logger.error(f"Startup readiness check failed: {message}")
for warning in warnings:
logger.warning(f"Startup readiness warning: {warning}")
if errors and should_fail_fast():
raise RuntimeError("Startup readiness checks failed")
return report
def get_startup_status() -> Dict[str, Any]:
return dict(_STARTUP_STATUS)
def readiness_under_auth_context(current_user: Dict[str, Any]) -> Dict[str, Any]:
user_id = (current_user or {}).get("id") or (current_user or {}).get("clerk_user_id")
if not user_id:
return {
"ready": False,
"reason": "missing_user_context",
"detail": "No authenticated user id was provided in auth context.",
}
try:
db_path = get_user_db_path(user_id)
session = get_session_for_user(user_id)
if not session:
raise RuntimeError("Session creation returned None")
session.execute(text("SELECT 1"))
session.close()
return {
"ready": True,
"user_id": user_id,
"tenant_db_path": db_path,
"db_session": "ok",
}
except Exception as exc:
logger.error(f"Readiness auth-context DB check failed for user '{user_id}': {exc}")
return {
"ready": False,
"user_id": user_id,
"tenant_db_path": get_user_db_path(user_id),
"db_session": "failed",
"reason": str(exc),
}