"""HarborForge API — Agent/人类协同任务管理平台""" from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI( title="HarborForge API", description="Agent/人类协同任务管理平台 API", version="0.3.0" ) # CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Health & version (kept at top level) @app.get("/health", tags=["System"]) def health_check(): return {"status": "healthy"} @app.get("/version", tags=["System"]) def version(): return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"} # Register routers from app.api.routers.auth import router as auth_router from app.api.routers.tasks import router as tasks_router from app.api.routers.projects import router as projects_router from app.api.routers.users import router as users_router from app.api.routers.comments import router as comments_router from app.api.routers.webhooks import router as webhooks_router from app.api.routers.misc import router as misc_router from app.api.routers.monitor import router as monitor_router from app.api.routers.milestones import router as milestones_router from app.api.routers.roles import router as roles_router from app.api.routers.proposes import router as proposes_router from app.api.routers.milestone_actions import router as milestone_actions_router app.include_router(auth_router) app.include_router(tasks_router) app.include_router(projects_router) app.include_router(users_router) app.include_router(comments_router) app.include_router(webhooks_router) app.include_router(misc_router) app.include_router(monitor_router) app.include_router(milestones_router) app.include_router(roles_router) app.include_router(proposes_router) app.include_router(milestone_actions_router) # Auto schema migration for lightweight deployments def _migrate_schema(): from sqlalchemy import text from app.core.config import SessionLocal def _has_table(db, table_name: str) -> bool: return db.execute(text("SHOW TABLES LIKE :table_name"), {"table_name": table_name}).fetchone() is not None def _has_column(db, table_name: str, column_name: str) -> bool: return db.execute( text(f"SHOW COLUMNS FROM {table_name} LIKE :column_name"), {"column_name": column_name}, ).fetchone() is not None def _drop_fk_constraints(db, table_name: str, referenced_table: str): rows = db.execute(text( """ SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table_name AND REFERENCED_TABLE_NAME = :referenced_table AND CONSTRAINT_NAME <> 'PRIMARY' """ ), {"table_name": table_name, "referenced_table": referenced_table}).fetchall() for (constraint_name,) in rows: db.execute(text(f"ALTER TABLE {table_name} DROP FOREIGN KEY `{constraint_name}`")) def _ensure_fk(db, table_name: str, column_name: str, referenced_table: str, referenced_column: str, constraint_name: str): exists = db.execute(text( """ SELECT 1 FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table_name AND COLUMN_NAME = :column_name AND REFERENCED_TABLE_NAME = :referenced_table AND REFERENCED_COLUMN_NAME = :referenced_column LIMIT 1 """ ), { "table_name": table_name, "column_name": column_name, "referenced_table": referenced_table, "referenced_column": referenced_column, }).fetchone() if not exists: db.execute(text( f"ALTER TABLE {table_name} ADD CONSTRAINT `{constraint_name}` FOREIGN KEY ({column_name}) REFERENCES {referenced_table}({referenced_column})" )) db = SessionLocal() try: # projects.project_code result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")) if not result.fetchone(): db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL")) db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)")) # projects.owner_name result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")) if not result.fetchone(): db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''")) # projects.sub_projects / related_projects result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'")) if not result.fetchone(): db.execute(text("ALTER TABLE projects ADD COLUMN sub_projects VARCHAR(512) NULL")) result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'")) if not result.fetchone(): db.execute(text("ALTER TABLE projects ADD COLUMN related_projects VARCHAR(512) NULL")) # tasks extra fields result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'task_type'")) if not result.fetchone(): db.execute(text("ALTER TABLE tasks ADD COLUMN task_type VARCHAR(32) DEFAULT 'task'")) result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'task_subtype'")) if not result.fetchone(): db.execute(text("ALTER TABLE tasks ADD COLUMN task_subtype VARCHAR(64) NULL")) result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'tags'")) if not result.fetchone(): db.execute(text("ALTER TABLE tasks ADD COLUMN tags VARCHAR(500) NULL")) result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'resolution_summary'")) if not result.fetchone(): db.execute(text("ALTER TABLE tasks ADD COLUMN resolution_summary TEXT NULL")) db.execute(text("ALTER TABLE tasks ADD COLUMN positions TEXT NULL")) db.execute(text("ALTER TABLE tasks ADD COLUMN pending_matters TEXT NULL")) result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'created_by_id'")) if not result.fetchone(): db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL")) _ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id") # milestones creator field result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'")) if not result.fetchone(): db.execute(text("ALTER TABLE milestones ADD COLUMN created_by_id INTEGER NULL")) _ensure_fk(db, "milestones", "created_by_id", "users", "id", "fk_milestones_created_by_id") # comments: issue_id -> task_id if _has_table(db, "comments"): _drop_fk_constraints(db, "comments", "issues") if _has_column(db, "comments", "issue_id") and not _has_column(db, "comments", "task_id"): db.execute(text("ALTER TABLE comments CHANGE COLUMN issue_id task_id INTEGER NOT NULL")) if _has_column(db, "comments", "task_id"): _ensure_fk(db, "comments", "task_id", "tasks", "id", "fk_comments_task_id") # work_logs: issue_id -> task_id if _has_table(db, "work_logs"): _drop_fk_constraints(db, "work_logs", "issues") if _has_column(db, "work_logs", "issue_id") and not _has_column(db, "work_logs", "task_id"): db.execute(text("ALTER TABLE work_logs CHANGE COLUMN issue_id task_id INTEGER NOT NULL")) if _has_column(db, "work_logs", "task_id"): _ensure_fk(db, "work_logs", "task_id", "tasks", "id", "fk_work_logs_task_id") # Drop issues table if it exists (no longer used anywhere) if _has_table(db, "issues"): db.execute(text("DROP TABLE issues")) # --- Milestone status enum migration (old -> new) --- if _has_table(db, "milestones"): # Alter enum column to accept new values db.execute(text( "ALTER TABLE milestones MODIFY COLUMN status " "ENUM('open','pending','deferred','progressing','freeze','undergoing','completed','closed') " "DEFAULT 'open'" )) # Migrate old values db.execute(text("UPDATE milestones SET status='open' WHERE status='pending'")) db.execute(text("UPDATE milestones SET status='closed' WHERE status='deferred'")) db.execute(text("UPDATE milestones SET status='undergoing' WHERE status='progressing'")) # Shrink enum to new-only values db.execute(text( "ALTER TABLE milestones MODIFY COLUMN status " "ENUM('open','freeze','undergoing','completed','closed') " "DEFAULT 'open'" )) # Add started_at if missing if not _has_column(db, "milestones", "started_at"): db.execute(text("ALTER TABLE milestones ADD COLUMN started_at DATETIME NULL")) # --- Task status enum migration (old -> new) --- if _has_table(db, "tasks"): # Widen enum first db.execute(text( "ALTER TABLE tasks MODIFY COLUMN status " "ENUM('open','pending','progressing','undergoing','completed','closed') " "DEFAULT 'open'" )) # Migrate old values db.execute(text("UPDATE tasks SET status='undergoing' WHERE status='progressing'")) # Shrink enum to new-only values db.execute(text( "ALTER TABLE tasks MODIFY COLUMN status " "ENUM('open','pending','undergoing','completed','closed') " "DEFAULT 'open'" )) db.commit() except Exception as e: db.rollback() print(f"Migration warning: {e}") finally: db.close() # Run database migration on startup @app.on_event("startup") def startup(): from app.core.config import Base, engine, SessionLocal from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, propose Base.metadata.create_all(bind=engine) _migrate_schema() # Initialize from AbstractWizard (admin user, default project, etc.) from app.init_wizard import run_init db = SessionLocal() try: run_init(db) finally: db.close() # Start lightweight monitor polling thread (every 10 minutes) import threading, time from app.services.monitoring import refresh_provider_usage_once def _monitor_poll_loop(): while True: db2 = SessionLocal() try: refresh_provider_usage_once(db2) except Exception: pass finally: db2.close() time.sleep(600) t = threading.Thread(target=_monitor_poll_loop, daemon=True) t.start()