diff --git a/.do/app.yaml b/.do/app.yaml
index 16a8bf4..3aaa310 100644
--- a/.do/app.yaml
+++ b/.do/app.yaml
@@ -7,6 +7,12 @@ alerts:
ingress:
rules:
+ # NextAuth OAuth routes → web (must be before /api catch-all)
+ - component:
+ name: web
+ match:
+ path:
+ prefix: /api/auth
# API endpoints → backend
- component:
name: api
@@ -130,3 +136,18 @@ services:
scope: RUN_TIME
type: SECRET
value: REPLACE_WITH_API_KEY
+ - key: GOOGLE_CLIENT_ID
+ scope: RUN_TIME
+ type: SECRET
+ value: REPLACE_WITH_GOOGLE_CLIENT_ID
+ - key: GOOGLE_CLIENT_SECRET
+ scope: RUN_TIME
+ type: SECRET
+ value: REPLACE_WITH_GOOGLE_CLIENT_SECRET
+ - key: AUTH_SECRET
+ scope: RUN_TIME
+ type: SECRET
+ value: REPLACE_WITH_AUTH_SECRET
+ - key: AUTH_URL
+ scope: RUN_TIME
+ value: ${APP_URL}
diff --git a/agent/db/connection.py b/agent/db/connection.py
index aab3662..d04afe1 100644
--- a/agent/db/connection.py
+++ b/agent/db/connection.py
@@ -5,6 +5,7 @@
_pool: asyncpg.Pool | None = None
_pool_lock: asyncio.Lock | None = None
+_users_table_ensured: bool = False
_DEFAULT_POOL_MIN_SIZE = 2
_DEFAULT_POOL_MAX_SIZE = 10
_DEFAULT_POOL_RETRIES = 5
@@ -31,8 +32,28 @@ def _float_env(name: str, default: float, minimum: float = 0.0) -> float:
return default
+async def ensure_users_table(pool: asyncpg.Pool) -> None:
+ """Create the users table if it does not already exist (idempotent)."""
+ async with pool.acquire() as conn:
+ await conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
+ email TEXT UNIQUE NOT NULL,
+ name TEXT,
+ image TEXT,
+ approved BOOLEAN NOT NULL DEFAULT FALSE,
+ domain TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ last_login_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+ """
+ )
+
+
async def get_pool() -> asyncpg.Pool:
- global _pool, _pool_lock
+ global _pool, _pool_lock, _users_table_ensured
if _pool_lock is None:
_pool_lock = asyncio.Lock()
if _pool is not None:
@@ -65,6 +86,16 @@ async def get_pool() -> asyncpg.Pool:
await asyncio.sleep(retry_delay * (attempt + 1))
if _pool is None and last_error is not None:
raise last_error
+ if _pool is not None and not _users_table_ensured:
+ try:
+ await ensure_users_table(_pool)
+ _users_table_ensured = True
+ except Exception:
+ import logging
+
+ logging.getLogger(__name__).warning(
+ "Could not create users table — DB may not be ready yet"
+ )
return _pool
diff --git a/agent/nodes/deployer.py b/agent/nodes/deployer.py
index 0eec788..db22ce5 100644
--- a/agent/nodes/deployer.py
+++ b/agent/nodes/deployer.py
@@ -77,7 +77,7 @@ async def deployer(state: VibeDeployState, config=None) -> dict:
build_validation = state.get("build_validation") or {}
deploy_gate_result = state.get("deploy_gate_result") or {}
- app_name = _build_repo_name(idea)
+ app_name = str(blueprint.get("app_name") or "").strip() or _build_repo_name(idea)
description = idea.get("tagline") or idea.get("problem") or "Generated by vibeDeploy"
deploy_blocker = _get_deploy_blocker(
frontend_code,
@@ -414,12 +414,110 @@ async def _track_do_phase(phase_name: str) -> None:
await _emit_step_start("verified", "Verifying live URL", config=config, live_url=live_url)
url_verification = _verify_live_url(live_url)
- if url_verification.get("root_ok"):
- logger.info("[DEPLOYER] Live URL verified: %s → %s", live_url, url_verification)
- await _emit_step_complete("verified", "Live URL verified", config=config, live_url=live_url)
+ v_score = url_verification.get("verification_score", 0)
+ v_verdict = url_verification.get("verification_verdict", "failed")
+
+ if v_verdict == "verified":
+ logger.info(
+ "[DEPLOYER] Live URL verified (score=%d, verdict=%s): %s",
+ v_score, v_verdict, live_url,
+ )
+ await _emit_step_complete(
+ "verified",
+ f"Live URL verified (score={v_score}/100)",
+ config=config,
+ live_url=live_url,
+ verification_score=v_score,
+ verification_verdict=v_verdict,
+ )
+ elif v_verdict == "partial":
+ logger.warning(
+ "[DEPLOYER] Live URL partially verified (score=%d, verdict=%s): %s",
+ v_score, v_verdict, live_url,
+ )
+ await _emit_step_complete(
+ "verified",
+ f"Live URL partially verified (score={v_score}/100) — basic functionality works, some issues detected",
+ config=config,
+ live_url=live_url,
+ verification_score=v_score,
+ verification_verdict=v_verdict,
+ )
else:
- logger.warning("[DEPLOYER] Live URL verification failed: %s → %s", live_url, url_verification)
- await _emit_step_error("verified", "Live URL verification failed", config=config, live_url=live_url)
+ # verdict == "failed" (score < 50) — attempt repair
+ logger.warning(
+ "[DEPLOYER] Live URL verification FAILED (score=%d, verdict=%s): %s — attempting repair",
+ v_score, v_verdict, live_url,
+ )
+ await _emit_step_error(
+ "verified",
+ f"Live URL verification failed (score={v_score}/100) — attempting repair",
+ config=config,
+ live_url=live_url,
+ verification_score=v_score,
+ verification_verdict=v_verdict,
+ )
+
+ # Build a diagnostic summary from the verification result for the repair LLM
+ diag_lines = [f"Verification score: {v_score}/100 (verdict: {v_verdict})"]
+ if not url_verification.get("root_ok"):
+ diag_lines.append("CRITICAL: Root page did not return 200 or returned <100 bytes")
+ if not url_verification.get("health_ok"):
+ diag_lines.append("CRITICAL: /health endpoint did not return 200")
+ cs = url_verification.get("content_signals", {})
+ if not cs.get("has_title"):
+ diag_lines.append("Missing:
tag in HTML")
+ if not cs.get("has_semantic_html"):
+ diag_lines.append("Missing: semantic HTML elements (h1, nav, main)")
+ if not cs.get("has_interactive_elements"):
+ diag_lines.append(f"Missing: interactive elements (only {cs.get('interactive_count', 0)} found, need >=3)")
+ if cs.get("broken_images"):
+ diag_lines.append(f"Broken images: {cs['broken_images'][:3]}")
+ failed_eps = [
+ k for k, v in url_verification.get("endpoint_results", {}).items()
+ if not v.get("ok")
+ ]
+ if failed_eps:
+ diag_lines.append(f"Failed API endpoints: {', '.join(failed_eps[:5])}")
+ failed_assets = [
+ k for k, v in url_verification.get("asset_checks", {}).items()
+ if not v.get("ok")
+ ]
+ if failed_assets:
+ diag_lines.append(f"Failed assets: {', '.join(failed_assets[:5])}")
+
+ diag_summary = "\n".join(diag_lines)
+ logger.info("[DEPLOYER] Verification diagnostics for repair:\n%s", diag_summary)
+
+ # Attempt one repair cycle using the verification diagnostics
+ error_logs = await get_deploy_error_logs(app_id) if app_id else ""
+ combined_errors = f"=== Live URL Verification Failures ===\n{diag_summary}"
+ if error_logs:
+ combined_errors += f"\n\n=== Deploy Error Logs ===\n{error_logs}"
+
+ fixed_files = await _repair_code_from_errors(all_files, combined_errors)
+ if fixed_files != all_files:
+ all_files = await _prepare_files_for_push(fixed_files)
+ push_result = await push_files(
+ {"full_name": github_full_name},
+ all_files,
+ commit_message="fix: repair from live URL verification failures",
+ )
+ if push_result.get("commit_sha") and app_id:
+ await asyncio.sleep(5)
+ redeploy_result = await redeploy_app(app_id)
+ if redeploy_result.get("status") != "error":
+ repair_url = await wait_for_deployment(app_id)
+ if repair_url:
+ live_url = repair_url
+ # Re-verify after repair
+ url_verification = _verify_live_url(live_url)
+ v_score = url_verification.get("verification_score", 0)
+ v_verdict = url_verification.get("verification_verdict", "failed")
+ logger.info(
+ "[DEPLOYER] Post-repair verification: score=%d, verdict=%s",
+ v_score, v_verdict,
+ )
homepage = live_url or github_repo_url
if github_full_name:
@@ -450,6 +548,8 @@ async def _track_do_phase(phase_name: str) -> None:
"frontend_files": frontend_file_count,
"backend_files": backend_file_count,
"url_verification": url_verification,
+ "verification_score": v_score,
+ "verification_verdict": v_verdict,
**ci_meta,
},
"phase": "deployed",
@@ -1712,9 +1812,30 @@ def _build_repo_name(idea: dict) -> str:
def _verify_live_url(live_url: str) -> dict:
+ """E2E-level verification of a deployed URL.
+
+ Returns a dict containing:
+ - root_ok, health_ok, root_bytes (legacy fields)
+ - content_signals: detailed frontend quality signals
+ - endpoint_results: per-endpoint probe results
+ - asset_checks: CSS/JS asset accessibility results
+ - verification_score: 0-100 composite quality score
+ - verification_verdict: "verified" | "partial" | "failed"
+ """
base = live_url.rstrip("/")
- result = {"root_ok": False, "health_ok": False, "root_bytes": 0, "endpoint_results": {}}
+ result: dict = {
+ "root_ok": False,
+ "health_ok": False,
+ "root_bytes": 0,
+ "endpoint_results": {},
+ "asset_checks": {},
+ "verification_score": 0,
+ "verification_verdict": "failed",
+ }
+ score = 0
+ root_html = ""
+ # ── Phase 0: Wait for root + health with retries ──────────────────
for _ in range(6):
for path, key in [("/", "root"), ("/health", "health"), ("/api/health", "health")]:
try:
@@ -1727,6 +1848,8 @@ def _verify_live_url(live_url: str) -> dict:
if key == "root":
result["root_ok"] = resp.status == 200 and len(body) > 100
result["root_bytes"] = max(result["root_bytes"], len(body))
+ if resp.status == 200:
+ root_html = body.decode("utf-8", errors="replace")
else:
result["health_ok"] = resp.status == 200
except (error.HTTPError, error.URLError, OSError):
@@ -1735,36 +1858,242 @@ def _verify_live_url(live_url: str) -> dict:
break
time.sleep(10)
- # Content quality verification
- content_signals = {"has_title": False, "has_components": False, "has_styles": False, "content_length_ok": False}
- if result["root_ok"] and result["root_bytes"] > 0:
- try:
- req = request.Request(
- f"{base}/",
- headers={"User-Agent": "vibeDeploy-verifier/1.0"},
+ # Score: root page loads (200, >100 bytes): +15
+ if result["root_ok"]:
+ score += 15
+ logger.info("[DEPLOYER][VERIFY] Root page OK (%d bytes) → +15", result["root_bytes"])
+ else:
+ logger.warning("[DEPLOYER][VERIFY] Root page FAILED (bytes=%d)", result["root_bytes"])
+
+ # Score: health endpoint: +10
+ if result["health_ok"]:
+ score += 10
+ logger.info("[DEPLOYER][VERIFY] Health endpoint OK → +10")
+ else:
+ logger.warning("[DEPLOYER][VERIFY] Health endpoint FAILED")
+
+ # ── Phase 1: Frontend rendering quality check ─────────────────────
+ content_signals: dict = {
+ "has_title": False,
+ "has_components": False,
+ "has_styles": False,
+ "content_length_ok": False,
+ "has_spa_framework": False,
+ "has_semantic_html": False,
+ "has_interactive_elements": False,
+ "has_meta_tags": False,
+ "broken_images": [],
+ "interactive_count": 0,
+ "semantic_elements": [],
+ }
+
+ if root_html:
+ html_lower = root_html.lower()
+
+ # Basic content signals
+ content_signals["content_length_ok"] = len(root_html) > 500
+ content_signals["has_title"] = bool(re.search(r"[^<]{3,}", root_html, re.IGNORECASE))
+ content_signals["has_components"] = root_html.count("= 3 or root_html.count("= 2
+ content_signals["has_styles"] = (
+ "tailwind" in html_lower or "globals.css" in html_lower or "