- web/app.py: Startup banner to docker logs (Python version, platform, Huey version, DB paths). All print() calls now flush=True so Docker captures them immediately. Emoji-free for robust stdout encoding. Startup now detects orphaned queued runs (queue empty but DB queued) and resets them to 'failed' so the UI does not stay stuck on reload. Huey logging configured at INFO level so task pick-up/completion appears in `docker logs`. Consumer skip reason logged explicitly. - web/tasks.py: generate_book_task now emits [TASK run=N] lines to stdout (docker logs) at pick-up, log-file creation, DB status update, and on error (with full traceback) so failures are always visible. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
182 lines
6.9 KiB
Python
182 lines
6.9 KiB
Python
import os
|
|
import sys
|
|
import platform
|
|
from datetime import datetime
|
|
from sqlalchemy import text
|
|
from flask import Flask
|
|
from flask_login import LoginManager
|
|
from werkzeug.security import generate_password_hash
|
|
from web.db import db, User, Run
|
|
from web.tasks import huey
|
|
from core import config
|
|
|
|
# Ensure stdout is UTF-8 in all environments (Docker, Windows, Raspberry Pi)
|
|
if hasattr(sys.stdout, 'reconfigure'):
|
|
try:
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
except Exception:
|
|
pass
|
|
|
|
def _log(msg):
|
|
"""Print to stdout with flush so Docker logs capture it immediately."""
|
|
print(msg, flush=True)
|
|
|
|
|
|
# Calculate paths relative to this file (web/app.py -> project root is two levels up)
|
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
TEMPLATE_DIR = os.path.join(BASE_DIR, 'templates')
|
|
|
|
app = Flask(__name__, template_folder=TEMPLATE_DIR)
|
|
app.url_map.strict_slashes = False
|
|
app.config['SECRET_KEY'] = config.FLASK_SECRET
|
|
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.DATA_DIR, "bookapp.db")}'
|
|
|
|
db.init_app(app)
|
|
|
|
login_manager = LoginManager()
|
|
login_manager.login_view = 'auth.login'
|
|
login_manager.init_app(app)
|
|
|
|
|
|
@login_manager.user_loader
|
|
def load_user(user_id):
|
|
return db.session.get(User, int(user_id))
|
|
|
|
|
|
@app.context_processor
|
|
def inject_globals():
|
|
return dict(app_version=config.VERSION)
|
|
|
|
|
|
# Register Blueprints
|
|
from web.routes.auth import auth_bp
|
|
from web.routes.project import project_bp
|
|
from web.routes.run import run_bp
|
|
from web.routes.persona import persona_bp
|
|
from web.routes.admin import admin_bp
|
|
|
|
app.register_blueprint(auth_bp)
|
|
app.register_blueprint(project_bp)
|
|
app.register_blueprint(run_bp)
|
|
app.register_blueprint(persona_bp)
|
|
app.register_blueprint(admin_bp)
|
|
|
|
|
|
# --- STARTUP DIAGNOSTIC BANNER ---
|
|
_log("=" * 60)
|
|
_log(f"BookApp v{config.VERSION} starting up")
|
|
_log(f" Python : {sys.version}")
|
|
_log(f" Platform : {platform.platform()}")
|
|
_log(f" Data dir : {config.DATA_DIR}")
|
|
_log(f" Queue db : {os.path.join(config.DATA_DIR, 'queue.db')}")
|
|
_log(f" App db : {os.path.join(config.DATA_DIR, 'bookapp.db')}")
|
|
try:
|
|
import huey as _huey_pkg
|
|
_log(f" Huey : {_huey_pkg.__version__}")
|
|
except Exception:
|
|
_log(" Huey : (version unknown)")
|
|
_log("=" * 60)
|
|
|
|
|
|
# --- SETUP ---
|
|
with app.app_context():
|
|
db.create_all()
|
|
|
|
# Auto-create Admin from Environment Variables (Docker/Portainer Setup)
|
|
if config.ADMIN_USER and config.ADMIN_PASSWORD:
|
|
admin = User.query.filter_by(username=config.ADMIN_USER).first()
|
|
if not admin:
|
|
_log(f"System: Creating Admin User '{config.ADMIN_USER}' from environment variables.")
|
|
admin = User(username=config.ADMIN_USER, password=generate_password_hash(config.ADMIN_PASSWORD, method='pbkdf2:sha256'), is_admin=True)
|
|
db.session.add(admin)
|
|
db.session.commit()
|
|
else:
|
|
_log(f"System: Syncing Admin User '{config.ADMIN_USER}' settings from environment.")
|
|
if not admin.is_admin: admin.is_admin = True
|
|
admin.password = generate_password_hash(config.ADMIN_PASSWORD, method='pbkdf2:sha256')
|
|
db.session.add(admin)
|
|
db.session.commit()
|
|
elif not User.query.filter_by(is_admin=True).first():
|
|
_log("System: No Admin credentials found in environment variables. Admin account not created.")
|
|
|
|
# Migration: Add 'progress' column if missing
|
|
try:
|
|
with db.engine.connect() as conn:
|
|
conn.execute(text("ALTER TABLE run ADD COLUMN progress INTEGER DEFAULT 0"))
|
|
conn.commit()
|
|
_log("System: Added 'progress' column to Run table.")
|
|
except: pass
|
|
|
|
# Reset stuck runs on startup
|
|
try:
|
|
stuck_runs = Run.query.filter_by(status='running').all()
|
|
if stuck_runs:
|
|
_log(f"System: Found {len(stuck_runs)} stuck run(s) — resetting to 'failed'.")
|
|
for r in stuck_runs:
|
|
r.status = 'failed'
|
|
r.end_time = datetime.utcnow()
|
|
db.session.commit()
|
|
# Also reset stuck 'queued' runs whose task entry was lost from queue.db
|
|
import sqlite3 as _sqlite3
|
|
_queue_path = os.path.join(config.DATA_DIR, 'queue.db')
|
|
if os.path.exists(_queue_path):
|
|
with _sqlite3.connect(_queue_path, timeout=5) as _qconn:
|
|
pending_count = _qconn.execute("SELECT COUNT(*) FROM task").fetchone()[0]
|
|
queued_runs = Run.query.filter_by(status='queued').count()
|
|
_log(f"System: Queue has {pending_count} pending task(s), DB has {queued_runs} queued run(s).")
|
|
if queued_runs > 0 and pending_count == 0:
|
|
_log("System: WARNING — queued runs exist but queue is empty (tasks lost). Resetting to 'failed'.")
|
|
Run.query.filter_by(status='queued').update({'status': 'failed', 'end_time': datetime.utcnow()})
|
|
db.session.commit()
|
|
except Exception as e:
|
|
_log(f"System: Startup cleanup error: {e}")
|
|
|
|
|
|
# --- HUEY CONSUMER ---
|
|
# Start the Huey task consumer in a background thread whenever the app loads.
|
|
# Guard against the Werkzeug reloader spawning a second consumer in the child process,
|
|
# and against test runners or importers that should not start background workers.
|
|
import threading as _threading
|
|
|
|
def _start_huey_consumer():
|
|
import logging as _logging
|
|
# INFO level so task pick-up/completion appears in docker logs
|
|
_logging.basicConfig(
|
|
level=_logging.INFO,
|
|
format='[%(asctime)s] HUEY %(levelname)s | %(message)s',
|
|
datefmt='%H:%M:%S',
|
|
stream=sys.stdout,
|
|
force=True,
|
|
)
|
|
try:
|
|
from huey.consumer import Consumer
|
|
# NOTE: Huey 2.6.0 does NOT accept a `loglevel` kwarg — omit it.
|
|
consumer = Consumer(huey, workers=1, worker_type='thread')
|
|
_log("System: Huey task consumer started successfully.")
|
|
consumer.run() # blocks until app exits
|
|
except Exception as e:
|
|
msg = f"System: Huey consumer FAILED to start: {type(e).__name__}: {e}"
|
|
_log(msg)
|
|
# Also write to a persistent file for diagnosis when stdout is piped away
|
|
try:
|
|
_err_path = os.path.join(config.DATA_DIR, "consumer_error.log")
|
|
with open(_err_path, 'a', encoding='utf-8') as _f:
|
|
_f.write(f"[{datetime.utcnow().isoformat()}] {msg}\n")
|
|
except Exception:
|
|
pass
|
|
|
|
_is_reloader_child = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
|
|
_is_testing = os.environ.get('FLASK_TESTING') == '1'
|
|
|
|
if not _is_reloader_child and not _is_testing:
|
|
_log("System: Launching Huey consumer thread...")
|
|
_huey_thread = _threading.Thread(target=_start_huey_consumer, daemon=True, name="huey-consumer")
|
|
_huey_thread.start()
|
|
else:
|
|
_log(f"System: Skipping Huey consumer (WERKZEUG_RUN_MAIN={os.environ.get('WERKZEUG_RUN_MAIN')}, FLASK_TESTING={os.environ.get('FLASK_TESTING')}).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host='0.0.0.0', port=5000, debug=False)
|