Files
bookapp/web/routes/run.py
Mike Wichers af2050160e Add run deletion with filesystem cleanup
- New POST /run/<id>/delete route removes run from DB and deletes run directory
- Only allows deletion of non-active runs (blocks running/queued)
- Delete Run button shown in run_details.html header for non-active runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:04:44 -05:00

460 lines
18 KiB
Python

import os
import json
import shutil
import markdown
from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, send_from_directory
from flask_login import login_required, current_user
from web.db import db, Run, LogEntry
from core import utils
from ai import models as ai_models
from ai import setup as ai_setup
from story import editor as story_editor
from story import bible_tracker, style_persona
from export import exporter
from web.tasks import huey, regenerate_artifacts_task, rewrite_chapter_task
run_bp = Blueprint('run', __name__)
@run_bp.route('/run/<int:id>')
@login_required
def view_run(id):
run = db.session.get(Run, id)
if not run: return "Run not found", 404
if run.project.user_id != current_user.id: return "Unauthorized", 403
log_content = ""
logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all()
if logs:
log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs])
elif run.log_file and os.path.exists(run.log_file):
with open(run.log_file, 'r') as f: log_content = f.read()
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
books_data = []
if os.path.exists(run_dir):
subdirs = utils.get_sorted_book_folders(run_dir)
for d in subdirs:
b_path = os.path.join(run_dir, d)
b_info = {'folder': d, 'artifacts': [], 'cover': None, 'blurb': ''}
for f in os.listdir(b_path):
if f.lower().endswith(('.epub', '.docx')):
b_info['artifacts'].append({'name': f, 'path': os.path.join(d, f).replace("\\", "/")})
if os.path.exists(os.path.join(b_path, "cover.png")):
b_info['cover'] = os.path.join(d, "cover.png").replace("\\", "/")
blurb_p = os.path.join(b_path, "blurb.txt")
if os.path.exists(blurb_p):
with open(blurb_p, 'r', encoding='utf-8', errors='ignore') as f: b_info['blurb'] = f.read()
books_data.append(b_info)
bible_path = os.path.join(run.project.folder_path, "bible.json")
bible_data = utils.load_json(bible_path)
tracking = {"events": [], "characters": {}, "content_warnings": []}
book_dir = os.path.join(run_dir, books_data[-1]['folder']) if books_data else run_dir
if os.path.exists(book_dir):
t_ev = os.path.join(book_dir, "tracking_events.json")
t_ch = os.path.join(book_dir, "tracking_characters.json")
t_wn = os.path.join(book_dir, "tracking_warnings.json")
if os.path.exists(t_ev): tracking['events'] = utils.load_json(t_ev) or []
if os.path.exists(t_ch): tracking['characters'] = utils.load_json(t_ch) or {}
if os.path.exists(t_wn): tracking['content_warnings'] = utils.load_json(t_wn) or []
return render_template('run_details.html', run=run, log_content=log_content, books=books_data, bible=bible_data, tracking=tracking)
@run_bp.route('/run/<int:id>/status')
@login_required
def run_status(id):
import sqlite3 as _sql3
import sys as _sys
from core import config as _cfg
# Expire session so we always read fresh values from disk (not cached state)
db.session.expire_all()
run = db.session.get(Run, id)
if not run:
return {"status": "not_found", "log": "", "cost": 0, "percent": 0, "start_time": None}, 404
log_content = ""
last_log = None
# 1. ORM query for log entries
logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all()
if logs:
log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs])
last_log = logs[-1]
# 2. Raw sqlite3 fallback — bypasses any SQLAlchemy session caching
if not log_content:
try:
_db_path = os.path.join(_cfg.DATA_DIR, "bookapp.db")
with _sql3.connect(_db_path, timeout=5) as _conn:
_rows = _conn.execute(
"SELECT timestamp, phase, message FROM log_entry WHERE run_id = ? ORDER BY timestamp",
(id,)
).fetchall()
if _rows:
log_content = "\n".join([
f"[{str(r[0])[:8]}] {str(r[1]):<15} | {r[2]}"
for r in _rows
])
except Exception as _e:
print(f"[run_status] sqlite3 fallback error for run {id}: {type(_e).__name__}: {_e}", flush=True, file=_sys.stdout)
# 3. File fallback — reads the log file written by the task worker
if not log_content:
try:
if run.log_file and os.path.exists(run.log_file):
with open(run.log_file, 'r', encoding='utf-8', errors='replace') as f:
log_content = f.read()
elif run.status in ['queued', 'running']:
project_folder = run.project.folder_path
# Temp log written at task start (before run dir exists)
temp_log = os.path.join(project_folder, f"system_log_{run.id}.txt")
if os.path.exists(temp_log):
with open(temp_log, 'r', encoding='utf-8', errors='replace') as f:
log_content = f.read()
else:
# Also check inside the run directory (after engine creates it)
run_dir = os.path.join(project_folder, "runs", f"run_{run.id}")
console_log = os.path.join(run_dir, "web_console.log")
if os.path.exists(console_log):
with open(console_log, 'r', encoding='utf-8', errors='replace') as f:
log_content = f.read()
except Exception as _e:
print(f"[run_status] file fallback error for run {id}: {type(_e).__name__}: {_e}", flush=True, file=_sys.stdout)
response = {
"status": run.status,
"log": log_content,
"cost": run.cost,
"percent": run.progress,
"start_time": run.start_time.timestamp() if run.start_time else None,
"server_timestamp": datetime.utcnow().isoformat() + "Z",
"db_log_count": len(logs),
"latest_log_timestamp": last_log.timestamp.isoformat() if last_log else None,
}
if last_log:
response["progress"] = {
"phase": last_log.phase,
"message": last_log.message,
"timestamp": last_log.timestamp.timestamp()
}
return response
@run_bp.route('/project/<int:run_id>/download')
@login_required
def download_artifact(run_id):
filename = request.args.get('file')
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if not filename: return "Missing filename", 400
if os.path.isabs(filename) or ".." in os.path.normpath(filename) or ":" in filename:
return "Invalid filename", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
if not os.path.exists(os.path.join(run_dir, filename)) and os.path.exists(run_dir):
subdirs = utils.get_sorted_book_folders(run_dir)
for d in subdirs:
possible_path = os.path.join(d, filename)
if os.path.exists(os.path.join(run_dir, possible_path)):
filename = possible_path
break
return send_from_directory(run_dir, filename, as_attachment=True)
@run_bp.route('/project/<int:run_id>/read/<string:book_folder>')
@login_required
def read_book(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
book_path = os.path.join(run_dir, book_folder)
ms_path = os.path.join(book_path, "manuscript.json")
if not os.path.exists(ms_path):
flash("Manuscript not found.")
return redirect(url_for('run.view_run', id=run_id))
manuscript = utils.load_json(ms_path)
manuscript.sort(key=utils.chapter_sort_key)
for ch in manuscript:
ch['html_content'] = markdown.markdown(ch.get('content', ''))
return render_template('read_book.html', run=run, book_folder=book_folder, manuscript=manuscript)
@run_bp.route('/project/<int:run_id>/save_chapter', methods=['POST'])
@login_required
def save_chapter(run_id):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status == 'running':
return "Cannot edit chapter while run is active.", 409
book_folder = request.form.get('book_folder')
chap_num_raw = request.form.get('chapter_num')
try: chap_num = int(chap_num_raw)
except: chap_num = chap_num_raw
new_content = request.form.get('content')
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
ms_path = os.path.join(run_dir, book_folder, "manuscript.json")
if os.path.exists(ms_path):
ms = utils.load_json(ms_path)
for ch in ms:
if str(ch.get('num')) == str(chap_num):
ch['content'] = new_content
break
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
book_path = os.path.join(run_dir, book_folder)
bp_path = os.path.join(book_path, "final_blueprint.json")
if os.path.exists(bp_path):
bp = utils.load_json(bp_path)
exporter.compile_files(bp, ms, book_path)
return "Saved", 200
return "Error", 500
@run_bp.route('/project/<int:run_id>/check_consistency/<string:book_folder>')
@login_required
def check_consistency(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
book_path = os.path.join(run_dir, book_folder)
bp = utils.load_json(os.path.join(book_path, "final_blueprint.json"))
ms = utils.load_json(os.path.join(book_path, "manuscript.json"))
if not bp or not ms:
return "Data files missing or corrupt.", 404
try: ai_setup.init_models()
except: pass
report = story_editor.analyze_consistency(bp, ms, book_path)
return render_template('consistency_report.html', report=report, run=run, book_folder=book_folder)
@run_bp.route('/project/<int:run_id>/sync_book/<string:book_folder>', methods=['POST'])
@login_required
def sync_book_metadata(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status == 'running':
flash("Cannot sync metadata while run is active.")
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
book_path = os.path.join(run_dir, book_folder)
ms_path = os.path.join(book_path, "manuscript.json")
bp_path = os.path.join(book_path, "final_blueprint.json")
if os.path.exists(ms_path) and os.path.exists(bp_path):
ms = utils.load_json(ms_path)
bp = utils.load_json(bp_path)
if not ms or not bp:
flash("Data files corrupt.")
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
try: ai_setup.init_models()
except: pass
bp = bible_tracker.harvest_metadata(bp, book_path, ms)
tracking_path = os.path.join(book_path, "tracking_characters.json")
if os.path.exists(tracking_path):
tracking_chars = utils.load_json(tracking_path) or {}
updated_tracking = False
for c in bp.get('characters', []):
if c.get('name') and c['name'] not in tracking_chars:
tracking_chars[c['name']] = {"descriptors": [c.get('description', '')], "likes_dislikes": [], "last_worn": "Unknown"}
updated_tracking = True
if updated_tracking:
with open(tracking_path, 'w') as f: json.dump(tracking_chars, f, indent=2)
style_persona.update_persona_sample(bp, book_path)
with open(bp_path, 'w') as f: json.dump(bp, f, indent=2)
flash("Metadata synced. Future generations will respect your edits.")
else:
flash("Files not found.")
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
@run_bp.route('/project/<int:run_id>/rewrite_chapter', methods=['POST'])
@login_required
def rewrite_chapter(run_id):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id:
return {"error": "Unauthorized"}, 403
if run.status == 'running':
return {"error": "Cannot rewrite while run is active."}, 409
data = request.json
book_folder = data.get('book_folder')
chap_num = data.get('chapter_num')
instruction = data.get('instruction')
if not book_folder or chap_num is None or not instruction:
return {"error": "Missing parameters"}, 400
if "/" in book_folder or "\\" in book_folder or ".." in book_folder: return {"error": "Invalid book folder"}, 400
try: chap_num = int(chap_num)
except: pass
task = rewrite_chapter_task(run.id, run.project.folder_path, book_folder, chap_num, instruction)
session['rewrite_task_id'] = task.id
return {"status": "queued", "task_id": task.id}, 202
@run_bp.route('/task_status/<string:task_id>')
@login_required
def get_task_status(task_id):
try:
task_result = huey.result(task_id, preserve=True)
except Exception as e:
return {"status": "completed", "success": False, "error": str(e)}
if task_result is None:
return {"status": "running"}
else:
return {"status": "completed", "success": task_result}
@run_bp.route('/project/<int:run_id>/revise_book/<string:book_folder>', methods=['POST'])
@login_required
def revise_book(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id:
flash("Unauthorized.")
return redirect(url_for('run.view_run', id=run_id))
if run.status == 'running':
flash("A run is already active. Please wait for it to finish.")
return redirect(url_for('run.view_run', id=run_id))
instruction = request.form.get('instruction', '').strip()
if not instruction:
flash("Please provide an instruction describing what to fix.")
return redirect(url_for('run.check_consistency', run_id=run_id, book_folder=book_folder))
bible_path = os.path.join(run.project.folder_path, "bible.json")
if not os.path.exists(bible_path):
flash("Bible file not found. Cannot start revision.")
return redirect(url_for('run.view_run', id=run_id))
new_run = Run(project_id=run.project_id, status='queued', start_time=datetime.utcnow())
db.session.add(new_run)
db.session.commit()
from web.tasks import generate_book_task
generate_book_task(new_run.id, run.project.folder_path, bible_path, feedback=instruction, source_run_id=run.id)
flash(f"Book revision queued. Instruction: '{instruction[:80]}...' — a new run has been started.")
return redirect(url_for('run.view_run', id=new_run.id))
@run_bp.route('/run/<int:id>/delete', methods=['POST'])
@login_required
def delete_run(id):
run = db.session.get(Run, id)
if not run: return "Run not found", 404
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status in ['running', 'queued']:
flash("Cannot delete an active run. Stop it first.")
return redirect(url_for('run.view_run', id=id))
project_id = run.project_id
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
if os.path.exists(run_dir):
shutil.rmtree(run_dir)
db.session.delete(run)
db.session.commit()
flash(f"Run #{id} deleted successfully.")
return redirect(url_for('project.view_project', id=project_id))
@run_bp.route('/run/<int:id>/download_bible')
@login_required
def download_bible(id):
run = db.session.get(Run, id)
if not run: return "Run not found", 404
if run.project.user_id != current_user.id: return "Unauthorized", 403
bible_path = os.path.join(run.project.folder_path, "bible.json")
if not os.path.exists(bible_path):
return "Bible file not found", 404
safe_name = utils.sanitize_filename(run.project.name or "project")
download_name = f"bible_{safe_name}.json"
return send_from_directory(
os.path.dirname(bible_path),
os.path.basename(bible_path),
as_attachment=True,
download_name=download_name
)
@run_bp.route('/project/<int:run_id>/regenerate_artifacts', methods=['POST'])
@login_required
def regenerate_artifacts(run_id):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status == 'running':
flash("Run is already active. Please wait for it to finish.")
return redirect(url_for('run.view_run', id=run_id))
feedback = request.form.get('feedback')
run.status = 'queued'
db.session.commit()
regenerate_artifacts_task(run_id, run.project.folder_path, feedback=feedback)
flash("Regenerating cover and files with updated metadata...")
return redirect(url_for('run.view_run', id=run_id))