* feat: maintenance flow UX redesign — batch status hub, context strip, detail page upgrades (#85) - Add BatchStatusPage (/flows/:id/batches/:batchId): per-target Start/Resume/View cards, progress bar, 5s polling while in-progress, completion outcome summary - Add BatchStatusCard: handles not-started/in-progress/complete states with step progress for in-progress targets - Add ActiveBatchBanner: amber banner on detail page when a batch is running, links to BatchStatusPage - Add MaintenanceContextStrip: amber strip in ProceduralNavigationPage for maintenance flows showing target name, batch progress (X/Y complete), and Back to Batch nav - Update MaintenanceFlowDetailPage: active batch banner, clickable run history rows with mini progress dots and outcome summaries, Run button loading state, post-launch navigates to BatchStatusPage - Update ProceduralNavigationPage: renders MaintenanceContextStrip between top bar and content when tree_type === 'maintenance'; fetches batch progress once on mount - Add batch_id filter to GET /sessions backend endpoint and SessionListParams frontend type - Add /flows/:id/batches/:batchId route to router Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: session detail page — completion action + outcome summary card - In-progress sessions: amber banner with "Complete Session" button opens SessionOutcomeModal to set outcome/notes/next-steps and finalize - Completed sessions: colored outcome summary card (icon + outcome label + duration + notes + next steps) replaces dense header metadata; "Copy for Ticket" promoted to primary action inside the card - Export toolbar de-emphasized to secondary row of smaller controls below the summary card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add library-page action props to StepCard (edit/delete/save) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: pass library-page action props through StepLibraryBrowser + refreshKey Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: StepFormModal wrapper + submitLabel/isSubmitting props on StepForm Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Step Library page — create, edit, delete, save-to-library Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add RuntimeStep union type for procedural custom steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: StepChecklist accepts RuntimeStep[], renders amber Custom badge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: StepDetail accepts RuntimeStep, renders Custom Step badge for custom steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: custom step insertion in procedural flow sessions Engineers can add custom steps inline during execution. Steps are persisted to session.custom_steps and restored on resume. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: suppress StepFeedback on custom steps, fix resume stepState seeding, functional updater for step index Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add tree forking UI design doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add tree fork UI implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add ForkInfo type and fork fields to Tree/TreeListItem Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: align ForkInfo type with backend schema, remove redundant fork fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: ForkInfo placement, required fork_info field, add JSDoc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add ForkModal component with name and reason fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: ForkModal accessibility and UX (escape, click-outside, labels, maxLength) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: open ForkModal on fork action in TreeLibraryPage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add ForkModal to MyTreesPage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: show Fork chip badge on forked tree cards Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add flow-to-library step sync design doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add flow-to-library sync implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add sync tracking columns to step_library Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add sync columns and source_tree relationship to StepLibrary model Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add group_label to StepContent, is_flow_synced/source_tree_name to StepLibraryResponse Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: include is_flow_synced and source_tree_name in step list/detail responses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add is_flow_synced and source_tree_name to step list response Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add selectinload and sync fields to search and get_step endpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add step_sync module with extraction and upsert logic Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: safe NOT IN placeholders for asyncpg, add deactivate docstring Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: trigger step library sync on tree publish and deactivate on delete - Call sync_steps_from_tree in update_tree whenever the tree is published (status transitions to 'published' or is already published and structure changes) - Call deactivate_synced_steps_for_tree in delete_tree before db.commit() so the FK SET NULL does not nullify source_tree_id before the WHERE clause runs - Fix ::jsonb cast syntax in step_sync.py (asyncpg rejects :: operator in text() queries; replaced with CAST(:content AS jsonb)) - Add UniqueConstraint('source_tree_id','source_node_id') to StepLibrary model so Base.metadata.create_all (used by tests) creates the constraint that the ON CONFLICT clause in sync_steps_from_tree depends on Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add is_flow_synced and source_tree_name to Step types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: show From Flow badge and lock icon on flow-synced StepCard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: show source flow name in StepDetailModal for synced steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add Library Visibility select to procedural StepEditor Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address code review issues in flow-to-library sync - Fix sync trigger: only fire on publish transition, not every PUT - Add TestSyncOnPublish integration tests (2 tests, 16 total passing) - Add group_label to frontend StepContent interface - Guard Library Visibility select to procedure_step nodes only - Block API edits to flow-synced steps (400 read-only guard) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: handle None author_id in step sync to avoid invalid UUID error When a system/default tree has no author (author_id is None), str(None) produces the literal string 'None' which asyncpg rejects as an invalid UUID for the created_by column. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add ResolutionFlow service account to own default tree steps in library Default/system trees had no author_id (NULL), causing a NOT NULL violation when syncing steps to step_library.created_by on publish. - Add is_service_account flag to users table (migration 4f4137ce) - Add service_account.py: idempotent ensure_service_account() creates noreply@resolutionflow.com with unusable password on startup - Cache service account ID on app.state at lifespan startup - Add get_service_account_id() FastAPI dep (returns None in tests) - sync_steps_from_tree: resolve author_id or service_account_id as created_by - create_tree: set author_id=service_account_id for is_default trees - Migration 1490781700bc: backfill author_id on 31 existing default trees Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
210 lines
7.9 KiB
Python
210 lines
7.9 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from slowapi import _rate_limit_exceeded_handler
|
|
from slowapi.errors import RateLimitExceeded
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import init_db, async_session_maker
|
|
from app.core.logging_config import setup_logging
|
|
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
|
|
from app.core.rate_limit import limiter
|
|
from app.api.router import api_router
|
|
from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations
|
|
from app.core.service_account import ensure_service_account
|
|
|
|
# Initialize logging configuration
|
|
setup_logging()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _configure_seed_module(mod: object, api_url: str, email: str, password: str) -> None:
|
|
"""Set globals on a seed script module."""
|
|
mod.API_BASE_URL = api_url # type: ignore[attr-defined]
|
|
mod.ADMIN_EMAIL = email # type: ignore[attr-defined]
|
|
mod.ADMIN_PASSWORD = password # type: ignore[attr-defined]
|
|
|
|
|
|
async def _seed_users_directly() -> None:
|
|
"""Seed test users directly via DB if they don't exist yet."""
|
|
try:
|
|
from scripts.seed_test_users import main as seed_users
|
|
logger.info("[seed] Seeding test users directly via DB...")
|
|
await seed_users()
|
|
logger.info("[seed] Test users seeded!")
|
|
except Exception as e:
|
|
logger.warning(f"[seed] User seeding failed: {e}")
|
|
raise
|
|
|
|
|
|
async def _seed_trees_background() -> None:
|
|
"""Background task: seed test users + all flows after server is ready."""
|
|
await asyncio.sleep(5) # Wait for server to be fully ready
|
|
port = os.environ.get("PORT", "8000")
|
|
api_url = f"http://127.0.0.1:{port}/api/v1"
|
|
email = "admin@resolutionflow.example.com"
|
|
password = "TestPass123!"
|
|
|
|
try:
|
|
import httpx
|
|
# Try to login — if it fails, seed users first
|
|
async with httpx.AsyncClient(base_url=api_url, timeout=30) as client:
|
|
login_resp = await client.post("/auth/login/json", json={"email": email, "password": password})
|
|
if login_resp.status_code != 200:
|
|
logger.warning("[seed] Admin login failed — seeding users first")
|
|
await _seed_users_directly()
|
|
# Retry login after seeding users
|
|
login_resp = await client.post("/auth/login/json", json={"email": email, "password": password})
|
|
if login_resp.status_code != 200:
|
|
logger.error(f"[seed] Admin login still failing after user seed (status={login_resp.status_code}) — aborting")
|
|
return
|
|
|
|
token = login_resp.json()["access_token"]
|
|
# Check if trees already exist
|
|
trees_resp = await client.get("/trees", headers={"Authorization": f"Bearer {token}"})
|
|
if trees_resp.status_code == 200 and len(trees_resp.json()) > 0:
|
|
logger.info(f"[seed] {len(trees_resp.json())} flows already exist — skipping flow seeding")
|
|
return
|
|
|
|
# No flows yet — run all seed scripts
|
|
seed_scripts = [
|
|
("seed_trees (Tier 1)", "scripts.seed_trees", "seed_database"),
|
|
("seed_trees_v2 (AD/M365/Networking)", "scripts.seed_trees_v2", "seed_database"),
|
|
("seed_procedural_flows", "scripts.seed_procedural_flows", "seed_procedural_flows"),
|
|
("seed_maintenance_flows", "scripts.seed_maintenance_flows", "seed_maintenance_flows"),
|
|
]
|
|
|
|
for label, module_path, func_name in seed_scripts:
|
|
try:
|
|
import importlib
|
|
mod = importlib.import_module(module_path)
|
|
_configure_seed_module(mod, api_url, email, password)
|
|
seed_fn = getattr(mod, func_name)
|
|
logger.info(f"[seed] Running {label}...")
|
|
await seed_fn()
|
|
logger.info(f"[seed] {label} complete!")
|
|
except Exception as e:
|
|
logger.warning(f"[seed] {label} failed (non-fatal): {e}")
|
|
|
|
logger.info("[seed] All flow seeding complete!")
|
|
except Exception as e:
|
|
logger.warning(f"[seed] Flow seeding failed (non-fatal): {e}")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Application lifespan handler."""
|
|
# Startup
|
|
logger.info("Starting ResolutionFlow API server...")
|
|
logger.info(f"Environment: {'Development' if settings.DEBUG else 'Production'}")
|
|
logger.info(f"ALLOW_RAILWAY_ORIGINS: {settings.ALLOW_RAILWAY_ORIGINS}")
|
|
# Note: In production, use Alembic migrations instead of init_db
|
|
# await init_db()
|
|
|
|
# Ensure service account exists and cache its ID for sync operations
|
|
async with async_session_maker() as db:
|
|
service_account_id = await ensure_service_account(db)
|
|
app.state.service_account_id = service_account_id
|
|
logger.info(f"[service_account] Service account ready (id={service_account_id})")
|
|
|
|
# Start maintenance schedule runner + AI conversation cleanup
|
|
scheduler.start()
|
|
async with async_session_maker() as db:
|
|
await load_all_schedules(db)
|
|
scheduler.add_job(
|
|
_cleanup_expired_ai_conversations,
|
|
trigger="interval",
|
|
hours=1,
|
|
id="cleanup_ai_conversations",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Auto-seed trees in background on PR environments
|
|
seed_task = None
|
|
if settings.SEED_ON_DEPLOY:
|
|
logger.info("[seed] SEED_ON_DEPLOY=true — scheduling background tree seeding")
|
|
seed_task = asyncio.create_task(_seed_trees_background())
|
|
|
|
yield
|
|
|
|
# Shutdown
|
|
if seed_task and not seed_task.done():
|
|
seed_task.cancel()
|
|
scheduler.shutdown(wait=False)
|
|
logger.info("Shutting down ResolutionFlow API server...")
|
|
|
|
|
|
app = FastAPI(
|
|
title=settings.APP_NAME,
|
|
description="ResolutionFlow - Take the path MOST traveled. Guided troubleshooting with automatic documentation.",
|
|
version="1.0.0",
|
|
docs_url="/api/docs",
|
|
redoc_url="/api/redoc",
|
|
openapi_url="/api/openapi.json",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
app.state.limiter = limiter
|
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
|
|
# Add logging middleware (BEFORE CORS to log all requests)
|
|
app.add_middleware(ErrorLoggingMiddleware)
|
|
app.add_middleware(RequestLoggingMiddleware)
|
|
|
|
# Configure CORS
|
|
# Note: When ALLOW_RAILWAY_ORIGINS is True, we use allow_origin_regex for Railway domains
|
|
# PLUS the explicit allowed_origins list (for custom domains like app.resolutionflow.com)
|
|
if settings.ALLOW_RAILWAY_ORIGINS:
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.allowed_origins,
|
|
allow_origin_regex=r"https://.*\.up\.railway\.app",
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
expose_headers=["X-Redaction-Mode", "X-Redaction-Summary"],
|
|
)
|
|
else:
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.allowed_origins,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
expose_headers=["X-Redaction-Mode", "X-Redaction-Summary"],
|
|
)
|
|
|
|
# Include API router
|
|
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
"""Root endpoint."""
|
|
return {
|
|
"message": "ResolutionFlow API",
|
|
"docs": "/api/docs",
|
|
"version": "1.0.0"
|
|
}
|
|
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
"""Health check endpoint."""
|
|
return {"status": "healthy"}
|
|
|
|
|
|
if settings.DEBUG:
|
|
@app.get("/debug/cors")
|
|
async def debug_cors():
|
|
"""Debug endpoint to check CORS configuration."""
|
|
return {
|
|
"allow_railway_origins": settings.ALLOW_RAILWAY_ORIGINS,
|
|
"cors_mode": "regex + list" if settings.ALLOW_RAILWAY_ORIGINS else "list",
|
|
"allowed_origins": settings.allowed_origins,
|
|
"railway_regex": r"https://.*\.up\.railway\.app" if settings.ALLOW_RAILWAY_ORIGINS else None
|
|
}
|