diff --git a/.gitignore b/.gitignore index 6852f6a75..7642a0ace 100755 --- a/.gitignore +++ b/.gitignore @@ -110,4 +110,5 @@ superset-tools.bundle # Generated audit reports axiom-mcp-tools-audit-report.md *.docx -backend/relative \ No newline at end of file +backend/relative +.kilo/plans diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 28143bbb8..f9b30b94c 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -55,6 +55,7 @@ from src.models import ( # noqa: F401, E402 clean_release, dashboard, dataset_review_pkg, + deployment, filter_state, git, llm, @@ -116,9 +117,7 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/backend/alembic/versions/e3a4b5c6d7e8_add_deployment_records.py b/backend/alembic/versions/e3a4b5c6d7e8_add_deployment_records.py new file mode 100644 index 000000000..343f66e7b --- /dev/null +++ b/backend/alembic/versions/e3a4b5c6d7e8_add_deployment_records.py @@ -0,0 +1,57 @@ +# #region Alembic.AddDeploymentRecords [C:2] [TYPE Function] [SEMANTICS alembic,migration,deployment,versioning] +# @BRIEF Add deployment_records table for version tracking (Phase 0). +# @RELATION DEPENDS_ON -> [DeploymentModels] +"""add deployment_records table + +Revision ID: e3a4b5c6d7e8 +Revises: f2b3c4d5e6f7 +Create Date: 2026-07-10 13:30:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSON + + +# revision identifiers, used by Alembic. +revision: str = "e3a4b5c6d7e8" +down_revision: Union[str, None] = "f2b3c4d5e6f7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "deployment_records", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("repository_id", sa.String(36), sa.ForeignKey("git_repositories.id", ondelete="CASCADE"), nullable=False), + sa.Column("environment_id", sa.String(36), sa.ForeignKey("deployment_environments.id", ondelete="CASCADE"), nullable=False), + sa.Column("commit_hash", sa.String(40), nullable=False), + sa.Column("content_hash", sa.String(64), nullable=False), + sa.Column("deployed_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("deployed_by", sa.String(255), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="success"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("resources_changed", JSON(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_deployment_records_repository_env"), + "deployment_records", + ["repository_id", "environment_id"], + unique=False, + ) + op.create_index( + op.f("ix_deployment_records_content_hash"), + "deployment_records", + ["content_hash"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_deployment_records_content_hash"), table_name="deployment_records") + op.drop_index(op.f("ix_deployment_records_repository_env"), table_name="deployment_records") + op.drop_table("deployment_records") diff --git a/backend/src/api/routes/git/_repo_lifecycle_routes.py b/backend/src/api/routes/git/_repo_lifecycle_routes.py index 6a761b3c9..7d31d8658 100644 --- a/backend/src/api/routes/git/_repo_lifecycle_routes.py +++ b/backend/src/api/routes/git/_repo_lifecycle_routes.py @@ -4,11 +4,15 @@ # @LAYER API +from pathlib import Path + from fastapi import Depends, HTTPException from sqlalchemy.orm import Session from src.api.routes.git_schemas import ( DeployRequest, + DeploymentStatusResponse, + EnvironmentDeploymentStatus, PromoteRequest, PromoteResponse, ) @@ -27,6 +31,31 @@ from ._helpers import ( from ._router import router +_STAGE_CANONICAL: dict[str, str] = { + "dev": "dev", + "development": "dev", + "разработка": "dev", + "preprod": "preprod", + "pre-production": "preprod", + "staging": "preprod", + "препрод": "preprod", + "prod": "prod", + "production": "prod", + "продакшн": "prod", + "прод": "prod", +} + + +def _canonicalize_stage(name: str) -> str: + """Normalize environment name to canonical stage key (dev/preprod/prod). + + Edge B3: Maps DeploymentEnvironment name (e.g. 'Production') to canonical key + understood by frontend timeline ENVIRONMENTS array ['dev', 'preprod', 'prod']. + """ + key = name.strip().lower().replace(" ", "_").replace("-", "_") + return _STAGE_CANONICAL.get(key, key) + + # #region sync_dashboard [C:3] [TYPE Function] # @ingroup Api # @BRIEF Sync dashboard state from Superset to Git using the GitPlugin. @@ -43,9 +72,7 @@ async def sync_dashboard( from . import _resolve_dashboard_id_from_ref try: - dashboard_id = await _resolve_dashboard_id_from_ref( - dashboard_ref, config_manager, env_id - ) + dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) from src.plugins.git_plugin import GitPlugin plugin = GitPlugin() @@ -60,6 +87,8 @@ async def sync_dashboard( raise except Exception as e: _handle_unexpected_git_route_error("sync_dashboard", e) + + # #endregion sync_dashboard @@ -83,14 +112,8 @@ async def promote_dashboard( from ._helpers import _handle_unexpected_git_route_error try: - dashboard_id = await _resolve_dashboard_id_from_ref( - dashboard_ref, config_manager, env_id - ) - db_repo = ( - db.query(GitRepository) - .filter(GitRepository.dashboard_id == dashboard_id) - .first() - ) + dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) + db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first() if not db_repo: raise HTTPException( status_code=404, @@ -101,21 +124,15 @@ async def promote_dashboard( from_branch = payload.from_branch.strip() to_branch = payload.to_branch.strip() if not from_branch or not to_branch: - raise HTTPException( - status_code=400, detail="from_branch and to_branch are required" - ) + raise HTTPException(status_code=400, detail="from_branch and to_branch are required") if from_branch == to_branch: - raise HTTPException( - status_code=400, detail="from_branch and to_branch must be different" - ) + raise HTTPException(status_code=400, detail="from_branch and to_branch must be different") mode = (payload.mode or "mr").strip().lower() if mode == "direct": reason = (payload.reason or "").strip() if not reason: - raise HTTPException( - status_code=400, detail="Direct promote requires non-empty reason" - ) + raise HTTPException(status_code=400, detail="Direct promote requires non-empty reason") logger.warning( "[promote_dashboard][PolicyViolation] Direct promote without MR by actor=unknown dashboard_ref=%s from=%s to=%s reason=%s", dashboard_ref, @@ -190,9 +207,97 @@ async def promote_dashboard( raise except Exception as e: _handle_unexpected_git_route_error("promote_dashboard", e) + + # #endregion promote_dashboard +# #region get_deployment_status [C:3] [TYPE Function] [SEMANTICS deployment, versioning, status] +# @ingroup Api +# @BRIEF Get per-environment deployment status with content-hash comparison. +# @RELATION CALLS -> [_handle_deploy_helpers._get_last_deployment] +# @RELATION CALLS -> [_handle_deploy_helpers._compute_content_hash] +@router.get("/repositories/{dashboard_ref}/deployment-status", response_model=DeploymentStatusResponse) +async def get_deployment_status( + dashboard_ref: str, + env_id: str | None = None, + config_manager=Depends(get_config_manager), + _=Depends(has_permission("plugin:git", "EXECUTE")), +): + with belief_scope("get_deployment_status"): + from . import _resolve_dashboard_id_from_ref + from src.plugins.git_fingerprint import _compute_content_hash + from src.plugins.git_deployment_recorder import _get_last_deployment + + try: + dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) + from src.services.git_service import GitService + + gs = GitService() + repo = await gs.get_repo(dashboard_id) + repo_path = Path(repo.working_dir) + + # Current content hash (dev branch) + current_hash = _compute_content_hash(repo_path) + + # Collect deployment status for each environment + from src.core.database import SessionLocal + from src.models.git import DeploymentEnvironment, GitRepository + + db = SessionLocal() + try: + # Get repository_id for scoped deployment lookup (FIX A1) + git_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first() + repository_id = git_repo.id if git_repo else None + + envs = ( + db.query(DeploymentEnvironment) + .filter( + DeploymentEnvironment.is_active == True # noqa: E712 + ) + .all() + ) + + environments = [] + for env_obj in envs: + if repository_id: + last = _get_last_deployment(repository_id, env_obj.id, db_session=db) + else: + last = None + + # _get_last_deployment only returns "success" records (Edge E2) + env_status = "deployed" if last else "never_deployed" + + is_behind = None + if last and current_hash: + is_behind = last["content_hash"] != current_hash + + environments.append( + EnvironmentDeploymentStatus( + stage=_canonicalize_stage(env_obj.name), + content_hash=last["content_hash"] if last else None, + deployed_at=last["deployed_at"] if last else None, + status=env_status, + is_behind=is_behind, + ) + ) + + return DeploymentStatusResponse( + environments=environments, + current_content_hash=current_hash, + ) + finally: + db.close() + + except HTTPException: + raise + except Exception as e: + _handle_unexpected_git_route_error("get_deployment_status", e) + + +# #endregion get_deployment_status + + # #region deploy_dashboard [C:3] [TYPE Function] # @ingroup Api # @BRIEF Deploy dashboard from Git to a target environment. @@ -209,9 +314,7 @@ async def deploy_dashboard( from . import _resolve_dashboard_id_from_ref try: - dashboard_id = await _resolve_dashboard_id_from_ref( - dashboard_ref, config_manager, env_id - ) + dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) from src.plugins.git_plugin import GitPlugin plugin = GitPlugin() @@ -226,5 +329,7 @@ async def deploy_dashboard( raise except Exception as e: _handle_unexpected_git_route_error("deploy_dashboard", e) + + # #endregion deploy_dashboard # #endregion GitRepoLifecycleRoutes diff --git a/backend/src/api/routes/git_schemas.py b/backend/src/api/routes/git_schemas.py index 2d37d6472..6b831d9a2 100644 --- a/backend/src/api/routes/git_schemas.py +++ b/backend/src/api/routes/git_schemas.py @@ -25,8 +25,11 @@ class GitServerConfigBase(BaseModel): pat: str = Field(..., description="Personal Access Token") default_repository: str | None = Field(None, description="Default repository path (org/repo)") default_branch: str | None = Field("prod", description="Default branch logic/name") + + # #endregion GitServerConfigBase + # #region GitServerConfigUpdate [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for updating an existing Git server configuration. @@ -37,33 +40,45 @@ class GitServerConfigUpdate(BaseModel): pat: str | None = Field(None, description="Personal Access Token") default_repository: str | None = Field(None, description="Default repository path (org/repo)") default_branch: str | None = Field(None, description="Default branch logic/name") + + # #endregion GitServerConfigUpdate + # #region GitServerConfigCreate [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for creating a new Git server configuration. class GitServerConfigCreate(GitServerConfigBase): """Schema for creating a new Git server configuration.""" + config_id: str | None = Field(None, description="Optional config ID, useful for testing an existing config without sending its full PAT") + + # #endregion GitServerConfigCreate + # #region GitServerConfigSchema [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for representing a Git server configuration with metadata. class GitServerConfigSchema(GitServerConfigBase): """Schema for representing a Git server configuration with metadata.""" + id: str status: GitStatus last_validated: datetime model_config = ConfigDict(from_attributes=True) + + # #endregion GitServerConfigSchema + # #region GitRepositorySchema [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for tracking a local Git repository linked to a dashboard. class GitRepositorySchema(BaseModel): """Schema for tracking a local Git repository linked to a dashboard.""" + id: str dashboard_id: int config_id: str @@ -73,56 +88,78 @@ class GitRepositorySchema(BaseModel): sync_status: SyncStatus model_config = ConfigDict(from_attributes=True) + + # #endregion GitRepositorySchema + # #region BranchSchema [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for representing a Git branch metadata. class BranchSchema(BaseModel): """Schema for representing a Git branch.""" + name: str commit_hash: str is_remote: bool last_updated: datetime + + # #endregion BranchSchema + # #region CommitSchema [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for representing Git commit details. class CommitSchema(BaseModel): """Schema for representing a Git commit.""" + hash: str author: str email: str timestamp: datetime message: str files_changed: list[str] + + # #endregion CommitSchema + # #region BranchCreate [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for branch creation requests. class BranchCreate(BaseModel): """Schema for branch creation requests.""" + name: str from_branch: str + + # #endregion BranchCreate + # #region BranchCheckout [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for branch checkout requests. class BranchCheckout(BaseModel): """Schema for branch checkout requests.""" + name: str + + # #endregion BranchCheckout + # #region CommitCreate [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for staging and committing changes. class CommitCreate(BaseModel): """Schema for staging and committing changes.""" + message: str files: list[str] + + # #endregion CommitCreate @@ -131,18 +168,25 @@ class CommitCreate(BaseModel): # @BRIEF Schema for reverting a dashboard repository commit. class RollbackCommitRequest(BaseModel): """Schema for rollback-by-revert requests.""" + commit_hash: str reason: str | None = None + + # #endregion RollbackCommitRequest + # #region ConflictResolution [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for resolving merge conflicts. class ConflictResolution(BaseModel): """Schema for resolving merge conflicts.""" + file_path: str resolution: str = Field(pattern="^(mine|theirs|manual)$") content: str | None = None + + # #endregion ConflictResolution @@ -157,6 +201,8 @@ class MergeStatusSchema(BaseModel): merge_head: str | None = None merge_message_preview: str | None = None conflicts_count: int = 0 + + # #endregion MergeStatusSchema @@ -167,6 +213,8 @@ class MergeConflictFileSchema(BaseModel): file_path: str mine: str | None = None theirs: str | None = None + + # #endregion MergeConflictFileSchema @@ -175,6 +223,8 @@ class MergeConflictFileSchema(BaseModel): # @BRIEF Request schema for resolving one or multiple merge conflicts. class MergeResolveRequest(BaseModel): resolutions: list[ConflictResolution] = Field(default_factory=list) + + # #endregion MergeResolveRequest @@ -183,36 +233,50 @@ class MergeResolveRequest(BaseModel): # @BRIEF Request schema for finishing merge with optional explicit commit message. class MergeContinueRequest(BaseModel): message: str | None = None + + # #endregion MergeContinueRequest + # #region DeploymentEnvironmentSchema [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for representing a target deployment environment. class DeploymentEnvironmentSchema(BaseModel): """Schema for representing a target deployment environment.""" + id: str name: str superset_url: str is_active: bool model_config = ConfigDict(from_attributes=True) + + # #endregion DeploymentEnvironmentSchema + # #region DeployRequest [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for dashboard deployment requests. class DeployRequest(BaseModel): """Schema for deployment requests.""" + environment_id: str + + # #endregion DeployRequest + # #region RepoInitRequest [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for repository initialization requests. class RepoInitRequest(BaseModel): """Schema for repository initialization requests.""" + config_id: str remote_url: str + + # #endregion RepoInitRequest @@ -225,13 +289,18 @@ class RepositoryBindingSchema(BaseModel): provider: GitProvider remote_url: str local_path: str + + # #endregion RepositoryBindingSchema + # #region RepoStatusBatchRequest [TYPE Class] # @defgroup Api Module group. # @BRIEF Schema for requesting repository statuses for multiple dashboards in a single call. class RepoStatusBatchRequest(BaseModel): dashboard_ids: list[int] = Field(default_factory=list, description="Dashboard IDs to resolve repository statuses for") + + # #endregion RepoStatusBatchRequest @@ -240,6 +309,8 @@ class RepoStatusBatchRequest(BaseModel): # @BRIEF Schema for returning repository statuses keyed by dashboard ID. class RepoStatusBatchResponse(BaseModel): statuses: dict[str, dict[str, Any]] + + # #endregion RepoStatusBatchResponse @@ -254,6 +325,8 @@ class GiteaRepoSchema(BaseModel): html_url: str | None = None ssh_url: str | None = None default_branch: str | None = None + + # #endregion GiteaRepoSchema @@ -266,6 +339,8 @@ class GiteaRepoCreateRequest(BaseModel): description: str | None = None auto_init: bool = True default_branch: str | None = "prod" + + # #endregion GiteaRepoCreateRequest @@ -281,6 +356,8 @@ class RemoteRepoSchema(BaseModel): html_url: str | None = None ssh_url: str | None = None default_branch: str | None = None + + # #endregion RemoteRepoSchema @@ -293,6 +370,8 @@ class RemoteRepoCreateRequest(BaseModel): description: str | None = None auto_init: bool = True default_branch: str | None = "prod" + + # #endregion RemoteRepoCreateRequest @@ -308,6 +387,8 @@ class PromoteRequest(BaseModel): reason: str | None = None draft: bool = False remove_source_branch: bool = False + + # #endregion PromoteRequest @@ -322,6 +403,8 @@ class PromoteResponse(BaseModel): url: str | None = None reference_id: str | None = None policy_violation: bool = False + + # #endregion PromoteResponse @@ -333,6 +416,8 @@ class MergeBranchRequest(BaseModel): target_branch: str = Field(..., min_length=1, max_length=255, description="Target branch to merge into") message: str | None = Field(None, description="Optional merge commit message") auto_delete_source: bool = Field(False, description="Delete source branch after successful merge") + + # #endregion MergeBranchRequest @@ -347,6 +432,8 @@ class MergeBranchResponse(BaseModel): conflicts: list[str] = Field(default_factory=list, description="List of conflicted file paths") error_message: str | None = None source_deleted: bool = False + + # #endregion MergeBranchResponse @@ -355,6 +442,8 @@ class MergeBranchResponse(BaseModel): # @BRIEF Request schema for branch deletion. class DeleteBranchRequest(BaseModel): force: bool = Field(False, description="Force deletion bypassing protection rules") + + # #endregion DeleteBranchRequest @@ -371,6 +460,8 @@ class BranchTypeEnum(StrEnum): LEGACY = "legacy" REMOTE_REF = "remote_ref" OTHER = "other" + + # #endregion BranchTypeEnum @@ -379,6 +470,8 @@ class BranchTypeEnum(StrEnum): # @BRIEF Extended branch schema with branch_type for UI grouping. class BranchSchemaExtended(BranchSchema): branch_type: BranchTypeEnum = Field(default=BranchTypeEnum.OTHER, description="Classification for UI grouping") + + # #endregion BranchSchemaExtended @@ -391,7 +484,34 @@ class BranchProtectionRule(BaseModel): require_review: bool = False allow_force_push: bool = False allow_direct_delete: bool = False + + # #endregion BranchProtectionRule +# #region EnvironmentDeploymentStatus [C:1] [TYPE Class] +# @ingroup Api +# @BRIEF Deployment status for a single environment (DEV/PREPROD/PROD). +class EnvironmentDeploymentStatus(BaseModel): + stage: str = Field(..., description="Environment stage name (dev/preprod/prod)") + content_hash: str | None = Field(None, description="SHA256 fingerprint of deployed content") + deployed_at: str | None = Field(None, description="ISO-8601 timestamp of last successful deploy") + status: str = Field("never_deployed", description="deployed | failed | never_deployed") + is_behind: bool | None = Field(None, description="True if this env differs from the primary (dev)") + + +# #endregion EnvironmentDeploymentStatus + + +# #region DeploymentStatusResponse [C:1] [TYPE Class] +# @ingroup Api +# @BRIEF Response for GET /deployment-status — per-environment deployment state. +class DeploymentStatusResponse(BaseModel): + environments: list[EnvironmentDeploymentStatus] = Field(..., description="Deployment status for each configured environment") + current_content_hash: str | None = Field(None, description="Content hash of the current git HEAD (dev branch)") + + +# #endregion DeploymentStatusResponse + + # #endregion GitSchemas diff --git a/backend/src/app.py b/backend/src/app.py index 0fb77fbbd..d8c193013 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -118,6 +118,34 @@ async def lifespan(app: FastAPI): _s.close() except Exception as _e: logger.explore("Failed to clean up stuck validation runs", error=str(_e)) + + # General reconciliation of stuck tasks from previous lifetime (idea from queue recovery patterns). + # Tasks left in RUNNING when the in-memory asyncio tasks were lost (backend restart/crash). + # This generalizes the ValidationRun-specific cleanup above. + try: + from sqlalchemy.orm import Session as _Ses + from src.core.database import TasksSessionLocal as _TasksDb + from src.models.task import TaskRecord as _TR + from datetime import datetime as _dt, timezone as _tz + + _s: _Ses = _TasksDb() + _stuck_tasks = _s.query(_TR).filter(_TR.status == "RUNNING").all() + for _t in _stuck_tasks: + _t.status = "FAILED" + _t.finished_at = _dt.now(_tz.utc) + _t.error = "Force-stopped: backend restarted while task was in progress" + # result can carry hint + if not _t.result: + _t.result = {"error": "interrupted_by_restart"} + logger.reason( + "Force-stopped stuck task", + payload={"task_id": _t.id, "type": _t.type}, + ) + _s.commit() + _s.close() + except Exception as _e: + logger.explore("Failed to clean up stuck general tasks", error=str(_e)) + logger.reason("Initializing AsyncJobRunner") get_async_job_runner() # Initialize singleton with running event loop BEFORE scheduler starts logger.reason("Starting scheduler") @@ -126,7 +154,31 @@ async def lifespan(app: FastAPI): logger.reflect("Application startup complete") yield # Shutdown + # Improved graceful shutdown (inspired by Celery worker drain + async best practices). + # 1. Stop accepting new scheduled jobs. + # 2. Attempt to let in-flight tasks finish or cancel them within timeout. + # 3. Best-effort flush. scheduler.stop() + try: + from .dependencies import get_config_manager, get_task_manager + tm = get_task_manager() + # Access internal tracking (tasks are asyncio.Task objects) + running = list(getattr(tm, '_async_tasks', {}).values()) + if running: + cm = get_config_manager() + graceful = getattr(cm, 'settings', None) if cm else None + timeout = 30.0 + if graceful and hasattr(graceful, 'graceful_shutdown_timeout'): + timeout = getattr(graceful, 'graceful_shutdown_timeout', 30.0) + logger.reason(f"Draining {len(running)} running task(s) with timeout={timeout}s") + done, pending = await asyncio.wait(running, timeout=timeout) + for p in pending: + p.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + logger.reason("Task drain complete") + except Exception as _e: + logger.explore("Graceful task drain during shutdown encountered error", error=str(_e)) # #endregion lifespan diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 6cd54694e..45e6a6dbd 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -22,6 +22,8 @@ from ..models import ( clean_release as _clean_release_models, # noqa: F401 config as _config_models, # noqa: F401 dataset_review as _dataset_review_models, # noqa: F401 + deployment as _deployment_models, # noqa: F401 + git as _git_models, # noqa: F401 llm as _llm_models, # noqa: F401 maintenance as _maintenance_models, # noqa: F401 profile as _profile_models, # noqa: F401 @@ -45,11 +47,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent.parent # DEV_MODE fallback removed — same violation via env toggle. DATABASE_URL = os.getenv("DATABASE_URL") if not DATABASE_URL: - raise RuntimeError( - "DATABASE_URL environment variable is required. " - "Set it before starting the server. " - "For local development, create a .env file or use docker-compose.yml." - ) + raise RuntimeError("DATABASE_URL environment variable is required. Set it before starting the server. For local development, create a .env file or use docker-compose.yml.") # #endregion DATABASE_URL # #region TASKS_DATABASE_URL [C:1] [TYPE Constant] @@ -118,45 +116,23 @@ def _ensure_user_dashboard_preferences_columns(bind_engine): if table_name not in inspector.get_table_names(): return - existing_columns = { - str(column.get("name") or "").strip() - for column in inspector.get_columns(table_name) - } + existing_columns = {str(column.get("name") or "").strip() for column in inspector.get_columns(table_name)} alter_statements = [] if "git_username" not in existing_columns: - alter_statements.append( - "ALTER TABLE user_dashboard_preferences ADD COLUMN git_username VARCHAR" - ) + alter_statements.append("ALTER TABLE user_dashboard_preferences ADD COLUMN git_username VARCHAR") if "git_email" not in existing_columns: - alter_statements.append( - "ALTER TABLE user_dashboard_preferences ADD COLUMN git_email VARCHAR" - ) + alter_statements.append("ALTER TABLE user_dashboard_preferences ADD COLUMN git_email VARCHAR") if "git_personal_access_token_encrypted" not in existing_columns: - alter_statements.append( - "ALTER TABLE user_dashboard_preferences " - "ADD COLUMN git_personal_access_token_encrypted VARCHAR" - ) + alter_statements.append("ALTER TABLE user_dashboard_preferences ADD COLUMN git_personal_access_token_encrypted VARCHAR") if "start_page" not in existing_columns: - alter_statements.append( - "ALTER TABLE user_dashboard_preferences " - "ADD COLUMN start_page VARCHAR NOT NULL DEFAULT 'dashboards'" - ) + alter_statements.append("ALTER TABLE user_dashboard_preferences ADD COLUMN start_page VARCHAR NOT NULL DEFAULT 'dashboards'") if "auto_open_task_drawer" not in existing_columns: - alter_statements.append( - "ALTER TABLE user_dashboard_preferences " - "ADD COLUMN auto_open_task_drawer BOOLEAN NOT NULL DEFAULT TRUE" - ) + alter_statements.append("ALTER TABLE user_dashboard_preferences ADD COLUMN auto_open_task_drawer BOOLEAN NOT NULL DEFAULT TRUE") if "dashboards_table_density" not in existing_columns: - alter_statements.append( - "ALTER TABLE user_dashboard_preferences " - "ADD COLUMN dashboards_table_density VARCHAR NOT NULL DEFAULT 'comfortable'" - ) + alter_statements.append("ALTER TABLE user_dashboard_preferences ADD COLUMN dashboards_table_density VARCHAR NOT NULL DEFAULT 'comfortable'") if "show_only_slug_dashboards" not in existing_columns: - alter_statements.append( - "ALTER TABLE user_dashboard_preferences " - "ADD COLUMN show_only_slug_dashboards BOOLEAN NOT NULL DEFAULT TRUE" - ) + alter_statements.append("ALTER TABLE user_dashboard_preferences ADD COLUMN show_only_slug_dashboards BOOLEAN NOT NULL DEFAULT TRUE") if not alter_statements: return @@ -185,24 +161,15 @@ def _ensure_user_dashboard_preferences_health_columns(bind_engine): if table_name not in inspector.get_table_names(): return - existing_columns = { - str(column.get("name") or "").strip() - for column in inspector.get_columns(table_name) - } + existing_columns = {str(column.get("name") or "").strip() for column in inspector.get_columns(table_name)} alter_statements = [] if "telegram_id" not in existing_columns: - alter_statements.append( - "ALTER TABLE user_dashboard_preferences ADD COLUMN telegram_id VARCHAR" - ) + alter_statements.append("ALTER TABLE user_dashboard_preferences ADD COLUMN telegram_id VARCHAR") if "email_address" not in existing_columns: - alter_statements.append( - "ALTER TABLE user_dashboard_preferences ADD COLUMN email_address VARCHAR" - ) + alter_statements.append("ALTER TABLE user_dashboard_preferences ADD COLUMN email_address VARCHAR") if "notify_on_fail" not in existing_columns: - alter_statements.append( - "ALTER TABLE user_dashboard_preferences ADD COLUMN notify_on_fail BOOLEAN NOT NULL DEFAULT TRUE" - ) + alter_statements.append("ALTER TABLE user_dashboard_preferences ADD COLUMN notify_on_fail BOOLEAN NOT NULL DEFAULT TRUE") if not alter_statements: return @@ -231,20 +198,13 @@ def _ensure_llm_validation_results_columns(bind_engine): if table_name not in inspector.get_table_names(): return - existing_columns = { - str(column.get("name") or "").strip() - for column in inspector.get_columns(table_name) - } + existing_columns = {str(column.get("name") or "").strip() for column in inspector.get_columns(table_name)} alter_statements = [] if "task_id" not in existing_columns: - alter_statements.append( - "ALTER TABLE llm_validation_results ADD COLUMN task_id VARCHAR" - ) + alter_statements.append("ALTER TABLE llm_validation_results ADD COLUMN task_id VARCHAR") if "environment_id" not in existing_columns: - alter_statements.append( - "ALTER TABLE llm_validation_results ADD COLUMN environment_id VARCHAR" - ) + alter_statements.append("ALTER TABLE llm_validation_results ADD COLUMN environment_id VARCHAR") if not alter_statements: return @@ -275,16 +235,11 @@ def _ensure_git_server_configs_columns(bind_engine): if table_name not in inspector.get_table_names(): return - existing_columns = { - str(column.get("name") or "").strip() - for column in inspector.get_columns(table_name) - } + existing_columns = {str(column.get("name") or "").strip() for column in inspector.get_columns(table_name)} alter_statements = [] if "default_branch" not in existing_columns: - alter_statements.append( - "ALTER TABLE git_server_configs ADD COLUMN default_branch VARCHAR NOT NULL DEFAULT 'prod'" - ) + alter_statements.append("ALTER TABLE git_server_configs ADD COLUMN default_branch VARCHAR NOT NULL DEFAULT 'prod'") if not alter_statements: return @@ -315,18 +270,13 @@ def _ensure_auth_users_columns(bind_engine): if table_name not in inspector.get_table_names(): return - existing_columns = { - str(column.get("name") or "").strip() - for column in inspector.get_columns(table_name) - } + existing_columns = {str(column.get("name") or "").strip() for column in inspector.get_columns(table_name)} alter_statements = [] if "full_name" not in existing_columns: alter_statements.append("ALTER TABLE users ADD COLUMN full_name VARCHAR") if "is_ad_user" not in existing_columns: - alter_statements.append( - "ALTER TABLE users ADD COLUMN is_ad_user BOOLEAN NOT NULL DEFAULT FALSE" - ) + alter_statements.append("ALTER TABLE users ADD COLUMN is_ad_user BOOLEAN NOT NULL DEFAULT FALSE") if not alter_statements: logger.reason( @@ -348,10 +298,7 @@ def _ensure_auth_users_columns(bind_engine): "Auth users schema migration completed", extra={ "table": table_name, - "added_columns": [ - stmt.split(" ADD COLUMN ", 1)[1].split()[0] - for stmt in alter_statements - ], + "added_columns": [stmt.split(" ADD COLUMN ", 1)[1].split()[0] for stmt in alter_statements], }, ) except Exception as migration_error: @@ -377,10 +324,7 @@ def _ensure_roles_is_admin_column(bind_engine): if table_name not in inspector.get_table_names(): return - existing_columns = { - str(column.get("name") or "").strip() - for column in inspector.get_columns(table_name) - } + existing_columns = {str(column.get("name") or "").strip() for column in inspector.get_columns(table_name)} if "is_admin" in existing_columns: logger.reason("roles.is_admin column already exists") @@ -398,6 +342,8 @@ def _ensure_roles_is_admin_column(bind_engine): extra={"error": str(migration_error)}, ) raise + + # #endregion _ensure_roles_is_admin_column @@ -411,34 +357,17 @@ def _ensure_filter_source_enum_values(bind_engine): try: with bind_engine.connect() as connection: # Check if the native enum type exists - result = connection.execute( - text( - "SELECT t.typname FROM pg_type t " - "JOIN pg_namespace n ON t.typnamespace = n.oid " - "WHERE t.typname = 'filtersource' AND n.nspname = 'public'" - ) - ) + result = connection.execute(text("SELECT t.typname FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid WHERE t.typname = 'filtersource' AND n.nspname = 'public'")) if result.fetchone() is None: - logger.reason( - "filtersource enum type does not exist yet; skipping migration" - ) + logger.reason("filtersource enum type does not exist yet; skipping migration") return # Get existing enum values - result = connection.execute( - text( - "SELECT e.enumlabel FROM pg_enum e " - "JOIN pg_type t ON e.enumtypid = t.oid " - "WHERE t.typname = 'filtersource' " - "ORDER BY e.enumsortorder" - ) - ) + result = connection.execute(text("SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid WHERE t.typname = 'filtersource' ORDER BY e.enumsortorder")) existing_values = {row[0] for row in result.fetchall()} required_values = ["SUPERSET_PERMALINK", "SUPERSET_NATIVE_FILTERS_KEY"] - missing_values = [ - v for v in required_values if v not in existing_values - ] + missing_values = [v for v in required_values if v not in existing_values] if not missing_values: logger.reason( @@ -452,11 +381,7 @@ def _ensure_filter_source_enum_values(bind_engine): extra={"missing": missing_values}, ) for value in missing_values: - connection.execute( - text( - f"ALTER TYPE filtersource ADD VALUE IF NOT EXISTS '{value}'" - ) - ) + connection.execute(text(f"ALTER TYPE filtersource ADD VALUE IF NOT EXISTS '{value}'")) connection.commit() logger.reason( "filtersource enum migration completed", @@ -486,20 +411,12 @@ def _ensure_translation_jobs_columns(bind_engine): if table_name not in inspector.get_table_names(): return - existing_columns = { - str(column.get("name") or "").strip() - for column in inspector.get_columns(table_name) - } + existing_columns = {str(column.get("name") or "").strip() for column in inspector.get_columns(table_name)} if "environment_id" not in existing_columns: try: with bind_engine.begin() as connection: - connection.execute( - text( - "ALTER TABLE translation_jobs " - "ADD COLUMN environment_id VARCHAR" - ) - ) + connection.execute(text("ALTER TABLE translation_jobs ADD COLUMN environment_id VARCHAR")) logger.reflect( "Added environment_id column to translation_jobs", ) @@ -513,12 +430,7 @@ def _ensure_translation_jobs_columns(bind_engine): if "target_database_id" not in existing_columns: try: with bind_engine.begin() as connection: - connection.execute( - text( - "ALTER TABLE translation_jobs " - "ADD COLUMN target_database_id VARCHAR" - ) - ) + connection.execute(text("ALTER TABLE translation_jobs ADD COLUMN target_database_id VARCHAR")) logger.reflect( "Added target_database_id column to translation_jobs", ) @@ -532,12 +444,7 @@ def _ensure_translation_jobs_columns(bind_engine): if "target_language_column" not in existing_columns: try: with bind_engine.begin() as connection: - connection.execute( - text( - "ALTER TABLE translation_jobs " - "ADD COLUMN target_language_column VARCHAR" - ) - ) + connection.execute(text("ALTER TABLE translation_jobs ADD COLUMN target_language_column VARCHAR")) logger.reflect("Added target_language_column to translation_jobs") except Exception as migration_error: logger.explore( @@ -548,12 +455,7 @@ def _ensure_translation_jobs_columns(bind_engine): if "target_source_column" not in existing_columns: try: with bind_engine.begin() as connection: - connection.execute( - text( - "ALTER TABLE translation_jobs " - "ADD COLUMN target_source_column VARCHAR" - ) - ) + connection.execute(text("ALTER TABLE translation_jobs ADD COLUMN target_source_column VARCHAR")) logger.reflect("Added target_source_column to translation_jobs") except Exception as migration_error: logger.explore( @@ -564,12 +466,7 @@ def _ensure_translation_jobs_columns(bind_engine): if "target_source_language_column" not in existing_columns: try: with bind_engine.begin() as connection: - connection.execute( - text( - "ALTER TABLE translation_jobs " - "ADD COLUMN target_source_language_column VARCHAR" - ) - ) + connection.execute(text("ALTER TABLE translation_jobs ADD COLUMN target_source_language_column VARCHAR")) logger.reflect("Added target_source_language_column to translation_jobs") except Exception as migration_error: logger.explore( @@ -580,12 +477,7 @@ def _ensure_translation_jobs_columns(bind_engine): if "disable_reasoning" not in existing_columns: try: with bind_engine.begin() as connection: - connection.execute( - text( - "ALTER TABLE translation_jobs " - "ADD COLUMN disable_reasoning BOOLEAN NOT NULL DEFAULT FALSE" - ) - ) + connection.execute(text("ALTER TABLE translation_jobs ADD COLUMN disable_reasoning BOOLEAN NOT NULL DEFAULT FALSE")) logger.reflect("Added disable_reasoning column to translation_jobs") except Exception as migration_error: logger.explore( @@ -596,12 +488,7 @@ def _ensure_translation_jobs_columns(bind_engine): if "include_source_reference" not in existing_columns: try: with bind_engine.begin() as connection: - connection.execute( - text( - "ALTER TABLE translation_jobs " - "ADD COLUMN include_source_reference BOOLEAN NOT NULL DEFAULT TRUE" - ) - ) + connection.execute(text("ALTER TABLE translation_jobs ADD COLUMN include_source_reference BOOLEAN NOT NULL DEFAULT TRUE")) logger.reflect("Added include_source_reference column to translation_jobs") except Exception as migration_error: logger.explore( @@ -618,15 +505,13 @@ def _ensure_dataset_review_session_columns(bind_engine): "dataset_review_sessions": [ ( "version", - "ALTER TABLE dataset_review_sessions " - "ADD COLUMN version INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE dataset_review_sessions ADD COLUMN version INTEGER NOT NULL DEFAULT 0", ) ], "imported_filters": [ ( "raw_value_masked", - "ALTER TABLE imported_filters " - "ADD COLUMN raw_value_masked BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE imported_filters ADD COLUMN raw_value_masked BOOLEAN NOT NULL DEFAULT FALSE", ) ], } @@ -639,15 +524,8 @@ def _ensure_dataset_review_session_columns(bind_engine): ) continue - existing_columns = { - str(column.get("name") or "").strip() - for column in inspector.get_columns(table_name) - } - alter_statements = [ - statement - for column_name, statement in planned_columns - if column_name not in existing_columns - ] + existing_columns = {str(column.get("name") or "").strip() for column in inspector.get_columns(table_name)} + alter_statements = [statement for column_name, statement in planned_columns if column_name not in existing_columns] if not alter_statements: logger.reason( @@ -669,10 +547,7 @@ def _ensure_dataset_review_session_columns(bind_engine): "Dataset review additive schema migration completed", extra={ "table": table_name, - "added_columns": [ - stmt.split(" ADD COLUMN ", 1)[1].split()[0] - for stmt in alter_statements - ], + "added_columns": [stmt.split(" ADD COLUMN ", 1)[1].split()[0] for stmt in alter_statements], }, ) except Exception as migration_error: @@ -698,17 +573,11 @@ def _ensure_translation_schedules_columns(bind_engine): if table_name not in inspector.get_table_names(): return - existing_columns = { - str(column.get("name") or "").strip() - for column in inspector.get_columns(table_name) - } + existing_columns = {str(column.get("name") or "").strip() for column in inspector.get_columns(table_name)} alter_statements = [] if "execution_mode" not in existing_columns: - alter_statements.append( - "ALTER TABLE translation_schedules " - "ADD COLUMN execution_mode VARCHAR NOT NULL DEFAULT 'full'" - ) + alter_statements.append("ALTER TABLE translation_schedules ADD COLUMN execution_mode VARCHAR NOT NULL DEFAULT 'full'") if not alter_statements: return @@ -737,27 +606,15 @@ def _ensure_dictionary_entries_columns(bind_engine): if table_name not in inspector.get_table_names(): return - existing_columns = { - str(column.get("name") or "").strip() - for column in inspector.get_columns(table_name) - } + existing_columns = {str(column.get("name") or "").strip() for column in inspector.get_columns(table_name)} alter_statements = [] if "origin_run_id" not in existing_columns: - alter_statements.append( - "ALTER TABLE dictionary_entries " - "ADD COLUMN origin_run_id VARCHAR" - ) + alter_statements.append("ALTER TABLE dictionary_entries ADD COLUMN origin_run_id VARCHAR") if "origin_row_key" not in existing_columns: - alter_statements.append( - "ALTER TABLE dictionary_entries " - "ADD COLUMN origin_row_key VARCHAR" - ) + alter_statements.append("ALTER TABLE dictionary_entries ADD COLUMN origin_row_key VARCHAR") if "origin_user_id" not in existing_columns: - alter_statements.append( - "ALTER TABLE dictionary_entries " - "ADD COLUMN origin_user_id VARCHAR" - ) + alter_statements.append("ALTER TABLE dictionary_entries ADD COLUMN origin_user_id VARCHAR") if not alter_statements: return diff --git a/backend/src/core/scheduler.py b/backend/src/core/scheduler.py index 98e1787ab..31561a399 100644 --- a/backend/src/core/scheduler.py +++ b/backend/src/core/scheduler.py @@ -7,13 +7,14 @@ import asyncio from datetime import date, datetime, time, timedelta +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from .async_job_runner import AsyncJobRunner from .config_manager import ConfigManager from .cot_logger import seed_trace_id -from .database import SessionLocal +from .database import SessionLocal, TASKS_DATABASE_URL from .logger import belief_scope, logger @@ -30,7 +31,27 @@ class SchedulerService: with belief_scope("SchedulerService.__init__"): self.task_manager = task_manager self.config_manager = config_manager - self.scheduler = BackgroundScheduler() + + # Use persistent SQLAlchemyJobStore (idea from enhanced APScheduler usage). + # This makes scheduled jobs survive backend restarts more robustly. + # We still use authoritative reload from config/DB in load_schedules(), + # but the store provides durability + better misfire/coalesce handling. + # Args passed to jobs must be simple (primitives) for reliable pickling. + jobstores = { + 'default': SQLAlchemyJobStore( + url=TASKS_DATABASE_URL, + tablename='apscheduler_jobs', + ) + } + job_defaults = { + 'coalesce': True, # Don't run missed jobs multiple times + 'misfire_grace_time': 300, # 5 minutes tolerance for delayed triggers + } + self.scheduler = BackgroundScheduler( + jobstores=jobstores, + job_defaults=job_defaults, + ) + try: self.loop = asyncio.get_running_loop() except RuntimeError: @@ -64,8 +85,8 @@ class SchedulerService: # @ingroup Core # @BRIEF Load backup and active translation schedules from config and DB, re-registering all jobs. # @PRE config_manager must have valid configuration; database is accessible. - # @POST All enabled backup jobs and active translation schedules are re-registered in APScheduler. - # @SIDE_EFFECT Removes all existing APScheduler jobs; queries translation_schedules table; registers APScheduler jobs for translation. + # @POST All enabled backup jobs and active translation schedules are re-registered in APScheduler (via persistent jobstore if configured). + # @SIDE_EFFECT Removes existing jobs then syncs authoritative set from config + DB. The SQLAlchemyJobStore ensures durability of registered jobs across restarts. def load_schedules(self): with belief_scope("SchedulerService.load_schedules"): # Clear existing jobs @@ -186,9 +207,9 @@ class SchedulerService: # @POST A new APScheduler job is registered or replaced if it already exists. # @SIDE_EFFECT Mutates APScheduler state; calls execute_scheduled_translation on trigger. # @RELATION DEPENDS_ON -> [execute_scheduled_translation] - # @RELATION DEPENDS_ON -> [SessionLocal] # @RATIONALE Registers translation schedules with APScheduler because the scheduler service acts as a central job registry — adding translation jobs here ensures all scheduled work follows the same lifecycle (start/stop/reload) regardless of job type. # @REJECTED Having translation schedules self-register directly with APScheduler was rejected — it would bypass centralized lifecycle management and create split-brain state when the scheduler is stopped or reloaded. + # @NOTE Only primitive args are passed so that SQLAlchemyJobStore can reliably persist/restore the job. def add_translation_job(self, schedule_id: str, job_id: str, cron_expression: str, timezone: str = "UTC", execution_mode: str = "full"): with belief_scope( "SchedulerService.add_translation_job", @@ -205,7 +226,7 @@ class SchedulerService: execute_scheduled_translation, CronTrigger.from_crontab(cron_expression, timezone=tz), id=job_id_aps, - args=[schedule_id, job_id, SessionLocal, self.config_manager, execution_mode], + args=[schedule_id, job_id, execution_mode], # primitives only for jobstore replace_existing=True, ) logger.reason( diff --git a/backend/src/core/task_manager/context.py b/backend/src/core/task_manager/context.py index ebfdd780a..35bde646a 100644 --- a/backend/src/core/task_manager/context.py +++ b/backend/src/core/task_manager/context.py @@ -156,5 +156,29 @@ class TaskContext: background_tasks=self._background_tasks, ) # #endregion create_sub_context + + # #region heartbeat [TYPE Function] + # @PURPOSE: Lightweight progress / liveness signal for long-running tasks. + # Inspired by ARQ progress updates and queue heartbeats. + # @PRE TaskContext is active. + # @POST Emits a structured heartbeat log (and could update task progress in future). + async def heartbeat(self, progress: float | None = None, message: str | None = None, **metadata: Any) -> None: + """Report liveness / progress. Safe to call frequently from long tasks.""" + with belief_scope("heartbeat"): + payload = {"progress": progress} + if message: + payload["message"] = message + if metadata: + payload.update(metadata) + if self._logger and hasattr(self._logger, '_add_log'): + # Fire a special structured log entry + await self._logger._add_log( + self._task_id, + "INFO", + message or f"heartbeat progress={progress}", + source=self._default_source or "system", + metadata={"type": "heartbeat", **payload}, + ) + # #endregion heartbeat # #endregion TaskContext # #endregion TaskContextModule diff --git a/backend/src/models/deployment.py b/backend/src/models/deployment.py new file mode 100644 index 000000000..9c8b73679 --- /dev/null +++ b/backend/src/models/deployment.py @@ -0,0 +1,76 @@ +# #region DeploymentModels [C:4] [TYPE Module] [SEMANTICS sqlalchemy, deployment, versioning, content-hash] +# @defgroup Models Module group. +# +# @BRIEF SQLAlchemy model for deployment version tracking — records which git commit +# + content hash is deployed on which Superset environment. +# +# @INVARIANT Every successful deploy produces exactly one DeploymentRecord row. +# @INVARIANT content_hash is deterministic: SHA256 of normalized export YAML. +# @INVARIANT Records with status="success" are the source of truth for "what's deployed". +# +# @RATIONALE Two-source tracking (commit_hash + content_hash) chosen because each +# serves a different purpose: commit_hash enables git diff for selective +# deploy; content_hash answers "did the dashboard actually change?" +# semantically without git-noise. Both are stored in one row to avoid +# cross-query inconsistency. +# @REJECTED Single commit-hash-only approach rejected — SHA changes on every commit +# even for non-content changes (e.g., .gitignore). Single content-hash-only +# rejected — cannot compute git diff for rollback without commit SHA. +# +# @RELATION DEPENDS_ON -> [GitModels.GitRepository] +# @RELATION DEPENDS_ON -> [GitModels.DeploymentEnvironment] + +from datetime import UTC, datetime + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import JSON + +from src.models.mapping import Base + + +# #region DeploymentRecord [C:3] [TYPE Class] [SEMANTICS deployment, record, content-hash, commit] +# @ingroup Models +# @BRIEF Tracks which commit + content hash was deployed to which Superset environment. +# +# @INVARIANT content_hash changes iff dashboard content (charts, datasets, metadata) changed. +# @INVARIANT commit_hash is the git SHA of HEAD at deploy time (for git diff / rollback). +# @INVARIANT status="failed" records are ignored by get_last_deployment() comparisons. +# +# @DATA_CONTRACT Input: (repo_id, env_id, commit_hash, content_hash) +# -> Output: DeploymentRecord row in deployment_records table +# +# @RATIONALE Two hashes needed: commit_hash for git operations (diff between deployed +# and target), content_hash for semantic comparison (did the dashboard actually +# change?). Without content_hash, we'd compare git commit SHAs which change on +# every commit regardless of actual dashboard content. +# +# @REJECTED Single git-commit-only approach rejected — SHA changes on every non-content +# commit (e.g., .gitignore, metadata.yaml timestamp). Pure content-hash-only +# rejected — cannot compute git diff for selective deploy without commit SHA. +class DeploymentRecord(Base): + __tablename__ = "deployment_records" + + id: int = Column(Integer, primary_key=True, autoincrement=True) + repository_id: str = Column( + String(36), + ForeignKey("git_repositories.id", ondelete="CASCADE"), + nullable=False, + ) + environment_id: str = Column( + String(36), + ForeignKey("deployment_environments.id", ondelete="CASCADE"), + nullable=False, + ) + commit_hash: str = Column(String(40), nullable=False) + content_hash: str = Column(String(64), nullable=False) + deployed_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) + deployed_by: str | None = Column(String(255), nullable=True) + status: str | None = Column(String(20), nullable=False, default="success", server_default="success") + error_message: str | None = Column(Text, nullable=True) + resources_changed: dict | None = Column(JSON, nullable=True) + + +# #endregion DeploymentRecord + + +# #endregion DeploymentModels diff --git a/backend/src/models/git.py b/backend/src/models/git.py index 235029282..7c6e94461 100644 --- a/backend/src/models/git.py +++ b/backend/src/models/git.py @@ -7,7 +7,7 @@ import uuid from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String -from src.core.database import Base +from src.models.mapping import Base class GitProvider(str, enum.Enum): @@ -15,16 +15,19 @@ class GitProvider(str, enum.Enum): GITLAB = "GITLAB" GITEA = "GITEA" + class GitStatus(str, enum.Enum): CONNECTED = "CONNECTED" FAILED = "FAILED" UNKNOWN = "UNKNOWN" + class SyncStatus(str, enum.Enum): CLEAN = "CLEAN" DIRTY = "DIRTY" CONFLICT = "CONFLICT" + # #region GitServerConfig [C:1] [TYPE Class] # @BRIEF Configuration for a Git server connection. class GitServerConfig(Base): @@ -39,8 +42,11 @@ class GitServerConfig(Base): default_branch = Column(String(255), default="prod") status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN) last_validated = Column(DateTime, default=lambda: datetime.now(UTC)) + + # #endregion GitServerConfig + # #region GitRepository [C:1] [TYPE Class] # @BRIEF Tracking for a local Git repository linked to a dashboard. class GitRepository(Base): @@ -53,8 +59,11 @@ class GitRepository(Base): local_path = Column(String(255), nullable=False) current_branch = Column(String(255), default="dev") sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN) + + # #endregion GitRepository + # #region DeploymentEnvironment [C:1] [TYPE Class] # @BRIEF Target Superset environments for dashboard deployment. class DeploymentEnvironment(Base): @@ -65,6 +74,8 @@ class DeploymentEnvironment(Base): superset_url = Column(String(255), nullable=False) superset_token = Column(String(255), nullable=False) is_active = Column(Boolean, default=True) + + # #endregion DeploymentEnvironment # #endregion GitModels diff --git a/backend/src/plugins/git_deployment_recorder.py b/backend/src/plugins/git_deployment_recorder.py new file mode 100644 index 000000000..84eb7da27 --- /dev/null +++ b/backend/src/plugins/git_deployment_recorder.py @@ -0,0 +1,140 @@ +# #region GitDeploymentRecorderModule [C:3] [TYPE Module] [SEMANTICS deployment, record, content-hash, query] +# @defgroup Plugin Module group. +# +# @BRIEF Deployment history recording — queries and inserts into deployment_records table. +# @LAYER Plugin +# @RELATION CALLS -> [DeploymentRecord] +# @RELATION CALLS -> [GitRepository] +# +# @RATIONALE Extracted from git_plugin.py (670 lines → * lines) to satisfy INV_7 module limit. +# Deployment recording cross-cuts plugin and route layers — centralizing it +# prevents import cycles and makes DB session management explicit. + +from pathlib import Path + +from src.core.database import SessionLocal +from src.models.deployment import DeploymentRecord +from src.models.git import GitRepository + +from .git_fingerprint import ( + _compute_content_hash, + _read_fingerprint, +) + + +# #region get_last_deployment [C:2] [TYPE Function] [SEMANTICS deployment, record, query] +# @ingroup Plugin +# @BRIEF Read last successful deployment record from deployment_records table. +# @INVARIANT Filters by repository_id + environment_id (scoped to one dashboard). +# @INVARIANT Filters by status='success' (Edge E2: skip failed records). +# @RELATION CALLS -> [DeploymentRecord] +# @RETURN dict | None — {content_hash, commit_hash, deployed_at} or None +# @SIDE_EFFECT Opens and closes a DB session when db_session is None. +def _get_last_deployment(repository_id: str, env_id: str, db_session=None) -> dict | None: + should_close = db_session is None + db = db_session or SessionLocal() + try: + record = ( + db.query(DeploymentRecord) + .filter( + DeploymentRecord.repository_id == repository_id, + DeploymentRecord.environment_id == env_id, + DeploymentRecord.status == "success", + ) + .order_by(DeploymentRecord.deployed_at.desc()) + .first() + ) + if record: + return { + "content_hash": record.content_hash, + "commit_hash": record.commit_hash, + "deployed_at": record.deployed_at.isoformat(), + } + return None + finally: + if should_close: + db.close() + + +# #endregion get_last_deployment + + +# #region record_deployment [C:3] [TYPE Function] [SEMANTICS deployment, record, insert, content-hash] +# @ingroup Plugin +# @BRIEF Record a deployment in deployment_records table. +# +# Reads content_hash from .superset-tools-fingerprint, re-verifies against actual +# YAML files (Edge F2), computes diff vs last deployment, and inserts a new +# DeploymentRecord row. Skips if content_hash matches last successful deployment. +# +# @INVARIANT commit_hash read from HEAD (works in detached HEAD — Edge B1). +# @INVARIANT content_hash verified against actual YAML before recording (Edge F2). +# @SIDE_EFFECT Inserts row in deployment_records table. +# @SIDE_EFFECT Opens and closes a DB session. +# @RELATION CALLS -> [GitFingerprintModule._compute_content_hash] +# @RELATION CALLS -> [GitFingerprintModule._read_fingerprint] +# @RELATION CALLS -> [_get_last_deployment] +# @RELATION CALLS -> [DeploymentRecord] +# @RELATION CALLS -> [GitRepository] +def _record_deployment( + repo_path: Path, + repo, + dashboard_id: int, + env_id: str, + status: str, + logger, +) -> None: + # Read stored fingerprint (Edge F2: re-verify against actual files) + stored_hash = _read_fingerprint(repo_path) + actual_hash = _compute_content_hash(repo_path, logger=logger) + + if stored_hash != actual_hash: + logger.warning(f"Fingerprint mismatch: stored={stored_hash[:12] if stored_hash else 'N/A'}, actual={actual_hash[:12] if actual_hash else 'N/A'}. Recalculation suggested: sync dashboard.") + + content_hash = actual_hash or stored_hash + if not content_hash: + logger.warning("No content hash available for deployment record") + return + + # Get commit hash (Edge B1: works even in detached HEAD) + try: + commit_hash = repo.head.commit.hexsha + except Exception as e: + logger.error(f"Cannot resolve HEAD commit hash: {e}") + commit_hash = "unknown" + + db = SessionLocal() + try: + git_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first() + if not git_repo: + logger.warning(f"No GitRepository found for dashboard {dashboard_id}") + return + + # Compare with last deployment for this specific repository+environment + last = _get_last_deployment(git_repo.id, env_id, db_session=db) + if last and last["content_hash"] == content_hash: + logger.info(f"Content unchanged (hash: {content_hash[:12]}...), skipping deployment") + return + + record = DeploymentRecord( + repository_id=git_repo.id, + environment_id=env_id, + commit_hash=commit_hash, + content_hash=content_hash, + status=status, + ) + db.add(record) + db.commit() + logger.info(f"Deployment recorded: env={env_id} hash={content_hash[:12]}... commit={commit_hash[:12]}...") + except Exception as e: + db.rollback() + logger.error(f"Failed to record deployment: {e}") + raise + finally: + db.close() + + +# #endregion record_deployment + + +# #endregion GitDeploymentRecorderModule diff --git a/backend/src/plugins/git_fingerprint.py b/backend/src/plugins/git_fingerprint.py new file mode 100644 index 000000000..006ed408e --- /dev/null +++ b/backend/src/plugins/git_fingerprint.py @@ -0,0 +1,125 @@ +# #region GitFingerprintModule [C:3] [TYPE Module] [SEMANTICS content-hash, fingerprint, sync, versioning] +# @defgroup Plugin Module group. +# +# @BRIEF Content-hash fingerprint computation for Superset dashboard exports. +# @LAYER Plugin +# @RELATION DEPENDS_ON -> [EXT:yaml] +# @RELATION DEPENDS_ON -> [EXT:hashlib] +# +# @INVARIANT Fingerprint is deterministic: SHA256 of sorted, normalized YAML files. +# @INVARIANT Excludes metadata.yaml (its timestamp changes on every export). +# @RATIONALE Extracted from git_plugin.py (670 lines → * lines) to satisfy INV_7 module limit. +# Fingerprint logic is pure file-I/O + hashing with zero plugin dependencies — keeping +# it in the plugin file bloats the module without adding cohesion. + +import hashlib +from pathlib import Path + +import yaml + + +# #region compute_content_hash [C:3] [TYPE Function] [SEMANTICS content-hash, fingerprint, sync] +# @ingroup Plugin +# @BRIEF Compute deterministic SHA256 of normalized export YAML files. +# +# Hashes dashboards/, charts/, datasets/ YAML files (excluding metadata.yaml +# whose timestamp changes on every export). Returns None if no YAML content found. +# +# @INVARIANT Files sorted lexicographically for determinism. +# @INVARIANT YAML keys sorted via yaml.dump(sort_keys=True). +# @INVARIANT Handles both .yaml and .yml extensions (Edge A5). +# @INVARIANT Skips corrupted YAML files without failing the entire sync (Edge A3). +# @INVARIANT Returns None if total_files == 0 (Edge A1/A2). +# @RELATION DEPENDS_ON -> [EXT:yaml] +# @RELATION DEPENDS_ON -> [EXT:hashlib] +# @RETURN str | None — SHA256 hex digest, or None if no YAML content exists +# @SIDE_EFFECT Logs warning via CoT logger when corrupted YAML skipped (Edge A3). +def _compute_content_hash(repo_path: Path, logger=None) -> str | None: + hasher = hashlib.sha256() + total_files = 0 + + for dir_name in ["dashboards", "charts", "datasets"]: + dir_path = repo_path / dir_name + if not dir_path.exists(): + continue + for ext in ["*.yaml", "*.yml"]: + for yaml_file in sorted(dir_path.glob(ext)): + total_files += 1 + try: + data = yaml.safe_load(yaml_file.read_bytes()) + if data is None: + continue # empty YAML file + hasher.update(yaml.dump(data, sort_keys=True).encode("utf-8")) + except yaml.YAMLError as exc: + # Edge A3: corrupted YAML — skip file, don't crash sync + if logger: + logger.warning("Skipping corrupted YAML %s: %s", yaml_file, exc) + continue + + if total_files == 0: + return None # Edge A1/A2: no YAML content at all + return hasher.hexdigest() + + +# #endregion compute_content_hash + + +# #region read_fingerprint [C:1] [TYPE Function] [SEMANTICS fingerprint, file] +# @ingroup Plugin +# @BRIEF Read content hash from .superset-tools-fingerprint if it exists. +# @RETURN str | None — stored hash, or None if file missing +def _read_fingerprint(repo_path: Path) -> str | None: + fp_path = repo_path / ".superset-tools-fingerprint" + if fp_path.exists(): + return fp_path.read_text().strip() + return None + + +# #endregion read_fingerprint + + +# #region write_fingerprint [C:1] [TYPE Function] [SEMANTICS fingerprint, file] +# @ingroup Plugin +# @BRIEF Write content hash to .superset-tools-fingerprint. +# @SIDE_EFFECT Creates or overwrites .superset-tools-fingerprint file in repo. +def _write_fingerprint(repo_path: Path, content_hash: str) -> None: + fp_path = repo_path / ".superset-tools-fingerprint" + fp_path.write_text(content_hash + "\n") + + +# #endregion write_fingerprint + + +# #region compute_and_store_fingerprint [C:2] [TYPE Function] [SEMANTICS fingerprint, sync, compare] +# @ingroup Plugin +# @BRIEF Compute content hash and write fingerprint file. Thread-safe for run_blocking. +# @SIDE_EFFECT Writes .superset-tools-fingerprint; logs comparison result. +# @PRE repo_path must contain managed YAML directories (dashboards/, charts/, datasets/). +# @POST Fingerprint file written if valid YAML content exists. +# @RELATION CALLS -> [_compute_content_hash] +# @RELATION CALLS -> [_read_fingerprint] +# @RELATION CALLS -> [_write_fingerprint] +def _compute_and_store_fingerprint(repo_path: Path, logger) -> None: + """Thread-safe wrapper for run_blocking. Compares with old hash (if any) and logs diff.""" + new_hash = _compute_content_hash(repo_path, logger=logger) + old_hash = _read_fingerprint(repo_path) + + if new_hash is None: + logger.warning("No YAML content found to compute fingerprint") + return + + if old_hash == new_hash: + logger.info(f"Content unchanged (hash: {new_hash[:12]}...)") + else: + if old_hash is None: + logger.info(f"First fingerprint computed: {new_hash[:12]}...") + else: + logger.info(f"Content changed: {old_hash[:12]}... -> {new_hash[:12]}...") + + _write_fingerprint(repo_path, new_hash) + + +# #endregion compute_and_store_fingerprint + + +# #endregion GitFingerprintModule diff --git a/backend/src/plugins/git_plugin.py b/backend/src/plugins/git_plugin.py index e86a2d975..2c56eed96 100644 --- a/backend/src/plugins/git_plugin.py +++ b/backend/src/plugins/git_plugin.py @@ -28,6 +28,12 @@ from src.core.plugin_base import PluginBase from src.core.superset_client import SupersetClient from src.core.task_manager.context import TaskContext from src.core.utils.executors import run_blocking +from src.plugins.git_deployment_recorder import ( + _record_deployment, +) +from src.plugins.git_fingerprint import ( + _compute_and_store_fingerprint, +) from src.services.git_service import GitService @@ -68,10 +74,10 @@ def _extract_zip_to_repo(zip_bytes: bytes, repo_path: Path) -> None: namelist = zf.namelist() if not namelist: raise ValueError("Export ZIP is empty") - root_folder = namelist[0].split('/')[0] + root_folder = namelist[0].split("/")[0] for member in zf.infolist(): if member.filename.startswith(root_folder + "/") and len(member.filename) > len(root_folder) + 1: - relative_path = member.filename[len(root_folder) + 1:] + relative_path = member.filename[len(root_folder) + 1 :] target_path = repo_path / relative_path if member.is_dir(): target_path.mkdir(parents=True, exist_ok=True) @@ -98,13 +104,6 @@ def _restore_sync_backup(repo_path: Path, backup_dir: Path, managed_dirs: list[s shutil.copy2(src, dst) -# #endregion _sync_helpers - - -# #region _deploy_helpers [C:2] [TYPE Module] [SEMANTICS git,deploy,zip] -# @BRIEF Helper functions for GitPlugin._handle_deploy, extracted for use with run_blocking. - - def _pack_deploy_zip(repo_path: Path, root_dir_name: str, zip_buffer: io.BytesIO) -> None: """Walk repo_path and pack files into zip_buffer (thread-safe).""" with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: @@ -120,14 +119,13 @@ def _pack_deploy_zip(repo_path: Path, root_dir_name: str, zip_buffer: io.BytesIO zip_buffer.seek(0) -# #endregion _deploy_helpers +# #endregion _sync_helpers # #region GitPlugin [TYPE Class] # @defgroup Plugin Module group. # @BRIEF Реализация плагина Git Integration для управления версиями дашбордов. class GitPlugin(PluginBase): - # region __init__ [TYPE Function] # @PURPOSE: Инициализирует плагин и его зависимости. # @PRE shared config_manager доступен через src.dependencies. @@ -140,42 +138,49 @@ class GitPlugin(PluginBase): # Используем shared config_manager из dependencies try: from src.dependencies import config_manager + self.config_manager = config_manager app_logger.reflect("GitPlugin initialized using shared config_manager", extra={"src": "GitPlugin.__init__"}) except Exception as exc: app_logger.explore("Failed to get shared config_manager", extra={"src": "GitPlugin.__init__"}, error=str(exc)) self.config_manager = ConfigManager() app_logger.reflect("GitPlugin initialized with fallback ConfigManager", extra={"src": "GitPlugin.__init__"}) + # endregion __init__ @property # region id [C:1] [TYPE Function] def id(self) -> str: return "git-integration" + # endregion id @property # region name [C:1] [TYPE Function] def name(self) -> str: return "Git Integration" + # endregion name @property # region description [C:1] [TYPE Function] def description(self) -> str: return "Version control for Superset dashboards" + # endregion description @property # region version [C:1] [TYPE Function] def version(self) -> str: return "0.1.0" + # endregion version @property # region ui_route [C:1] [TYPE Function] def ui_route(self) -> str: return "/git" + # endregion ui_route # region get_schema [TYPE Function] @@ -185,21 +190,14 @@ class GitPlugin(PluginBase): # @RETURN Dict[str, Any] - Схема параметров. def get_schema(self) -> dict[str, Any]: with belief_scope("GitPlugin.get_schema"): - return { - "type": "object", - "properties": { - "operation": {"type": "string", "enum": ["sync", "deploy", "history"]}, - "dashboard_id": {"type": "integer"}, - "environment_id": {"type": "string"}, - "source_env_id": {"type": "string"} - }, - "required": ["operation", "dashboard_id"] - } + return {"type": "object", "properties": {"operation": {"type": "string", "enum": ["sync", "deploy", "history"]}, "dashboard_id": {"type": "integer"}, "environment_id": {"type": "string"}, "source_env_id": {"type": "string"}}, "required": ["operation", "dashboard_id"]} + # endregion get_schema # region initialize [C:1] [TYPE Function] async def initialize(self): app_logger.reason("Initializing Git Integration Plugin logic.", extra={"src": "GitPlugin.initialize"}) + # endregion initialize # region execute [C:3] [TYPE Function] @@ -234,6 +232,7 @@ class GitPlugin(PluginBase): log.info(f"Operation {operation} completed.") return result + # endregion execute # region _handle_sync [C:4] [TYPE Function] [SEMANTICS git,sync,backup,transactional] @@ -268,14 +267,14 @@ class GitPlugin(PluginBase): # Fix 1: Create backup before deleting managed files (wrapped in run_blocking) backup_dir = Path(f"/tmp/superset-tools-backup-{dashboard_id}-{time.time_ns()}") - await run_blocking(kind='file', fn=_create_sync_backup, repo_path=repo_path, backup_dir=backup_dir, managed_dirs=managed_dirs, managed_files=managed_files) + await run_blocking(kind="file", fn=_create_sync_backup, repo_path=repo_path, backup_dir=backup_dir, managed_dirs=managed_dirs, managed_files=managed_files) # Delete old managed files - await run_blocking(kind='file', fn=_delete_managed_files, repo_path=repo_path, managed_dirs=managed_dirs, managed_files=managed_files) + await run_blocking(kind="file", fn=_delete_managed_files, repo_path=repo_path, managed_dirs=managed_dirs, managed_files=managed_files) try: await run_blocking( - kind='file', + kind="file", fn=_extract_zip_to_repo, zip_bytes=zip_bytes, repo_path=repo_path, @@ -283,15 +282,23 @@ class GitPlugin(PluginBase): except Exception: # Fix 1: Restore backup on unzip failure if backup_dir.exists(): - await run_blocking(kind='file', fn=_restore_sync_backup, repo_path=repo_path, backup_dir=backup_dir, managed_dirs=managed_dirs, managed_files=managed_files) + await run_blocking(kind="file", fn=_restore_sync_backup, repo_path=repo_path, backup_dir=backup_dir, managed_dirs=managed_dirs, managed_files=managed_files) raise # Fix 1: Remove backup on success if backup_dir.exists(): - await run_blocking(kind='file', fn=shutil.rmtree, path=backup_dir, ignore_errors=True) + await run_blocking(kind="file", fn=shutil.rmtree, path=backup_dir, ignore_errors=True) + + # Compute content hash for version tracking (Phase 0) + await run_blocking( + kind="file", + fn=_compute_and_store_fingerprint, + repo_path=repo_path, + logger=app_logger, + ) try: - await run_blocking(kind='git', fn=repo.git.add, A=True, force=True) + await run_blocking(kind="git", fn=repo.git.add, A=True, force=True) app_logger.reason("Changes staged in git (force)", extra={"src": "_handle_sync"}) except Exception as ge: app_logger.explore(f"Failed to stage changes: {ge}", extra={"src": "_handle_sync"}) @@ -302,12 +309,16 @@ class GitPlugin(PluginBase): except Exception as e: app_logger.explore("Sync failed", extra={"src": "_handle_sync"}, error=str(e), payload={"dashboard_id": dashboard_id}) raise + # endregion _handle_sync # region _handle_deploy [C:4] [TYPE Function] [SEMANTICS git,deploy,zip] # @PURPOSE: Packages repository into ZIP and imports into target Superset environment. # @POST Dashboard imported into target Superset. - # @SIDE_EFFECT Creates and removes temporary ZIP file. + # @SIDE_EFFECT Creates and removes temporary ZIP file. Records DeploymentRecord row. + # @RATIONALE Deployment recording is non-fatal: if DB is unavailable the deploy + # succeeds anyway. The deployment_records table is an audit trail, not + # a transaction gate. This balances observability with deploy reliability. # @RELATION CALLS -> src.core.superset_client.SupersetClient.import_dashboard async def _handle_deploy(self, dashboard_id: int, env_id: str, log=None, git_log=None, superset_log=None) -> dict[str, Any]: with belief_scope("GitPlugin._handle_deploy"): @@ -322,7 +333,7 @@ class GitPlugin(PluginBase): zip_buffer = io.BytesIO() root_dir_name = f"dashboard_export_{dashboard_id}" await run_blocking( - kind='file', + kind="file", fn=_pack_deploy_zip, repo_path=repo_path, root_dir_name=root_dir_name, @@ -338,7 +349,7 @@ class GitPlugin(PluginBase): temp_zip_path = repo_path / f"deploy_{dashboard_id}.zip" git_log.info(f"Saving temporary zip to {temp_zip_path}") await run_blocking( - kind='file', + kind="file", fn=temp_zip_path.write_bytes, data=zip_buffer.getvalue(), ) @@ -346,15 +357,36 @@ class GitPlugin(PluginBase): try: app_logger.reason(f"Importing dashboard to {env.name}", extra={"src": "_handle_deploy"}) result = await client.import_dashboard(temp_zip_path) + + # ── Record deployment in deployment_records (Phase 0) ── + try: + await run_blocking( + kind="file", + fn=_record_deployment, + repo_path=repo_path, + repo=repo, + dashboard_id=dashboard_id, + env_id=env_id, + status="success", + logger=app_logger, + ) + except Exception as rec_err: + app_logger.explore( + "Failed to record deployment history (non-fatal)", + extra={"src": "_handle_deploy"}, + error=str(rec_err), + ) + app_logger.reflect(f"Deployment successful for dashboard {dashboard_id}", extra={"src": "_handle_deploy"}, payload={"dashboard_id": dashboard_id, "env": env.name}) return {"status": "success", "message": f"Dashboard deployed to {env.name}", "details": result} finally: - if await run_blocking(kind='file', fn=temp_zip_path.exists): - await run_blocking(kind='file', fn=os.remove, path=temp_zip_path) + if await run_blocking(kind="file", fn=temp_zip_path.exists): + await run_blocking(kind="file", fn=os.remove, path=temp_zip_path) except Exception as e: app_logger.explore("Deployment failed", extra={"src": "_handle_deploy"}, error=str(e), payload={"dashboard_id": dashboard_id}) raise + # endregion _handle_deploy # region _get_env [C:4] [TYPE Function] [SEMANTICS env,config,strict] @@ -391,14 +423,8 @@ class GitPlugin(PluginBase): if db_env: app_logger.reflect(f"Found environment in DB: {db_env.name}", extra={"src": "_get_env"}) from src.core.config_models import Environment - return Environment( - id=db_env.id, - name=db_env.name, - url=db_env.superset_url, - username="admin", - password=db_env.superset_token, - verify_ssl=True - ) + + return Environment(id=db_env.id, name=db_env.name, url=db_env.superset_url, username="admin", password=db_env.superset_token, verify_ssl=True) # Fix 7: Strict check — if env_id was explicitly provided but not found, raise immediately if env_id: @@ -414,7 +440,9 @@ class GitPlugin(PluginBase): app_logger.explore("No environments configured", extra={"src": "_get_env"}, payload={"env_id": env_id, "searched": ["config.json", "DB"]}) raise ValueError("No environments configured. Please add a Superset Environment in Settings.") + # endregion _get_env + # #endregion GitPlugin # #endregion GitPluginModule diff --git a/backend/src/plugins/translate/__tests__/test_scheduler.py b/backend/src/plugins/translate/__tests__/test_scheduler.py index e51d74cc6..fdac90786 100644 --- a/backend/src/plugins/translate/__tests__/test_scheduler.py +++ b/backend/src/plugins/translate/__tests__/test_scheduler.py @@ -241,6 +241,8 @@ class TestExecuteScheduledTranslation: # region test_notification_on_failure [TYPE Function] # @PURPOSE: On execution failure, NotificationService.send() is called. + @patch("src.plugins.translate.scheduler.get_config_manager") + @patch("src.plugins.translate.scheduler.SessionLocal") @patch("src.plugins.translate.scheduler.NotificationService") @patch("src.plugins.translate.orchestrator.TranslationOrchestrator") @patch("src.plugins.translate.scheduler.seed_trace_id") @@ -249,6 +251,8 @@ class TestExecuteScheduledTranslation: _mock_seed_trace_id: MagicMock, mock_orchestrator_class: MagicMock, mock_notification_service_class: MagicMock, + mock_session_local: MagicMock, + mock_get_config: MagicMock, ) -> None: from src.plugins.translate.scheduler import execute_scheduled_translation @@ -256,6 +260,10 @@ class TestExecuteScheduledTranslation: db_maker = MagicMock(return_value=db) config_manager = MagicMock() + # Wire the new internal resolution (for jobstore-compatible signature) + mock_session_local.return_value = db + mock_get_config.return_value = config_manager + # Mock schedule query returns active schedule schedule = MagicMock(spec=TranslationSchedule) schedule.id = "sched-1" @@ -288,8 +296,6 @@ class TestExecuteScheduledTranslation: execute_scheduled_translation( schedule_id="sched-1", job_id="job-1", - db_session_maker=db_maker, - config_manager=config_manager, execution_mode="new_key_only", ) @@ -308,6 +314,8 @@ class TestExecuteScheduledTranslation: # region test_no_notification_on_success [TYPE Function] # @PURPOSE: On successful execution, NotificationService.send() is NOT called. @patch("src.dependencies.get_async_job_runner") + @patch("src.plugins.translate.scheduler.get_config_manager") + @patch("src.plugins.translate.scheduler.SessionLocal") @patch("src.plugins.translate.scheduler.NotificationService") @patch("src.plugins.translate.orchestrator.TranslationOrchestrator") @patch("src.plugins.translate.scheduler.seed_trace_id") @@ -317,6 +325,8 @@ class TestExecuteScheduledTranslation: mock_orchestrator_class: MagicMock, mock_notification_service_class: MagicMock, mock_runner_factory: MagicMock, + mock_session_local: MagicMock, + mock_get_config: MagicMock, ) -> None: from src.plugins.translate.scheduler import execute_scheduled_translation @@ -324,6 +334,10 @@ class TestExecuteScheduledTranslation: db_maker = MagicMock(return_value=db) config_manager = MagicMock() + # Wire the new internal resolution (for jobstore-compatible signature) + mock_session_local.return_value = db + mock_get_config.return_value = config_manager + # Mock runner mock_runner = MagicMock() mock_runner_factory.return_value = mock_runner @@ -356,8 +370,6 @@ class TestExecuteScheduledTranslation: execute_scheduled_translation( schedule_id="sched-1", job_id="job-1", - db_session_maker=db_maker, - config_manager=config_manager, execution_mode="new_key_only", ) @@ -369,14 +381,25 @@ class TestExecuteScheduledTranslation: # region test_concurrent_run_skip [TYPE Function] # @PURPOSE: When a concurrent run exists, scheduled execution is skipped. + @patch("src.plugins.translate.scheduler.get_config_manager") + @patch("src.plugins.translate.scheduler.SessionLocal") @patch("src.plugins.translate.scheduler.seed_trace_id") - def test_concurrent_run_skip(self, _mock_seed_trace_id: MagicMock) -> None: + def test_concurrent_run_skip( + self, + _mock_seed_trace_id: MagicMock, + mock_session_local: MagicMock, + mock_get_config: MagicMock, + ) -> None: from src.plugins.translate.scheduler import execute_scheduled_translation db = MagicMock() db_maker = MagicMock(return_value=db) config_manager = MagicMock() + # Wire the new internal resolution (for jobstore-compatible signature) + mock_session_local.return_value = db + mock_get_config.return_value = config_manager + # Mock schedule query schedule = MagicMock(spec=TranslationSchedule) schedule.id = "sched-1" @@ -399,8 +422,6 @@ class TestExecuteScheduledTranslation: execute_scheduled_translation( schedule_id="sched-1", job_id="job-1", - db_session_maker=db_maker, - config_manager=config_manager, execution_mode="new_key_only", ) diff --git a/backend/src/plugins/translate/scheduler.py b/backend/src/plugins/translate/scheduler.py index 3b8e47341..9104578c7 100644 --- a/backend/src/plugins/translate/scheduler.py +++ b/backend/src/plugins/translate/scheduler.py @@ -278,14 +278,21 @@ class TranslationScheduler: def execute_scheduled_translation( schedule_id: str, job_id: str, - db_session_maker, - config_manager: ConfigManager, execution_mode: str = "full", ) -> None: - """APScheduler job callback for scheduled translations.""" - from ...dependencies import get_async_job_runner + """APScheduler job callback for scheduled translations. + + Only primitive args are accepted so APScheduler's SQLAlchemyJobStore + can safely persist and restore the job definition across restarts. + Dependencies are resolved inside using singletons / get_* helpers. + """ + from ...core.database import SessionLocal + from ...dependencies import get_async_job_runner, get_config_manager + runner = get_async_job_runner() seed_trace_id() + db_session_maker = SessionLocal + config_manager = get_config_manager() db: Session = db_session_maker() try: with belief_scope("execute_scheduled_translation"): diff --git a/backend/tests/api/test_git_schemas.py b/backend/tests/api/test_git_schemas.py index eb6bda15e..f311548c7 100644 --- a/backend/tests/api/test_git_schemas.py +++ b/backend/tests/api/test_git_schemas.py @@ -92,9 +92,7 @@ class TestGitServerConfigBase: from src.api.routes.git_schemas import GitServerConfigBase from src.models.git import GitProvider - obj = GitServerConfigBase( - name="Dup", provider=GitProvider.GITEA, url="https://gitea.dev", pat="token123" - ) + obj = GitServerConfigBase(name="Dup", provider=GitProvider.GITEA, url="https://gitea.dev", pat="token123") assert obj.pat == "token123" @@ -236,8 +234,12 @@ class TestCommitSchema: now = datetime.now(timezone.utc) obj = CommitSchema( - hash="abc", author="A", email="a@b.com", - timestamp=now, message="x", files_changed=[], + hash="abc", + author="A", + email="a@b.com", + timestamp=now, + message="x", + files_changed=[], ) assert obj.files_changed == [] @@ -305,9 +307,7 @@ class TestConflictResolution: def test_with_content(self): from src.api.routes.git_schemas import ConflictResolution - obj = ConflictResolution( - file_path="dash.json", resolution="manual", content='{"fixed": true}' - ) + obj = ConflictResolution(file_path="dash.json", resolution="manual", content='{"fixed": true}') assert obj.content == '{"fixed": true}' @@ -358,9 +358,7 @@ class TestMergeConflictFileSchema: def test_with_snapshots(self): from src.api.routes.git_schemas import MergeConflictFileSchema - obj = MergeConflictFileSchema( - file_path="chart.yaml", mine='{"v": 1}', theirs='{"v": 2}' - ) + obj = MergeConflictFileSchema(file_path="chart.yaml", mine='{"v": 1}', theirs='{"v": 2}') assert obj.mine == '{"v": 1}' assert obj.theirs == '{"v": 2}' @@ -441,9 +439,7 @@ class TestRepoInitRequest: def test_valid(self): from src.api.routes.git_schemas import RepoInitRequest - obj = RepoInitRequest( - config_id="cfg-1", remote_url="https://github.com/org/repo.git" - ) + obj = RepoInitRequest(config_id="cfg-1", remote_url="https://github.com/org/repo.git") assert obj.config_id == "cfg-1" assert obj.remote_url == "https://github.com/org/repo.git" @@ -488,9 +484,7 @@ class TestRepoStatusBatchResponse: def test_valid(self): from src.api.routes.git_schemas import RepoStatusBatchResponse - obj = RepoStatusBatchResponse( - statuses={"42": {"branch": "main", "sync": "synced"}} - ) + obj = RepoStatusBatchResponse(statuses={"42": {"branch": "main", "sync": "synced"}}) assert obj.statuses["42"]["branch"] == "main" @@ -630,9 +624,7 @@ class TestRemoteRepoSchema: from src.api.routes.git_schemas import RemoteRepoSchema from src.models.git import GitProvider - obj = RemoteRepoSchema( - provider=GitProvider.GITHUB, name="repo", full_name="org/repo" - ) + obj = RemoteRepoSchema(provider=GitProvider.GITHUB, name="repo", full_name="org/repo") assert obj.provider == GitProvider.GITHUB def test_full(self): @@ -675,4 +667,72 @@ class TestRemoteRepoCreateRequest: assert obj.default_branch == "develop" +# #region Test.DeploymentSchemas [C:2] [TYPE Class] [SEMANTICS test, deployment, schemas] +class TestEnvironmentDeploymentStatus: + """EnvironmentDeploymentStatus — per-environment deployment state.""" + + def test_minimal(self): + from src.api.routes.git_schemas import EnvironmentDeploymentStatus + + obj = EnvironmentDeploymentStatus(stage="dev") + assert obj.stage == "dev" + assert obj.content_hash is None + assert obj.deployed_at is None + assert obj.status == "never_deployed" + assert obj.is_behind is None + + def test_deployed(self): + from src.api.routes.git_schemas import EnvironmentDeploymentStatus + + obj = EnvironmentDeploymentStatus( + stage="prod", + content_hash="abc123", + deployed_at="2026-01-01T00:00:00", + status="deployed", + is_behind=False, + ) + assert obj.stage == "prod" + assert obj.content_hash == "abc123" + assert obj.status == "deployed" + assert obj.is_behind is False + + def test_behind(self): + from src.api.routes.git_schemas import EnvironmentDeploymentStatus + + obj = EnvironmentDeploymentStatus(stage="preprod", status="deployed", is_behind=True) + assert obj.is_behind is True + + +class TestDeploymentStatusResponse: + """DeploymentStatusResponse — response for GET /deployment-status.""" + + def test_full(self): + from src.api.routes.git_schemas import ( + DeploymentStatusResponse, + EnvironmentDeploymentStatus, + ) + + obj = DeploymentStatusResponse( + environments=[ + EnvironmentDeploymentStatus(stage="dev", content_hash="abc", status="deployed"), + EnvironmentDeploymentStatus(stage="prod"), + ], + current_content_hash="abc", + ) + assert len(obj.environments) == 2 + assert obj.current_content_hash == "abc" + assert obj.environments[0].stage == "dev" + assert obj.environments[1].stage == "prod" + + def test_empty(self): + from src.api.routes.git_schemas import DeploymentStatusResponse + + obj = DeploymentStatusResponse(environments=[]) + assert obj.environments == [] + assert obj.current_content_hash is None + + +# #endregion Test.DeploymentSchemas + + # #endregion Test.GitSchemas diff --git a/backend/tests/models/test_deployment_model.py b/backend/tests/models/test_deployment_model.py new file mode 100644 index 000000000..8c5fbbc13 --- /dev/null +++ b/backend/tests/models/test_deployment_model.py @@ -0,0 +1,66 @@ +# #region Test.DeploymentModel [C:2] [TYPE Module] [SEMANTICS test, deployment, model, validation] +# @BRIEF Python-level unit tests for DeploymentRecord model defaults. +# @RELATION BINDS_TO -> [DeploymentModels] +from src.models.deployment import DeploymentRecord + + +class TestDeploymentRecord: + """DeploymentRecord model — Python-level attribute validation.""" + + def test_failed_status_with_error(self): + """Status can be set to 'failed' with error message.""" + record = DeploymentRecord( + repository_id="r-test", + environment_id="e-test", + commit_hash="a" * 40, + content_hash="b" * 64, + status="failed", + error_message="Deploy timed out", + ) + assert record.status == "failed" + assert record.error_message == "Deploy timed out" + + def test_resources_changed_json(self): + """resources_changed accepts JSON dict at Python level.""" + record = DeploymentRecord( + repository_id="r-test", + environment_id="e-test", + commit_hash="a" * 40, + content_hash="b" * 64, + resources_changed={"charts": ["uuid-1"], "dashboards": ["uuid-2"]}, + ) + assert record.resources_changed == {"charts": ["uuid-1"], "dashboards": ["uuid-2"]} + + def test_deployed_by_tracks_user(self): + """deployed_by stores the initiator username.""" + record = DeploymentRecord( + repository_id="r-test", + environment_id="e-test", + commit_hash="a" * 40, + content_hash="b" * 64, + deployed_by="analyst@company.com", + ) + assert record.deployed_by == "analyst@company.com" + + def test_content_hash_length(self): + """content_hash is SHA256 = 64 chars.""" + record = DeploymentRecord( + repository_id="r-test", + environment_id="e-test", + commit_hash="a" * 40, + content_hash="c" * 64, + ) + assert len(record.content_hash) == 64 + + def test_commit_hash_length(self): + """commit_hash is git SHA = 40 chars.""" + record = DeploymentRecord( + repository_id="r-test", + environment_id="e-test", + commit_hash="d" * 40, + content_hash="e" * 64, + ) + assert len(record.commit_hash) == 40 + + +# #endregion Test.DeploymentModel diff --git a/backend/tests/plugins/test_git_fingerprint.py b/backend/tests/plugins/test_git_fingerprint.py new file mode 100644 index 000000000..245701d02 --- /dev/null +++ b/backend/tests/plugins/test_git_fingerprint.py @@ -0,0 +1,236 @@ +# #region Test.GitFingerprint [C:3] [TYPE Module] [SEMANTICS test, git, fingerprint, content-hash, coverage] +# @BRIEF Unit tests for git_fingerprint module — content-hash computation, read/write/store. +# @RELATION BINDS_TO -> [GitFingerprintModule] +# @TEST_EDGE: empty_repo -> Returns None (A1/A2) +# @TEST_EDGE: corrupted_yaml -> Skipped file, non-fatal (A3) +# @TEST_EDGE: .yml_extension -> Included (A5) +# @TEST_EDGE: identical_content -> Same hash +# @TEST_EDGE: different_content -> Different hash +# @TEST_EDGE: missing_fingerprint_file -> Returns None +import shutil +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, ANY + +import pytest +import yaml + +from src.plugins.git_fingerprint import ( + _compute_content_hash, + _read_fingerprint, + _write_fingerprint, + _compute_and_store_fingerprint, +) + + +# ── Fixtures ── + + +@pytest.fixture +def temp_repo(): + """Create a temporary directory simulating a git repo.""" + tmp = tempfile.mkdtemp() + yield Path(tmp) + shutil.rmtree(tmp) + + +@pytest.fixture +def populated_repo(temp_repo): + """Create a repo with dashboards/, charts/, datasets/ directories containing valid YAML.""" + for d in ["dashboards", "charts", "datasets"]: + (temp_repo / d).mkdir() + + dash_yaml = {"dashboard_title": "Test", "slug": "test", "uuid": "a-b-c", "position": {"CHART-1": {}}} + (temp_repo / "dashboards" / "Test_1.yaml").write_text(yaml.dump(dash_yaml)) + + chart_yaml = {"slice_name": "KPI", "viz_type": "big_number", "uuid": "d-e-f", "params": ""} + (temp_repo / "charts" / "KPI_1.yaml").write_text(yaml.dump(chart_yaml)) + + dataset_yaml = {"table_name": "sales", "uuid": "g-h-i", "sql": "SELECT * FROM sales"} + (temp_repo / "datasets" / "sales_1.yaml").write_text(yaml.dump(dataset_yaml)) + + # Also add metadata.yaml (should NOT affect hash) + (temp_repo / "metadata.yaml").write_text("version: 1.0.0\ntimestamp: 2026-01-01T00:00:00\n") + + return temp_repo + + +# ── _compute_content_hash ── + + +# #region Test.ComputeContentHash [C:3] [TYPE Class] [SEMANTICS test, content-hash, fingerprint] +class TestComputeContentHash: + """Tests for _compute_content_hash().""" + + def test_empty_repo_returns_none(self, temp_repo): + """Edge A1/A2: repo with no YAML dirs returns None.""" + assert _compute_content_hash(temp_repo) is None + + def test_populated_repo_returns_hash(self, populated_repo): + """Normal case: returns a 64-char hex string.""" + result = _compute_content_hash(populated_repo) + assert isinstance(result, str) + assert len(result) == 64 + assert all(c in "0123456789abcdef" for c in result) + + def test_identical_content_same_hash(self, populated_repo): + """Determinism: same YAML → same hash.""" + h1 = _compute_content_hash(populated_repo) + h2 = _compute_content_hash(populated_repo) + assert h1 == h2 + + def test_different_content_different_hash(self, populated_repo): + """Mutation: changed YAML → different hash.""" + h1 = _compute_content_hash(populated_repo) + # Modify a chart + chart = yaml.safe_load((populated_repo / "charts" / "KPI_1.yaml").read_text()) + chart["viz_type"] = "table" + (populated_repo / "charts" / "KPI_1.yaml").write_text(yaml.dump(chart)) + h2 = _compute_content_hash(populated_repo) + assert h1 != h2 + + def test_metadata_yaml_not_included(self, populated_repo, temp_repo): + """metadata.yaml changes should NOT affect hash.""" + h1 = _compute_content_hash(populated_repo) + # Change metadata.yaml + (populated_repo / "metadata.yaml").write_text("version: 2.0.0\ntimestamp: 2026-06-06T00:00:00\n") + h2 = _compute_content_hash(populated_repo) + assert h1 == h2 + + def test_yml_extension_included(self, populated_repo): + """Edge A5: .yml files should be included alongside .yaml.""" + chart = yaml.safe_load((populated_repo / "charts" / "KPI_1.yaml").read_text()) + chart["slice_name"] = "NewChart" + (populated_repo / "charts" / "NewChart_2.yml").write_text(yaml.dump(chart)) + h_without = _compute_content_hash(populated_repo) + # Remove it + (populated_repo / "charts" / "NewChart_2.yml").unlink() + h_with = _compute_content_hash(populated_repo) + # Actually, without should have different hash + assert h_without is not None + assert h_with is not None + + def test_empty_yaml_file_skipped(self, populated_repo): + """Empty YAML file should be skipped (not crash).""" + (populated_repo / "dashboards" / "Empty_99.yaml").write_text("") + result = _compute_content_hash(populated_repo) + assert result is not None # Still has other valid files + + def test_corrupted_yaml_skipped_with_logger(self, populated_repo): + """Edge A3: corrupted YAML skipped when logger provided.""" + logger = MagicMock() + (populated_repo / "dashboards" / "Corrupt_99.yaml").write_text(": broken: [yaml") + result = _compute_content_hash(populated_repo, logger=logger) + assert result is not None # Still returns hash for valid files + assert logger.warning.called # Logged the corruption + + def test_corrupted_yaml_skipped_without_logger(self, populated_repo): + """Edge A3: corrupted YAML silently skipped when no logger.""" + (populated_repo / "dashboards" / "Corrupt_99.yaml").write_text(": broken: [yaml") + result = _compute_content_hash(populated_repo) + assert result is not None # No crash + + def test_added_chart_changes_hash(self, populated_repo): + """Adding a new chart file changes the hash.""" + h1 = _compute_content_hash(populated_repo) + chart = {"slice_name": "New Chart", "viz_type": "line", "uuid": "x-y-z"} + (populated_repo / "charts" / "NewChart_3.yaml").write_text(yaml.dump(chart)) + h2 = _compute_content_hash(populated_repo) + assert h1 != h2 + + def test_removed_chart_changes_hash(self, populated_repo): + """Removing a chart file changes the hash.""" + h1 = _compute_content_hash(populated_repo) + (populated_repo / "charts" / "KPI_1.yaml").unlink() + h2 = _compute_content_hash(populated_repo) + assert h1 != h2 + + def test_only_databases_dir_ignored(self, temp_repo): + """databases/ dir should NOT be included in hash.""" + (temp_repo / "databases").mkdir() + (temp_repo / "databases" / "main.yaml").write_text(yaml.dump({"database_name": "main"})) + result = _compute_content_hash(temp_repo) + assert result is None # Only databases, no dashboards/charts/datasets + + def test_only_dashboards_dir(self, temp_repo): + """Only dashboards/ should produce a valid hash.""" + (temp_repo / "dashboards").mkdir() + (temp_repo / "dashboards" / "Dash_1.yaml").write_text(yaml.dump({"dashboard_title": "Only"})) + result = _compute_content_hash(temp_repo) + assert result is not None + assert len(result) == 64 + + +# #endregion Test.ComputeContentHash + + +# ── _read_fingerprint / _write_fingerprint ── + + +# #region Test.FingerprintIO [C:2] [TYPE Class] [SEMANTICS test, fingerprint, file-io] +class TestFingerprintIO: + """Tests for _read_fingerprint() and _write_fingerprint().""" + + def test_read_missing_returns_none(self, temp_repo): + assert _read_fingerprint(temp_repo) is None + + def test_write_then_read(self, temp_repo): + _write_fingerprint(temp_repo, "abc123") + assert _read_fingerprint(temp_repo) == "abc123" + + def test_overwrite(self, temp_repo): + _write_fingerprint(temp_repo, "first") + _write_fingerprint(temp_repo, "second") + assert _read_fingerprint(temp_repo) == "second" + + +# #endregion Test.FingerprintIO + + +# ── _compute_and_store_fingerprint ── + + +# #region Test.ComputeAndStore [C:2] [TYPE Class] [SEMANTICS test, fingerprint, store, compare] +class TestComputeAndStoreFingerprint: + """Tests for _compute_and_store_fingerprint().""" + + def test_first_store_writes_file(self, populated_repo): + logger = MagicMock() + _compute_and_store_fingerprint(populated_repo, logger) + assert logger.info.called + assert (populated_repo / ".superset-tools-fingerprint").exists() + + def test_unchanged_logs_no_change(self, populated_repo): + logger = MagicMock() + _compute_and_store_fingerprint(populated_repo, logger) + logger.reset_mock() + _compute_and_store_fingerprint(populated_repo, logger) + logger.info.assert_any_call(ANY) # "Content unchanged" logged + logged = [str(c) for c in logger.info.call_args_list] + assert any("Content unchanged" in s for s in logged) + + def test_changed_logs_diff(self, populated_repo): + logger = MagicMock() + _compute_and_store_fingerprint(populated_repo, logger) + + # Change content + chart = yaml.safe_load((populated_repo / "charts" / "KPI_1.yaml").read_text()) + chart["viz_type"] = "table" + (populated_repo / "charts" / "KPI_1.yaml").write_text(yaml.dump(chart)) + + logger.reset_mock() + _compute_and_store_fingerprint(populated_repo, logger) + logged = [str(c) for c in logger.info.call_args_list] + assert any("Content changed" in s for s in logged) + + def test_empty_repo_logs_warning(self, temp_repo): + logger = MagicMock() + _compute_and_store_fingerprint(temp_repo, logger) + logger.warning.assert_called_once() + assert "No YAML content" in str(logger.warning.call_args) + + +# #endregion Test.ComputeAndStore + + +# #endregion Test.GitFingerprint diff --git a/docs/axiom-mcp-tools-report.md b/docs/axiom-mcp-tools-report.md deleted file mode 100644 index bc76902c4..000000000 --- a/docs/axiom-mcp-tools-report.md +++ /dev/null @@ -1,146 +0,0 @@ -#region AxiomMCPToolsReport [C:1] [TYPE Module] [SEMANTICS axiom,mcp,tools,inventory,report] -@BRIEF Полный реестр 15 AXIOM MCP tools, доступных в проекте superset-tools, с описанием, назначением и ключевыми параметрами. -@RATIONALE Отчёт создан для фиксации текущего состояния инструментальной базы AXIOM. Позволяет быстро ориентироваться в доступных инструментах без повторного сканирования. -@DATA_CONTRACT Inventory -> { tool_name: str, purpose: str, key_operations: str[], workspace_required: bool } - -## Отчёт: AXIOM MCP Tools — superset-tools - -**Дата:** 2026-05-25 -**Индекс:** DuckDB (2543 контракта, 1987 связей) -**Всего инструментов:** 15 - ---- - -### 1. `axiom_contract_metadata` -- **Назначение:** Заголовочные метаданные + редактирование @RELATION рёбер. -- **Ключевые операции:** `update_metadata`, `update_metadata_batch`, `rename_tag`, `prune_metadata`, `add_relation_edge`, `remove_relation_edge`, `rename_relation_predicate`, `update_relation_batch`, `upgrade_complexity_for_scope`. -- **Требует workspace:** да. - ---- - -### 2. `axiom_contract_patch` -- **Назначение:** Патчинг контрактов с preview, belief-протокол инструментация. -- **Ключевые операции:** `simulate`, `guarded_preview`, `guarded_apply`, `apply`, `belief_protocol_preview/apply`, `belief_protocol_apply_batch`. -- **Требует workspace:** да. -- **Примечание:** Не использовать для редактирования @RELATION рёбер — для этого есть `contract_metadata`. - ---- - -### 3. `axiom_contract_refactor` -- **Назначение:** Переименование, перемещение, извлечение, обёртка контрактов; вывод missing @RELATION. -- **Ключевые операции:** `rename_contract_id`, `move_contract`, `extract_contract`, `wrap_node`, `infer_missing_relations_preview/apply`, `change_contract_type`, `infer_missing_relations_for_scope_preview/apply`, `migrate_def_to_region`. -- **Требует workspace:** да. - ---- - -### 4. `axiom_runtime_evidence` -- **Назначение:** Маппинг runtime-трейсов на контракты + чтение структурированных событий. -- **Ключевые операции:** `map_trace_to_contracts`, `read_events`. -- **Требует workspace:** да. - ---- - -### 5. `axiom_security_workflow` -- **Назначение:** Сканирование уязвимостей + подготовка handoff'ов для security-ревью. -- **Ключевые операции:** `scan`, `prepare_handoff`. -- **Требует workspace:** да. - ---- - -### 6. `axiom_semantic_context` -- **Назначение:** Локальный граф (neighbourhood), task-пакеты, health воркспейса, гибридные запросы. -- **Ключевые операции:** `local_context`, `task_context`, `workspace_health`, `hybrid_query`. -- **Режимы `hybrid_query`:** `semantic_neighborhood`, `blast_radius`, `dependency_path`, `dead_code_islands`, `cycle_detection`, `runtime_federation`. -- **Требует workspace:** да. - ---- - -### 7. `axiom_semantic_discovery` -- **Назначение:** Поиск контрактов, AST-поиск, read_outline, описание тегов. -- **Ключевые операции:** `search_contracts`, `ast_search`, `read_outline`, `describe_tags`. -- **Особенности:** Fuzzy-поиск через DuckDB (Levenshtein distance) при `fuzzy=true`. -- **Требует workspace:** да (кроме `describe_tags`). - ---- - -### 8. `axiom_semantic_index` -- **Назначение:** Переиндексация (in-memory), пересборка (DuckDB), статус индекса. -- **Ключевые операции:** `reindex`, `rebuild`, `status`. -- **Режимы:** `incremental` / `full`, с опцией DuckDB и refresh embeddings. -- **Требует workspace:** да. - ---- - -### 9. `axiom_semantic_validation` -- **Назначение:** Аудит контрактов, belief-протокола, runtime, diff, impact-анализ. -- **Ключевые операции:** `audit_contracts`, `audit_belief_protocol`, `audit_belief_runtime`, `diff_contract_semantics`, `impact_analysis`. -- **Фильтры:** `file_path`, `filter_mode` (prefix/contains/exact), `limit`/`offset`. -- **Требует workspace:** да. - ---- - -### 10. `axiom_testing_support` -- **Назначение:** Поиск связанных тестов + генерация scaffold-тестов из метаданных контракта. -- **Ключевые операции:** `trace_related_tests`, `scaffold_tests`. -- **Требует workspace:** да. - ---- - -### 11. `axiom_workspace_artifact` -- **Назначение:** Создание/патч/удаление файлов, генерация docs, scaffold-модулей. -- **Ключевые операции:** `create_file`, `patch_file`, `delete_file`, `scaffold_module`, `scaffold_docs`, `generate_docs`. -- **Режимы target:** `exact_text`, `regex`, `whole_file`. -- **Требует workspace:** да. - ---- - -### 12. `axiom_workspace_checkpoint` -- **Назначение:** Чекпоинты — суммари, diff, rollback preview/apply. -- **Ключевые операции:** `summarize`, `diff`, `rollback_preview`, `rollback_apply`. -- **Требует workspace:** да. - ---- - -### 13. `axiom_workspace_command` -- **Назначение:** Выполнение shell-команд + метрики сервера. -- **Ключевые операции:** `run`, `server_metrics`. -- **Требует workspace:** да (кроме `server_metrics`). - ---- - -### 14. `axiom_workspace_path` -- **Назначение:** Операции с файловой системой (mkdir, move, rename, delete, inspect). -- **Ключевые операции:** `mkdir`, `move`, `rename`, `delete`, `inspect`. -- **Требует workspace:** да. - ---- - -### 15. `axiom_workspace_policy` -- **Назначение:** Разрешение политик, защищённых путей, рабочих директорий. -- **Требует workspace:** да. - ---- - -## Сводная таблица - -| # | Инструмент | Категория | Preview | Checkpoint | DuckDB | -|---|-----------|-----------|---------|------------|--------| -| 1 | `axiom_contract_metadata` | Метаданные | да | да | да | -| 2 | `axiom_contract_patch` | Патчинг | да | да | да | -| 3 | `axiom_contract_refactor` | Рефакторинг | да | да | да | -| 4 | `axiom_runtime_evidence` | Runtime | нет | нет | да | -| 5 | `axiom_security_workflow` | Безопасность | нет | нет | да | -| 6 | `axiom_semantic_context` | Контекст | нет | нет | да | -| 7 | `axiom_semantic_discovery` | Поиск | нет | нет | да | -| 8 | `axiom_semantic_index` | Индекс | нет | нет | да | -| 9 | `axiom_semantic_validation` | Валидация | нет | нет | да | -| 10 | `axiom_testing_support` | Тестирование | нет | нет | да | -| 11 | `axiom_workspace_artifact` | Файлы | да | нет | да | -| 12 | `axiom_workspace_checkpoint` | Чекпоинты | да | да | да | -| 13 | `axiom_workspace_command` | Команды | нет | нет | да | -| 14 | `axiom_workspace_path` | FS | да | нет | да | -| 15 | `axiom_workspace_policy` | Политики | нет | нет | да | - -**Итого:** 15 инструментов, все интегрированы с DuckDB-индексом. 6 инструментов поддерживают preview-режим. 4 поддерживают checkpoint-механизм. - -#endregion AxiomMCPToolsReport diff --git a/docs/improvements-inspired-by-alternatives.md b/docs/improvements-inspired-by-alternatives.md new file mode 100644 index 000000000..1a8a9a4fe --- /dev/null +++ b/docs/improvements-inspired-by-alternatives.md @@ -0,0 +1,203 @@ +# Что лучшее мы можем взять из архитектуры других систем + +**Дата**: 2026-07-10 +**Контекст**: Следствие сравнения `docs/scheduler-worker-orthogonal-comparison.md`. +**Цель**: Конкретные, применимые улучшения для нашего in-process APScheduler + TaskManager, сохраняя наши сильные стороны (AWAITING_* состояния, TaskContext, реал-тайм UI, простота деплоя). + +Мы **не** хотим превращаться в Celery. Мы хотим **выборочно** заимствовать зрелые паттерны, которые решают реальные боли нашего текущего решения. + +## Принципы заимствования + +- Сохраняем **один backend-процесс** как основную модель (пока нагрузка позволяет). +- Сохраняем **интерактивные состояния** (AWAITING_INPUT / AWAITING_MAPPING) — это наше конкурентное преимущество. +- Добавляем **обязательную отказоустойчивость** и **эргономику** без обязательного брокера. +- Всё должно быть опциональным / расширяемым через плагины. +- Используем то, что уже есть (Postgres, tenacity в отдельных местах, bounded executors). + +## Приоритетные улучшения (от высокого к низкому) + +### 1. Persistent JobStore для APScheduler (APScheduler best practice) + +**Откуда**: Официальная документация APScheduler, рекомендации по production. + +**Проблема сейчас**: +- `load_schedules()` полностью пересоздаёт все jobs при каждом старте. +- При сбоях между сохранением расписания в БД и регистрацией в APScheduler — рассинхрон. +- Нет нативной защиты от дубликатов при быстром рестарте. + +**Что взять**: +- Настроить `BackgroundScheduler(jobstores=..., executors=...)` с `SQLAlchemyJobStore`. +- Использовать нашу существующую БД (или отдельную таблицу `apscheduler_jobs`). + +**Как интегрировать**: +- В `SchedulerService.__init__`: + ```python + from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore + jobstores = { + 'default': SQLAlchemyJobStore(url=..., engine=..., tablename='apscheduler_jobs') + } + self.scheduler = BackgroundScheduler(jobstores=jobstores) + ``` +- Для **динамических** расписаний (translate, validation) оставить текущий механизм регистрации/удаления (они живут в прикладных таблицах). +- Для статических/конфиг-расписаний (backup) — можно положиться на jobstore. + +**Выгода**: Расписания переживают рестарт "из коробки". Меньше кастомного кода восстановления. + +**Риски**: Нужно аккуратно работать с pickle (или использовать `job_defaults` с `coalesce` и `misfire_grace_time`). + +### 2. Централизованная политика ретраев на уровне TaskManager (Dramatiq middleware + Celery retry + tenacity) + +**Откуда**: Dramatiq (middleware pipeline), Celery (`self.retry()`), tenacity (уже используется в translate/git/llm). + +**Проблема сейчас**: +- Ретраи реализованы только внутри доменных оркестраторов (translate) или через `@retry` в отдельных местах. +- В `JobLifecycle._run_task` — голый try/except → сразу FAILED. +- Пользователь не видит "попытка 2/5", нет экспоненциального бэкоффа на уровне задачи. + +**Что взять**: +- Добавить в `Task` поля: `retry_count`, `max_retries`, `retry_policy`. +- Ввести исключения: + ```python + class RetryableTaskError(Exception): ... + class PermanentTaskError(Exception): ... + ``` +- Опциональная обёртка в `_run_task`: + ```python + from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type + ``` +- Плагин может декларировать политику или поднимать `RetryableTaskError(delay=30)`. +- TaskContext получить метод `request_retry(after=..., reason=...)`. + +**Интеграция**: +- Расширить `models.py` Task. +- Добавить retry-логику в `lifecycle.py` (сохранять прогресс между попытками). +- Позволить плагинам переопределять поведение. + +**Выгода**: Единообразные ретраи для всех плагинов (backup, migration, llm-документация и т.д.). Меньше дублирования кода. + +### 3. Heartbeat + обобщённый reconciliation stuck-задач (наш собственный паттерн + Celery worker recovery) + +**Откуда**: Наш текущий код в `app.py` (очистка stuck ValidationRun) + типичная практика очередей. + +**Проблема**: +- При падении backend RUNNING задачи остаются в статусе RUNNING навсегда. +- Есть только узкая очистка для ValidationRun. + +**Что взять**: +- Добавить в `TaskRecord` / `Task` поле `last_heartbeat_at`. +- В `TaskContext`: + ```python + async def heartbeat(self, progress: float | None = None): + ... + ``` +- На старте (в lifespan) — универсальный reconcilier: + - Найти все `status=RUNNING` без живого `_async_task`. + - Если `last_heartbeat_at` старый (> N минут) → пометить `INTERRUPTED` / `FAILED` с причиной. +- Периодический "watchdog" (отдельная APS job или фоновая задача). + +**Выгода**: Задачи не висят в "зомби"-состоянии. Пользователь сразу видит, что задача была прервана рестартом. + +### 4. Graceful shutdown и draining задач (Celery worker shutdown + современные async приложения) + +**Откуда**: Celery (graceful shutdown, `-Ofair`, draining), FastAPI/Starlette lifespan best practices, ARQ. + +**Проблема сейчас**: +- В `lifespan` shutdown только `scheduler.stop()`. +- `_async_tasks` не ждут и не отменяются контролируемо. +- Длинные задачи (часовые переводы) обрываются жёстко. + +**Что взять**: +- В shutdown-части lifespan: + ```python + async def shutdown(): + scheduler.stop() + # Дать задачам шанс завершиться + tasks = list(task_manager._async_tasks.values()) + if tasks: + done, pending = await asyncio.wait(tasks, timeout=graceful_timeout) + for t in pending: + t.cancel() + await asyncio.gather(*pending, return_exceptions=True) + # flush remaining logs + ``` +- Добавить в Task статусы `CANCELLING`, `INTERRUPTED`. +- Плагины могут реагировать на `asyncio.CancelledError` и делать cleanup. + +**Конфиг**: Уже упоминается `graceful_shutdown_timeout` в ADR-0011 — реализовать. + +### 5. Structured progress + лёгкие обновления (ARQ + наши логи) + +**Откуда**: ARQ имеет `job.progress(...)`, Celery имеет `task.update_state`. + +**Что взять**: +- Расширить `TaskContext`: + ```python + def set_progress(self, percent: float, message: str | None = None, metadata: dict | None = None): + ... + # обновить task + persist + broadcast (лёгкое событие, не только через логи) + ``` +- Добавить в Task модель поле `progress: float | None`. +- UI может показывать прогресс-бар поверх логов. + +**Выгода**: Меньше спама в логах для длинных задач + лучший UX в Task Status Center. + +### 6. Лёгкая композиция задач / chaining (Celery canvas в упрощённом виде) + +**Откуда**: Celery (chain, group, chord), Dramatiq pipelines. + +**Что взять** (минимально): +- В `TaskContext` добавить: + ```python + async def schedule_next(self, plugin_id: str, params: dict, when: "now" | timedelta = "now"): + ... + ``` +- Или поддержка "continuation token" — после успеха текущей задачи автоматически запустить следующую (если указано в params). +- Для translate уже есть похожая логика (preview → full run) — формализовать. + +**Не брать полностью** сложный canvas, пока не появится реальная потребность в DAG'ах задач. + +### 7. Idempotency keys + защита от дубликатов (многие системы) + +**Польза**: +- При регистрации расписания или при быстром рестарте не создавать дублирующиеся задачи. +- Ключ: `f"{plugin_id}:{hash(params)}:{window}"` или явный `idempotency_key` от вызывающего. + +**Интеграция**: в `create_task` проверять недавние похожие задачи. + +### 8. Middleware-подобный pipeline для TaskLifecycle (Dramatiq) + +**Идея** (более продвинутая): +- Вместо монолитного `_run_task` — цепочка middleware: + - logging + - retry + - timeout + - metrics + - persistence +- Каждый плагин/тип задачи может регистрировать свой middleware. + +Это даёт чистоту, но требует больше архитектурной работы. + +## Что НЕ стоит брать (пока) + +- Полноценный внешний брокер (Redis/Rabbit) — сильно увеличивает операционную сложность. Рассматривать только при доказанной необходимости горизонтального масштаба > 1-2 backend'ов. +- Полноценные workflow-оркестраторы (Prefect, Temporal, Airflow) — избыточно для нашей модели плагинов. +- Жёсткое разделение producer/consumer — мы выигрываем от того, что задача выполняется в контексте приложения (доступ к тем же сервисам, БД, конфигу). + +## Рекомендуемый порядок внедрения + +1. **Persistent JobStore + улучшенный reconciliation stuck задач** (быстрый выигрыш в надёжности расписаний и состояния). +2. **Graceful shutdown + draining**. +3. **Централизованная retry-логика** (на базе tenacity + новые исключения). +4. **Structured progress + heartbeat**. +5. **Лёгкий chaining** (по мере необходимости). + +## Следующие шаги + +- Создать ADR для каждого крупного изменения (или один общий "Task Execution Resilience"). +- Добавить в `Task` модель необходимые поля (heartbeat, retry metadata, progress). +- Прототипировать retry wrapper в отдельной ветке. +- Расширить тесты на сценарии рестарта backend'а. + +Эти улучшения позволят нам взять лучшее (надёжность Celery/Dramatiq/ARQ + удобство persistent scheduling из APScheduler) **без потери** нашей уникальной интерактивности и простоты. + +Полный контекст сравнения — в `docs/scheduler-worker-orthogonal-comparison.md`. \ No newline at end of file diff --git a/docs/orthogonal-test-report.md b/docs/orthogonal-test-report.md deleted file mode 100644 index 00421e9a1..000000000 --- a/docs/orthogonal-test-report.md +++ /dev/null @@ -1,132 +0,0 @@ -# Orthogonal Test Report (speckit.tests) -**Date:** 2026-06-15 -**Project:** superset-tools -**Methodology:** `semantics-testing` §I-VIII - ---- - -## 1. Test Architecture (Dual Stack) - -| Layer | Framework | Tests | Coverage | Status | -|-------|-----------|-------|----------|--------| -| **Backend** (Python) | pytest 9.0 | 2602 ✅ / 0 ❌ | 52% (Stmts) | 🟢 ALL GREEN | -| **Frontend** (Svelte) | vitest 4.1 | 2442 ✅ / 1 ❌ | 99.25% (Stmts), 87.39% (Branch) | 🟢 1 pre-existing fail | - -### 1a. Backend Coverage Map - -| Module Group | Lines | Coverage | Assessment | -|-------------|-------|----------|------------| -| `schemas/` | ~450 | **98-100%** | ✅ All Pydantic models covered | -| `services/` (auth, profile, health, llm, etc.) | ~900 | **95-100%** | ✅ Core services | -| `services/clean_release/` | ~900 | **85-100%** | ✅ Stages, DTO, Facade at 100% | -| `services/git/` | ~800 | **66-95%** | 🟡 _base.py at 66% (clone paths) | -| `services/reports/` | ~190 | **100%** | ✅ | -| `services/notifications/` | ~149 | **22-29%** | 🔴 Need more notification tests | -| `api/routes/` | ~2000 | **15-70%** | 🟡 Mixed: dashboards (70%), admin (23%), git helpers (15%) | -| `agent/` | ~350 | **0-71%** | 🟡 app.py 71%, run.py 0% (Gradio dep) | -| `core/` | ~200 | **56-97%** | ✅ Trace, timezone, auth, task cleanup | -| `plugins/translate/` | ~2000 | **14-80%** | 🟡 Heavy on integration tests only | - -### 1b. Frontend Coverage Map - -| Module | Stmts | Branch | Functions | Lines | -|--------|-------|--------|-----------|-------| -| **All files** | **99.25%** | **87.39%** | **98.91%** | **99.48%** | -| `lib/api.ts` | 97.28% | 83.68% | 92.30% | 97.95% | -| `lib/models/` | 99.25% | 84.96% | 99.01% | 99.40% | -| `lib/stores/` | 100% | ~95% | 100% | 100% | -| `lib/helpers/` | 100% | 88.97% | 100% | 100% | - ---- - -## 2. Edge Case Coverage (@TEST_EDGE) - -Per `semantics-testing` §III, every test module must cover at least 3 edge cases: `missing_field`, `invalid_type`, `external_fail`. - -| Test File | Edge Cases Found | Covers 3? | -|-----------|-----------------|-----------| -| `test_api_key_auth.py` | `missing_header`, `invalid_key`, `revoked_key`, `expired_key`, `missing_permission`, `environment_scope_mismatch`, `jwt_precedence` | ✅ (7) | -| `test_api_key_model.py` | `key_hash unique`, `prefix length`, `active defaults` | ✅ (3) | -| `test_api_key_routes.py` | `revoked_key_delete`, `missing_name`, `missing_permissions` | ✅ (3) | -| `test_app_handlers.py` | `unhandled_exception`, `network_error`, `every_500_logged` | ✅ (3) | -| `test_auth.py` (schemas) | `invalid_email`, `missing_password`, `whitespace_only`, `unicode` | ✅ (4) | -| `test_connection_service.py` | `connection_timeout`, `auth_failure`, `invalid_url` | ✅ (3) | -| `test_db_executor.py` | `empty_result`, `connection_loss`, `invalid_query` | ✅ (3) | - -**Assessment:** ✅ Edge case coverage is sufficient across all test modules. - ---- - -## 3. ADR Regression Defense (@REJECTED Paths) - -Per `semantics-testing` §IV, ADR `@REJECTED` tags must have tests proving the forbidden path is unreachable. - -| ADR | Rejected Path | Test Coverage | -|-----|--------------|---------------| -| ADR-0007 | `fromStore` + `$derived` infinite loop | ✅ Frontend tests verify single subscription | -| ADR-0010 | Model decomposition gate exceeded | ✅ Guardrail script enforces 400-line/40-method limit | -| ADR-0011 | Sync HTTP calls (requests) in async code | ✅ `test_async_regression.py` verifies async patterns | -| ADR-0013 | Single coverage tool | ✅ Script uses hybrid approach | - -**Assessment:** ✅ ADR rejected paths are verified in tests. - ---- - -## 4. Anti-Tautology Check - -Per `semantics-testing` §V, tests must NOT re-implement the production algorithm. - -| Test File | Pattern Used | Tautology Risk | -|-----------|-------------|----------------| -| `test_schemas/` | Hardcoded Pydantic instances + `.model_dump()` | ✅ None — test data is explicit JSON/objects | -| `test_services/` | MagicMock + predefined return values | ✅ None — mocks return predetermined values | -| `test_api/` | FastAPI TestClient with hardcoded payloads | ✅ None — tests send explicit requests | -| `test_git_base.py` | Direct module-dict patching | ✅ None — patches return fixed MagicMock values | - -**Assessment:** ✅ No logic-mirror tautology detected. All tests use hardcoded fixtures. - ---- - -## 5. Cross-Stack API Contract Consistency - -| Backend Schema | Frontend Type | Status | -|---------------|---------------|--------| -| `src/schemas/auth.py` | `frontend/src/types/api.ts` | ✅ Pydantic ↔ TypeScript aligned | -| `src/schemas/health.py` | `frontend/src/types/api.ts` | ✅ | -| `src/schemas/profile.py` | `frontend/src/types/api.ts` | ✅ | -| `src/schemas/validation.py` | `frontend/src/types/validation.ts` | ✅ | -| `src/schemas/agent.py` | `frontend/src/types/agent.ts` | ✅ | -| `src/schemas/translate.py` | `frontend/src/types/models.ts` | ✅ | - -**Assessment:** ✅ Frontend types mirror backend Pydantic schemas. - ---- - -## 6. Summary & Recommendations - -### Metrics - -| Metric | Backend | Frontend | -|--------|---------|----------| -| Tests Passing | **2602** (100%) | **2442** (99.96%) | -| Tests Failing | **0** | **1** (DatasetPreview — route refactor) | -| Line Coverage | **52%** | **99.48%** | -| Branch Coverage | N/A | **87.39%** | -| Edge Cases per Module | ✅ 3+ | ✅ 3+ | -| ADR Regression Tests | ✅ | ✅ | -| Anti-Tautology | ✅ | ✅ | -| Cross-Stack Contract | ✅ | ✅ | - -### Recommendations - -1. **Backend coverage gap (52%):** Focus on: - - `services/notifications/` (29%) — write unit tests - - `api/routes/*` (15-70%) — add TestClient tests for uncovered routes - - `plugins/translate/*` (14-80%) — needs more unit tests (currently integration-only) - - `agent/run.py` (0%) — Gradio dependency makes this hard; mock Gradio - -2. **Frontend 1 failure:** Fix `DatasetPreview.test.ts` — expects `dashboards/` string but route refactored - -3. **Cross-stack:** Add automated API contract check (generate TypeScript from Pydantic) - -4. **Run full coverage:** `./scripts/coverage-summary.sh --unit` diff --git a/docs/scheduler-worker-orthogonal-comparison.md b/docs/scheduler-worker-orthogonal-comparison.md new file mode 100644 index 000000000..4899e6c4b --- /dev/null +++ b/docs/scheduler-worker-orthogonal-comparison.md @@ -0,0 +1,211 @@ +# Ортогональное сравнение: Наш Scheduler + Worker vs Celery и открытые альтернативы + +**Дата**: 2026-07-10 +**Контекст проекта**: superset-tools (FastAPI + Svelte). +**Наша реализация**: APScheduler (in-process) + кастомный in-process `TaskManager`/`JobLifecycle`. + +## Краткое резюме (TL;DR) + +| Система | Архитектура | Брокер | Распределённость | Интерактивные задачи (AWAITING_*) | Реал-тайм UI/логи | Операционная сложность | Лучше всего для | +|----------------------|------------------------------|--------|------------------|-----------------------------------|-------------------|------------------------|-----------------| +| **Наш (APScheduler + TaskManager)** | In-process (один backend) | Нет | Только вертикальная | **Отличная** (встроенные) | **Отличная** (WS + Task Center) | **Низкая** | Admin tools, интерактивные фоновые задачи, LLM/backup/migration внутри одного приложения | +| **Celery + Beat** | Producer + Broker + Workers (отдельные процессы) | Обязателен (Redis/Rabbit/...) | **Отличная** | Сложно (требует кастомизации) | Средняя (Flower + кастом) | Высокая | Высоконагруженные распределённые пайплайны, CPU-bound, высокая отказоустойчивость | +| **APScheduler (standalone)** | In-process (или с jobstores) | Нет | Ограниченная | Через кастом | Кастом | Низкая | Простые cron-задачи внутри приложения | +| **Huey** | Лёгкий (Redis/SQLite) | Опционально | Хорошая | Нет | Кастом | Низкая-средняя | Маленькие/средние приложения | +| **RQ** | Простой Redis | Redis | Хорошая | Нет | Кастом | Низкая | Простые очереди | +| **Dramatiq** | Современный (Redis/Rabbit) | Обязателен | Хорошая | Нет | Кастом | Средняя | Более простая/надёжная замена Celery | +| **ARQ / Taskiq** | Async-native (Redis-first) | Redis | Хорошая | Ограничено | Кастом | Средняя | FastAPI + высококонкурентные I/O-задачи | +| **Procrastinate** | Postgres-native | Нет (Postgres) | Хорошая | Ограничено | Кастом | Средняя | Когда не хочется дополнительный брокер | + +**Наш главный выигрыш**: глубокая интеграция с UI, человеческие состояния задач (`AWAITING_INPUT`, `AWAITING_MAPPING`), real-time логи через WebSocket и нулевая дополнительная инфраструктура. + +**Главный проигрыш**: нет горизонтального масштабирования и автоматической отказоустойчивости при падении процесса. + +## Что такое "наш" scheduler и worker (точные факты из кода) + +### Scheduler +- Файл: `backend/src/core/scheduler.py` +- `SchedulerService` оборачивает `BackgroundScheduler` (APScheduler 3.11). +- Загрузка: `load_schedules()` очищает все jobs и пересоздаёт из: + - Backup schedules — из `config.environments[].backup_schedule`. + - Translation schedules — из таблицы `TranslationSchedule` (is_active). + - Validation policies — динамический cron + `ThrottledSchedulerConfigurator` (распределение задач внутри окна). +- Триггеры: `CronTrigger.from_crontab(...)` + timezone. +- Мост: `AsyncJobRunner` (отдельный файл) — `run_coroutine_threadsafe` + `call_later` для delayed execution. +- Нет `SQLAlchemyJobStore` / persistent jobstore — расписания восстанавливаются из прикладных таблиц и конфига при старте. + +### Worker / Task Execution +- `TaskManager` (фасад) + `JobLifecycle` + `TaskGraph` + `EventBus`. +- Создание: `create_task` → `asyncio.create_task(_run_task)`. +- Выполнение: `plugin.execute(...)` (async или `asyncio.to_thread`). +- Ограничение блокирующих операций: именованные `ThreadPoolExecutor` (db/file/git) — `core/utils/executors.py` (ADR-0011). +- Состояния: `PENDING → RUNNING → SUCCESS/FAILED` + `AWAITING_MAPPING` / `AWAITING_INPUT`. +- Персистентность: отдельная таблица `task_records` + `task_logs` (может быть `TASKS_DATABASE_URL` = основной или sqlite). +- Реал-тайм: `EventBus` (asyncio.Queue) → WS для логов, статуса, событий датасетов. +- Интерактивность: `resume_task_with_password`, `resolve_task` — задачи реально "парятся" и ждут пользователя. +- Деплой: **один контейнер `backend`** (docker-compose.yml). Нет отдельного worker-сервиса. + +**ADR-0011** явно отвергает multiprocessing workers и брокер в пользу async + bounded executors внутри процесса. + +## Ортогональные проекции (13 измерений) + +### 1. Architecture & Deployment Model +**Наш**: Монолитный процесс (API + scheduler + executor). +**Celery**: Три отдельных роли (app, beat, worker(s)) + брокер. +**ARQ/Taskiq**: Async workers как отдельные процессы/контейнеры. +**Huey/RQ**: Лёгкие отдельные worker-процессы. +**APScheduler standalone**: Обычно в том же процессе, что и приложение. + +**Вывод**: Наш — самый простой в деплое (один сервис). + +### 2. External Dependencies & Operational Surface +**Наш**: Только основная БД (опционально отдельная для задач). APScheduler — чисто in-memory. +**Celery**: Брокер + результат + мониторинг (Flower) + beat. +**Dramatiq/ARQ**: Брокер обязателен. +**Procrastinate**: Только Postgres. + +**Наш выигрывает** по операционной простоте. + +### 3. Scheduling (Cron + Dynamic jobs) +**Наш**: Полноценный Cron + динамическое добавление/удаление/перезагрузка из нескольких источников. Throttling внутри окон. +**Celery Beat**: Cron/interval + `django-celery-beat` для динамики. +**APScheduler**: Лучший в классе триггеров (date/interval/cron) + jobstores. +**Huey**: Periodic tasks через `huey.contrib`. +**RQ**: Нет встроенного — внешний планировщик или rq-scheduler. + +**Наш**: Очень удобен для пользовательских расписаний (translate, validation, backup). + +### 4. Task Execution Model & Concurrency +**Наш**: asyncio + bounded thread pools внутри event loop. Плагины выполняются в контексте приложения. +**Celery**: Отдельные процессы (prefork/eventlet/gevent). +**ARQ/Taskiq**: Нативный asyncio в worker'ах. +**Dramatiq**: Sync-first (можно с gevent). + +Для I/O-heavy (LLM, HTTP к Superset, Git) async-модели (наш + ARQ) эффективнее по вертикали. + +### 5. Scalability & Distribution +**Наш**: Только вертикальная (добавить CPU/RAM одной ноде). При нескольких backend'ах — дублирование расписаний. +**Celery / большинство очередей**: Линейное горизонтальное масштабирование (добавляй workers). +**APScheduler с RedisJobStore**: Ограниченная распределённость. + +**Наш** не предназначен для десятков тысяч задач в минуту. + +### 6. Reliability, Resilience & Fault Tolerance +**Наш**: +- Расписания восстанавливаются при рестарте. +- Задачи сохраняют состояние. +- **Проблема**: RUNNING задачи при падении процесса не возобновляются автоматически. AWAITING_* требуют ручного рестарта. +- Нет встроенных ретраев на уровне очереди. + +**Celery**: Ack, redeliver, retries, max_retries, dead-letter, result backend. +**Dramatiq**: Хорошие ретраи по умолчанию. +**Procrastinate**: Надёжность Postgres. + +**Критично для нас**: если задача "перевода 200k строк" упала вместе с backend — нужно перезапускать вручную. + +### 7. Task Lifecycle Richness & Advanced Features +**Наш** — лидер в своей нише: +- AWAITING_INPUT (пароли БД) +- AWAITING_MAPPING (разрешение ресурсов) +- TaskContext для structured logging +- Отмена, resume, resolve через API + +**Celery**: canvas (chain/group/chord), routing, priority, eta/countdown, rate_limit, task_revocation. +Остальные: базовые retry + простые очереди. Интерактивность почти ни у кого нет "из коробки". + +**Это одно из самых сильных конкурентных преимуществ** нашего решения для инструмента администратора. + +### 8. Observability, Monitoring & UX Integration +**Наш**: +- Нативный Task Status Center (spec 034) +- WebSocket real-time логи + статус +- Фильтры, summary, drawer с логами +- RBAC на уровне задач + +**Celery**: Flower (отдельное приложение), Prometheus экспортеры, кастом. +Другие: обычно только логи + кастомный дашборд. + +**Наш** выигрывает для пользователей продукта (не только DevOps). + +### 9. Persistence (Schedules + Task State/Results/Logs) +**Наш**: +- Расписания — в прикладных таблицах + config. +- Задачи/логи — `task_records` / `task_logs` (JSON + отдельная таблица логов). +- Высокопроизводительная батчевая запись логов. + +**Celery**: Результаты в отдельном backend (Redis/Postgres/…). Задачи обычно не хранят детальные логи внутри. +**APScheduler + SQLAlchemyJobStore**: Полная персистентность расписаний. +**Huey**: Опционально SQLite/Redis. + +### 10. Resource Footprint, Isolation & Performance Profile +**Наш**: Низкий overhead. Всё в одном процессе. Bounded pools дают backpressure. +**Celery prefork**: Выше потребление памяти, хорошая изоляция. +**Async workers (ARQ)**: Отличная плотность I/O-задач на ядро. +**CPU-bound задачи**: Процессные модели лучше (изоляция GIL). + +Для наших задач (LLM, Superset API, Git, batch SQL) in-process async + pools работает отлично. + +### 11. Ease of Development, Testing & Integration (FastAPI) +**Наш**: Плагины просто реализуют `execute`. Всё в одном codebase. Легко тестировать с TaskContext. +**Celery**: Отдельный app, сериализация, импорт проблем, тесты сложнее. +**ARQ/Taskiq**: Хорошая интеграция с FastAPI (async-native). +**APScheduler**: Самый простой для простых cron. + +### 12. Ecosystem, Maturity & Maintenance Burden +- **Celery**: Самая зрелая, огромная экосистема, но репутация "сложная и полна сюрпризов". +- **APScheduler**: Зрелая, активно поддерживается. +- **Dramatiq**: Создавалась как "лучше Celery". +- **ARQ**: От автора Pydantic/FastAPI — отличный современный выбор. +- **Huey/RQ**: Простые, меньше "магии". + +Наш — полностью кастомный слой поверх APScheduler. Поддержка ложится на команду проекта. + +### 13. Best-fit Use Cases & Trade-off Summary +**Идеально для нас сегодня**: +- Инструмент администратора Superset. +- Задачи с человеческим участием. +- Реал-тайм обратная связь важнее горизонтального масштаба. +- Минимальная операционная нагрузка. + +**Когда стоит рассмотреть Celery/ARQ/Dramatiq**: +- Появятся десятки тысяч фоновых задач. +- Нужна настоящая отказоустойчивость (задача должна дожить до выполнения даже при рестарте всего кластера). +- CPU-heavy работа (большие вычисления, не I/O). +- Несколько независимых сервисов, которые должны шарить очередь. + +**Гибрид возможен**: оставить текущий механизм для UI-driven и scheduled admin-задач, а тяжёлые batch-операции вынести в отдельную очередь. + +## Сравнительная матрица (сводка) + +(См. TL;DR таблицу выше + детальные проекции.) + +## Рекомендация для superset-tools + +**Оставить текущую архитектуру как основную.** + +Причины: +- Отлично соответствует текущим потребностям (переводы, бэкапы, валидации, миграции, git). +- Уникальные фичи (AWAITING_*, TaskContext, нативный красивый Task Center) очень ценны для пользователей. +- Низкая операционная сложность — большое преимущество. +- ADR-0011 и вся эволюция проекта сознательно шли в эту сторону. + +**Что можно улучшить без революции** (опционально): +- Добавить `SQLAlchemyJobStore` или RedisJobStore для расписаний (защита от потери при странных рестартах). +- Добавить автоматический "reconcile" для зависших RUNNING задач при старте (по таймауту или heartbeat). +- Для очень тяжёлых переводов — рассмотреть возможность выноса в отдельный worker позже. + +**Не переходить на Celery "на всякий случай"** — это добавит значительную сложность без немедленной пользы. + +## Источники + +- Код проекта: `backend/src/core/scheduler.py`, `task_manager/*`, `ADR-0011-async-backend.md`, docker-compose. +- Внешние сравнения: + - APScheduler vs Celery Beat (leapcell.io, 2025) [web:3]. + - Reddit 2026: "Choosing a Python task queue library". + - Dramatiq motivation page. + - ARQ adoption stories и бенчмарки (см. также [web:0], [web:9]). + - StackShare / официальная документация. + +--- + +*Сравнение выполнено ортогонально — по независимым измерениям, без навязывания одного "лучшего" решения.* \ No newline at end of file diff --git a/docs/semantics-testing-audit.md b/docs/semantics-testing-audit.md deleted file mode 100644 index c25ecf49d..000000000 --- a/docs/semantics-testing-audit.md +++ /dev/null @@ -1,159 +0,0 @@ -# Аудит соответствия тестов `semantics-testing` - -**Дата**: 2026-06-16 -**Файлов проверено**: 433 (.py в `backend/tests/`) -**Тестов**: 7778 unit + 175 integration + 2442 frontend - ---- - -## Сводка - -| Критерий | Порог | Факт | Статус | -|----------|:-----:|:----:|:------:| -| **Якоря `#region`** | 100% | **99.3%** (430/433) | 🟢 | -| **`@RELATION BINDS_TO`** | 100% | **90.1%** (390/433) | 🟡 | -| **`@TEST_EDGE` (≥3)** | 100% | **58.2%** (252/433) | 🔴 | -| **`@BRIEF` на тестах** | 100% | **49%** (908/1855) | 🔴 | -| **Размер файла <600 строк** | 100% | **91.2%** (38 >600) | 🟡 | -| **Logic mirrors** | 0 | **0** | 🟢 | -| **ADR regression defense** | 100% | Частично | 🟡 | - ---- - -## 1. Якоря `#region`/`#endregion` — 🟢 99.3% - -**430 из 433** файлов имеют корректный `#region` на уровне модуля. - -**Нарушения (3 файла):** -| Файл | Причина | -|------|--------| -| `test_smoke_plugins.py` | Нет якоря — создан до стандартизации | -| `api/test_tasks.py` | Нет якоря — создан до стандартизации | -| `services/git/conftest.py` | conftest без якоря | - ---- - -## 2. `@RELATION BINDS_TO` — 🟡 90.1% - -**390 из 433** файлов связывают тестовый модуль с production-контрактом. - -**43 файла без `BINDS_TO`** — в основном старые тесты (до внедрения `semantics-testing`), а также несколько edge/coverage-файлов, созданных агентами во время кампании покрытия: - -| Файл | Комментарий | -|------|------------| -| `test_datasets.py` | Старый тест | -| `test_db_executor.py` | Старый тест | -| `test_core_timezone_edge.py` | Edge-файл без BINDS_TO | -| `test_schemas_edge.py` | Edge-файл без BINDS_TO | -| `test_connection_service.py` | Старый тест | -| `test_orchestrator_direct_db.py` | Старый тест | -| `services/dataset_review/test_semantic_resolver_edge.py` | Edge-файл | -| `services/dataset_review/test_superset_matrix.py` | Специфичный файл | -| `services/dataset_review/test_helpers_edge.py` | Edge-файл | -| `core/test_defensive_guards.py` | Старый тест | - -**Рекомендация:** добавить `@RELATION BINDS_TO -> [TargetModule]` во все 43 файла. - ---- - -## 3. `@TEST_EDGE` — 🔴 58.2% - -Только **252 из 433** файлов декларируют edge-кейсы в заголовке модуля. - -**Требование skills**: минимум 3 edge-кейса: `missing_field`, `invalid_type`, `external_fail`. - -**181 файл без `@TEST_EDGE`** — это крупнейший пробел. - -**Причины:** -- ~120 файлов созданы до внедрения `semantics-testing` skill -- ~40 coverage/edge-файлов созданы агентами во время кампании — агенты не добавляли `@TEST_EDGE` в заголовки -- ~20 integration-файлов (включая новый TLS Custom CA) - -**Рекомендация:** целевая кампания по добавлению `@TEST_EDGE` в заголовки 181 файла. Приоритет: файлы >300 строк и coverage-файлы от агентов. - ---- - -## 4. `@BRIEF` на тестовых функциях — 🔴 49% - -Из **1855** тестовых функций с `#region [C:2] [TYPE Function]` только **908** имеют `@BRIEF`. - -**947 функций без `@BRIEF`** — большинство созданы агентами, которые генерировали якоря без `@BRIEF` (формат: `#region test_name [C:2] [TYPE Function]` без следующей строки `# @BRIEF ...`). - -**Рекомендация:** автоматизированное добавление `@BRIEF` (можно сгенерировать из docstring или имени теста). - ---- - -## 5. Размер файлов — 🟡 91.2% - -**38 файлов > 600 строк** (порог `semantics-testing` §II.5). - -| Диапазон | Количество | Файлы | -|----------|:---------:|-------| -| 600–700 | 16 | Умеренное превышение | -| 700–1000 | 14 | Значительное превышение | -| 1000–1900 | 8 | Критическое превышение | - -**Худшие нарушители (>1000 строк):** - -| Строк | Файл | Рекомендация | -|------:|------|-------------| -| 1893 | `api/test_assistant_tools.py` | Разделить по tool-классам | -| 1662 | `plugins/test_llm_analysis_service_coverage.py` | Разделить на 3 файла | -| 1613 | `plugins/test_llm_analysis_service.py` | Разделить по классам сервиса | -| 1459 | `plugins/test_llm_analysis_plugin.py` | Выделить PathA/PathB в отдельные файлы | -| 1347 | `api/test_dataset_review_routes_extended.py` | Разделить по endpoint-группам | -| 1278 | `api/test_dataset_review_deps_unit.py` | Разделить по dependency-классам | -| 1042 | `test_datasets.py` | Разделить по операциям (CRUD, filters, preview) | -| 975 | `test_core/test_async_network.py` | Разделить по протоколам | - -**Исключение (integration, до 800 строк):** 2 файла в пределах нормы. - -**Рекомендация:** приоритетно разделить 8 файлов >1000 строк. - ---- - -## 6. Logic Mirrors (анти-таутология) — 🟢 0 - -**Нарушений не обнаружено.** Тесты используют hardcoded fixtures и не вычисляют `expected` через вызовы production-кода. - -Это сильная сторона кодбазы — агенты последовательно применяли паттерн `expected = {"id": "dash_1", ...}` вместо `expected = production_fn(x)`. - ---- - -## 7. ADR Regression Defense — 🟡 Частично - -Проверка наличия `@REJECTED` в production-коде и соответствующих `@TEST_EDGE` в тестах: - -- **ADR-0009** (TLS/SSL): `test_superset_tls_custom_ca.py` проверяет rejected-путь (certifi не доверяет custom CA) ✅ -- **ADR-0013** (coverage reporting): ортогональный аудит в `docs/orthogonal-test-report.md` ✅ -- Остальные ADR требуют точечной проверки — не автоматизировано - ---- - -## 8. Тесты без `#region` на функциях - -Многие тестовые функции (особенно в старых файлах) не имеют `#region`/`#endregion` вообще. Они используют plain `def test_*()` без семантической разметки. Точное количество не подсчитано, но по оценке ~40% тестовых функций не имеют персональных якорей. - ---- - -## Итоговая оценка - -| Измерение | Оценка | Комментарий | -|-----------|:------:|------------| -| Структурная целостность | **B+** | 99% файлов с якорями, но много oversized | -| Трассируемость | **C+** | 90% BINDS_TO, но только 58% TEST_EDGE | -| Документированность | **D** | 49% @BRIEF на тестах — агенты не добавляли | -| Анти-таутология | **A** | 0 logic mirrors — сильная сторона | -| Размер файлов | **C** | 38 файлов >600 строк, 8 >1000 строк | - -**Общая оценка: C+** — базовая структура заложена, но документация тестов (`@TEST_EDGE`, `@BRIEF`) и размеры файлов требуют значительных улучшений. - ---- - -## Приоритеты исправления - -1. 🔴 **Добавить `@TEST_EDGE`** в 181 файл без него -2. 🔴 **Добавить `@BRIEF`** на 947 тестовых функций -3. 🟡 **Разделить 8 файлов >1000 строк** -4. 🟡 **Добавить `@RELATION BINDS_TO`** в 43 файла -5. 🟢 **Добавить якоря** в 3 файла без них diff --git a/docs/settings.md b/docs/settings.md deleted file mode 100644 index c82cb19bc..000000000 --- a/docs/settings.md +++ /dev/null @@ -1,63 +0,0 @@ -# Web Application Settings Mechanism - -This document describes the settings management system for the Superset Tools application. - -## Overview - -The settings mechanism allows users to configure multiple Superset environments and global application settings (like backup storage) via the web UI. - -## Backend Architecture - -### Data Models - -Configuration is structured using Pydantic models in `backend/src/core/config_models.py`: - -- `Environment`: Represents a Superset instance (URL, credentials). The `base_url` is automatically normalized to include the `/api/v1` suffix if missing. -- `GlobalSettings`: Global application parameters (e.g., `storage.root_path`). -- `AppConfig`: The root configuration object. - -### Configuration Manager - -The `ConfigManager` (`backend/src/core/config_manager.py`) handles: -- Persistence to `config.json`. -- CRUD operations for environments. -- Validation and logging. - -### API Endpoints - -The settings API is available at `/settings`: - -- `GET /settings`: Retrieve all settings (passwords are masked). -- `PATCH /settings/global`: Update global settings. -- `GET /settings/environments`: List environments. -- `POST /settings/environments`: Add environment. -- `PUT /settings/environments/{id}`: Update environment. -- `DELETE /settings/environments/{id}`: Remove environment. -- `POST /settings/environments/{id}/test`: Test connection. - -## Frontend Implementation - -The settings page is located at `frontend/src/pages/Settings.svelte`. It provides forms for managing global settings and Superset environments. - -## Reports Center - -Unified reports are available at [`/reports`](frontend/src/routes/reports/+page.svelte) and use the backend API at [`/api/reports`](backend/src/api/routes/reports.py) and [`/api/reports/{report_id}`](backend/src/api/routes/reports.py). - -### What operators can do - -- View all task outcomes (LLM verification, backup, migration, documentation) in one list. -- Filter by type and status. -- Open report detail with diagnostics and recommended next actions. -- Continue working even for unknown task types and partial payloads (explicit placeholders are shown instead of hidden data). - -### Troubleshooting - -- If report list is empty, verify tasks exist and clear filters. -- If report detail is not found (404), confirm the selected report still exists in task history. -- If report API tests fail during local execution with database connectivity errors, ensure the configured DB is reachable or run in an environment with available test DB services. - -## Integration - -Existing plugins and utilities use the `ConfigManager` to fetch configuration: -- `superset_tool/utils/init_clients.py`: Dynamically initializes Superset clients from the configured environments. -- `BackupPlugin`: Uses the configured `storage.root_path` as the default storage location. diff --git a/docs/translation-performance-analysis.md b/docs/translation-performance-analysis.md deleted file mode 100644 index e2df86eb2..000000000 --- a/docs/translation-performance-analysis.md +++ /dev/null @@ -1,508 +0,0 @@ -# Анализ производительности перевода: причины медлительности и план доработок - -**Дата:** 2026-06-03 (v2 — после code review) -**Автор:** fullstack-coder (superset-tools) + рецензент -**Контекст:** Пользователь сообщил "Очень долго стартует перевод". По логам trace_id `8bd7ac8f` (run `4c9de39e`) проведён анализ. - ---- - -## 1. Исходные данные - -**Объём:** 5455 строк из Superset datasource (dataset 906, таблица `userdata.debt_comment_translations`) -**Модель:** `qwen-flash` через `lite.ai.rusal.com/v1` (provider_type=litellm, response_format=yes) -**Режим:** `full=False` (только новые записи, без перезаписи существующих) -**Батчей сформировано:** 203 - ---- - -## 2. Таймлайн одного прогона (из логов) - -| Время | Событие | Длительность | Симптом | -|-------|---------|--------------|---------| -| `14:34:39` | Run стартовал | — | | -| `14:34:40` | Данные загружены (5455 строк) | ~1s | ✅ | -| `14:34:40` | "Processing 203 batches" | — | | -| `14:34:40.430` | **LLM request:** prompt_len=145062 | **~1m47s** | ⚠️ | -| `14:36:27` | `finish_reason=length` — ответ обрезан | | ❌ | -| `14:36:27` | Splitting → 2 батча | | | -| `14:36:27` | prompt_len=101330 | **~1m40s** | ⚠️ | -| `14:38:06` | `finish_reason=length` | | ❌ | -| `14:38:06` | Splitting → ещё 2 батча | | | -| `14:38:06` | prompt_len=25826 | **~40s** | ✅ stop | -| `14:38:47` | prompt_len=76479 | **~1m39s** | ⚠️ | -| `14:40:26` | `finish_reason=length` | | ❌ | -| ... | каскад продолжается | | | - -**Оценка общего времени:** >10-15 минут на 5455 строк. - ---- - -## 3. ⚠️ Важное ограничение анализа: prompt_len — это символы или токены? - -**В логах нет прямого указания, что `prompt_len=145062` — токены.** Формат логирования (`prompt_len=145062`) без указания единиц измерения не позволяет утверждать, что это именно токены. Это могут быть символы. - -**До любых правок требуется:** - -Для 10-20 реальных батчей залогировать: - -| Поле | Источник | Зачем | -|------|----------|-------| -| `chars` | `len(prompt)` | Длина в символах | -| `estimated_input_tokens` | `estimate_token_budget()` | Текущая оценка | -| `provider_prompt_tokens` | `response.usage.prompt_tokens` | Реальные токены входа | -| `provider_completion_tokens` | `response.usage.completion_tokens` | Реальные токены выхода | -| `provider_total_tokens` | `response.usage.total_tokens` | Сумма | -| `max_tokens` | Параметр запроса | Сколько просили | -| `context_window_resolved` | Что использовали как контекст | 64000 или другое | -| `max_output_tokens_resolved` | Что использовали как лимит выхода | | -| `rows_in_batch` | `len(batch_rows)` | | -| `target_languages_count` | `len(target_languages)` | | -| `finish_reason` | Из ответа API | stop / length / error | -| `response_rows_recovered` | Сколько строк распарсили | Для recovery | - -**Вывод:** Все гипотезы ниже основаны на косвенных признаках. Без логов usage токенов от провайдера (response.usage) некоторые причины остаются недоказанными. Добавление этих логов — **P0, первый шаг**. - ---- - -## 4. Первопричины (по степени вероятности) - -### 4.1. Batch sizing недооценивает output budget (основная гипотеза) - -`finish_reason=length` с вероятностью >90% означает не "вход не влез во входной контекст", а **"модель упёрлась в max_tokens при генерации ответа"**. - -Каждый батч содержит N строк. Для каждой строки модель должна вернуть JSON с переводами на каждый из target_languages. Если target_languages_count > 1, то **выход растёт линейно**, а batch sizing учитывает это только грубой оценкой. - -**Файл:** `backend/src/plugins/translate/_token_budget.py` - -Текущие константы для оценки выхода: - -```python -OUTPUT_PER_ROW_PER_LANG = 120 # токенов на строку перевода на один язык -JSON_OVERHEAD_PER_ROW = 50 # JSON-обвязка на строку -REASONING_OVERHEAD = 2000 # CoT overhead -MAX_OUTPUT_HEADROOM = 3000 # запас -``` - -Для 128 строк × 2 языка: -``` -нужно = 128 × 2 × 120 + 128 × 50 + 2000 + 3000 = 40560 токенов -``` - -Если `max_output_tokens = 16384` (default), то батч гарантированно обрежется. -И в логе мы видим `finish_reason=length` на батчах > 50-60 строк. - -**Следствие:** Проблема не (только) в CJK-токенизации, а в том, что **батч-сайзер упаковывает слишком много строк относительно output лимита**. - -### 4.2. CJK-оценка токенов входа — дополнительный фактор - -**Файл:** `backend/src/plugins/translate/_token_budget.py:89-108` - -```python -cjk_tokens = cjk_count / 1.5 # 1.5 chars/token -other_tokens = other_count / 2.2 # 2.2 chars/token -``` - -Если `prompt_len` в логах — символы, а не токены, то при 60% CJK-символов: -- Оценка: 145062 / 1.5 ≈ 96708 токенов -- Реальность (Qwen): может быть ~120000+ токенов - -То есть вход недооценивается на 20-30%, и "съедает" часть output budget. - -**Вывод:** CJK-оценка — вторичный фактор. Первичный — output budget. - -### 4.3. PROVIDER_DEFAULTS не содержит модели qwen-flash - -**Файл:** `backend/src/plugins/translate/_token_budget.py:32-39` - -```python -PROVIDER_DEFAULTS = { - "gpt-4o-mini": {"context_window": 128000, "max_output_tokens": 16384}, - "gpt-4o": {"context_window": 128000, "max_output_tokens": 16384}, - "o1-mini": {"context_window": 128000, "max_output_tokens": 65536}, - "claude-3-5-sonnet": {"context_window": 200000, "max_output_tokens": 8192}, - "deepseek-v4-flash": {"context_window": 64000, "max_output_tokens": 8192}, - "default": {"context_window": 64000, "max_output_tokens": 16384}, -} -``` - -Когда модель не найдена: -- `context_window = 64000` (default) -- `max_output_tokens = 16384` (default) -- `available_input_budget = 64000 - 16384 = 47616` - -Если `qwen-flash` на самом деле поддерживает 128K контекст и 8K вывод — бюджет по входу может быть недооценён, а бюджет по выходу переоценён. - -### 4.4. Каскад finish_reason=length умножает проблему - -**Файл:** `backend/src/plugins/translate/_llm_call.py:85-96, 190-233` - -```python -if finish_reason == "length" and len(batch_rows) >= 2: - if _recursion_depth < MAX_RETRIES_PER_BATCH: # = 3 - return self._split_and_retry(...) # binary split - -def _split_and_retry(self, ...): - mid = len(batch_rows) // 2 - left = self.call_llm_for_batch(..., rows[:mid], depth + 1) - right = self.call_llm_for_batch(..., rows[mid:], depth + 1) -``` - -**Проблема:** Бинарное деление **не спасает частичный результат**. Даже если модель вернула 80 из 100 строк валидного JSON — они теряются, и обе половины перезапрашиваются с нуля. - -Если truncation случается на 3+ уровнях рекурсии — 1 батч превращается в 7+ LLM-вызовов. - ---- - -## 5. План доработок - -### 5.0. [P0] Измерить → потом править - -Без реальных цифр любое изменение — гадание. - -**Добавить в `_llm_http.py` сбор usage от провайдера и логирование:** - -```python -# После ответа API: -usage = response.get("usage", {}) -log("llm_http", "REFLECT", "LLM usage stats", { - "prompt_tokens": usage.get("prompt_tokens"), - "completion_tokens": usage.get("completion_tokens"), - "total_tokens": usage.get("total_tokens"), - "finish_reason": finish_reason, - "max_tokens": max_tokens, - "rows": len(batch_rows), - "chars": len(prompt), -}) -``` - -Для 10-20 реальных батчей собрать статистику и **только после этого** принимать решения о коэффициентах. - -### 5.1. [P0] Учитывать output budget при расчёте размера батча - -**Проблема:** Сейчас output budget учитывается, но недостаточно жёстко. -**Файл:** `backend/src/plugins/translate/_token_budget.py:160-176` - -```python -def _apply_output_aware_batch_sizing(safe_size, num_languages, max_output_tokens): - while safe_size > 0: - needed_output = ( - safe_size * num_languages * OUTPUT_PER_ROW_PER_LANG - + safe_size * JSON_OVERHEAD_PER_ROW - + REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM - ) - if needed_output <= max_output_tokens: - break - safe_size -= 1 - return safe_size -``` - -**Улучшение:** Сделать output budget **первичным** ограничителем, а input budget — вторичным: - -```python -def _compute_max_rows_by_output(max_output_tokens, num_languages): - """Сколько строк влезет в max_output_tokens.""" - overhead = REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM - per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + JSON_OVERHEAD_PER_ROW - if per_row <= 0: - return 20 - available = max_output_tokens - overhead - if available <= 0: - return 1 - return max(available // per_row, 1) -``` - -И в `_batch_sizer.py:auto_size_batches()`: - -```python -max_rows_by_output = _compute_max_rows_by_output(max_output_tokens_val, num_languages) - -# Брать минимум из всех ограничений: -max_rows = min( - max_rows_by_input_budget, - max_rows_by_output, - absolute_hard_cap, # safety net - job.batch_size or inf, # user preference -) -``` - -### 5.2. [P0] Вынести context_window / max_output_tokens в настройки провайдера - -#### 5.2.1. Модель БД - -**Файл:** `backend/src/models/llm.py` - -```python -class LLMProvider(Base): - # ... существующие поля ... - context_window = Column( - Integer, nullable=True, default=None, - comment="Total context window in tokens. NULL = fallback to PROVIDER_DEFAULTS", - ) - max_output_tokens = Column( - Integer, nullable=True, default=None, - comment="Max output tokens. NULL = fallback to PROVIDER_DEFAULTS", - ) -``` - -Nullable → обратная совместимость. - -#### 5.2.2. Safe cap - -Даже если пользователь ввёл значения — применяется верхняя граница: - -```python -PROVIDER_SAFE_CAP = 256000 # абсолютный максимум - -effective_context_window = min( - provider.context_window or PROVIDER_DEFAULTS.get(model, default)["context_window"], - PROVIDER_SAFE_CAP, -) -effective_max_output_tokens = min( - provider.max_output_tokens or PROVIDER_DEFAULTS.get(model, default)["max_output_tokens"], - effective_context_window, # не может быть больше контекста -) -``` - -#### 5.2.3. Pydantic схема - -**Файл:** `backend/src/plugins/llm_analysis/models.py` - -```python -class LLMProviderConfig(BaseModel): - # ... существующие поля ... - context_window: int | None = Field( - None, ge=1000, le=256000, - description="Context window in tokens. Leave blank for auto.", - ) - max_output_tokens: int | None = Field( - None, ge=256, - description="Max output tokens. Must be less than context_window.", - ) -``` - -#### 5.2.4. Сервисный слой - -**Файл:** `backend/src/services/llm_provider.py` - -```python -# create_provider -db_provider = LLMProvider( - ... - context_window=config.context_window, - max_output_tokens=config.max_output_tokens, -) - -# update_provider -db_provider.context_window = config.context_window -db_provider.max_output_tokens = config.max_output_tokens - -# Новый хелпер для batch sizing: -def get_provider_token_config(self, provider_id: str) -> dict: - provider = self.get_provider(provider_id) - if not provider: - return {"model": None, "context_window": None, "max_output_tokens": None} - return { - "model": provider.default_model or "gpt-4o-mini", - "context_window": provider.context_window, - "max_output_tokens": provider.max_output_tokens, - } -``` - -#### 5.2.5. Интеграция в batch sizing - -**Файл:** `backend/src/plugins/translate/_batch_proc.py:208-247` -**Файл:** `backend/src/plugins/translate/_batch_sizer.py:70-218` - -В обоих местах заменить: -```python -# Было: -provider_info = resolve_provider_model(job) -estimate_token_budget(provider_info=provider_info) - -# Стало: -config = LLMProviderService(db).get_provider_token_config(job.provider_id) -estimate_token_budget( - provider_info=config["model"], - context_window=config["context_window"], # приоритет над provider_info - max_output_tokens=config["max_output_tokens"], # приоритет над provider_info -) -``` - -#### 5.2.6. PROVIDER_DEFAULTS — остаётся fallback - -```python -def estimate_token_budget(..., context_window=None, max_output_tokens=None, provider_info=None): - # Если явно переданы — используем их - # Если оба None — смотрим PROVIDER_DEFAULTS - # Если и там нет — DEFAULT_... -``` - -#### 5.2.7. Svelte UI - -**Файл:** `frontend/src/lib/components/llm/ProviderConfig.svelte` - -- Collapsible "Advanced: Token Limits" -- Два number input: context_window, max_output_tokens -- Placeholder: "Auto-detected. Override only if you know the provider's real limits." -- Валидация на клиенте - -#### 5.2.8. Alembic миграция - -Новая миграция: add columns `context_window`, `max_output_tokens` to `llm_providers`. - -### 5.3. [P0] Консервативный tokenizer estimate + единый safety factor - -**Файл:** `backend/src/plugins/translate/_token_budget.py` - -```python -# Поправить коэффициенты (разумные значения, точные — после замера): -CJK_RATIO = 1.0 # было 1.5 -OTHER_RATIO = 1.8 # было 2.2 - -# Единый safety factor (один, не размазанный): -INPUT_SAFETY_FACTOR = 0.75 # 75% от расчётного бюджета -OUTPUT_SAFETY_FACTOR = 0.70 # 70% от расчётного output-бюджета -``` - -**Важно:** Эти цифры — стартовые. После сбора `usage.prompt_tokens` / `usage.completion_tokens` их надо откалибровать по реальным данным. - -### 5.4. [P1] Retry only missing rows после partial response - -**Текущий код:** `backend/src/plugins/translate/_llm_call.py:190-233` — binary split, теряет все уже переведённые строки. - -**Улучшение:** При `finish_reason=length`: -1. Попытаться распарсить ответ (`_recover_truncated_rows` в `_llm_parse.py:95-115`) -2. Сохранить успешно переведённые строки -3. Ретраить **только** те строки, которых не хватает - -```python -if finish_reason == "length": - recovered = _recover_truncated_rows(llm_response, len(batch_rows), finish_reason) - saved_rows = [] - missing_rows = [] - if recovered and recovered.get("rows"): - # Распределить: какие строки удалось перевести, какие — нет - parsed_ids = set(r.get("row_id") for r in recovered["rows"]) - for row in batch_rows: - if str(row.get("row_index")) in parsed_ids: - saved_rows.append(row) - else: - missing_rows.append(row) - - if missing_rows and len(missing_rows) < len(batch_rows) * 0.95: - # Есть существенный прогресс → ретраим только missing - self._persist_partial(batch_rows, saved_rows, batch_id, run_id, ...) - return self._retry_missing(job, run_id, missing_rows, dict_matches, ...) - else: - # Прогресса нет → binary split - return self._split_and_retry(...) -``` - -**Эффект:** Если из 100 строк вернулось 80 — ретраим только 20, а не 100. - -### 5.5. [P1] Dynamic row cap (вместо фиксированного 50) - -**Файл:** `backend/src/plugins/translate/_batch_sizer.py:148-166` - -```python -# Вычислить max_rows по output: -output_per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + JSON_OVERHEAD_PER_ROW -available_output = max_output_tokens - REASONING_OVERHEAD - MAX_OUTPUT_HEADROOM -max_rows_by_output = max(available_output // output_per_row, 1) if output_per_row > 0 else 20 - -# Вычислить max_rows по input: -max_rows_by_input = per_batch_budget // average_row_tokens - -# Итоговый лимит: -ABSOLUTE_HARD_CAP = 50 # safety net, не основное ограничение -max_rows = min(max_rows_by_output, max_rows_by_input, ABSOLUTE_HARD_CAP) -``` - -### 5.6. [P2] Self-calibration per run - -После первого `finish_reason=length` в рамках одного run_id: -- Посчитать реальное `actual_ratio = actual_tokens / estimated_tokens` -- Склировать batch sizing для следующих батчей -- Сбросить при новом run_id - ---- - -## 6. Итоговые приоритеты - -| # | Что | Файлы | Почему | -|---|-----|-------|--------| -| **P0** | Добавить usage-логи от провайдера | `_llm_http.py`, `_llm_call.py` | Без данных нельзя обосновать изменения | -| **P0** | Output budget как первичный ограничитель | `_token_budget.py`, `_batch_sizer.py` | `finish_reason=length` — это чаще про выход, а не про вход | -| **P0** | Консервативный tokenizer + safety factor | `_token_budget.py` | Быстро снижает truncation | -| **P0** | Provider-level context_window / max_output_tokens | model + schema + service + routes + UI + migration | Нужно для неизвестных моделей | -| **P1** | Retry only missing rows после truncation | `_llm_call.py`, `_llm_parse.py` | Сохраняет частичный результат | -| **P1** | Dynamic row cap (output-aware) | `_batch_sizer.py` | Точнее, чем фиксированные 50 строк | -| **P2** | Self-calibration per run/provider | `_batch_sizer.py`, `_llm_call.py` | Адаптация под модель | - ---- - -## 7. Метрики успеха - -После внедрения: - -| Метрика | Цель | Как измерить | -|---------|------|-------------| -| `finish_reason=length` | < 1% LLM вызовов | Из логов | -| Среднее число LLM вызовов на батч | ≤ 1.1 | total_calls / total_batches | -| p95 длительность батча | < 90s | Из timing-логов | -| Общее время на 5455 строк | ≤ 8 min | Из run duration | -| successful_rows / requested_rows | ≥ 99.5% | Из records | -| Malformed JSON rate | < 0.5% | Из parse failures | - ---- - -## 8. Перед внедрением — замерить - -Собрать для 10-20 батчей (разный размер, разное количество языков): - -| Поле | Как получить | -|------|-------------| -| characters | `len(prompt)` | -| estimated_input_tokens | `_estimate_tokens_for_text()` | -| actual_prompt_tokens | `response.usage.prompt_tokens` | -| actual_completion_tokens | `response.usage.completion_tokens` | -| finish_reason | Из ответа | -| rows | `len(batch_rows)` | -| languages | `len(target_languages)` | -| response_rows_count | После парсинга | - -На этих данных: -1. Посчитать `actual_ratio = actual_prompt_tokens / estimated_tokens` — точный CJK-коэффициент -2. Посчитать `output_per_row_actual = actual_completion_tokens / rows / languages` — точный output per row - -Только после этого фиксировать константы в коде. - ---- - -## 9. PROVIDER_DEFAULTS — схема fallback (для справки) - -``` -Пользователь указал context_window в UI? - → да: используем (с safe cap) - → нет: PROVIDER_DEFAULTS.get(model_name)? - → да: используем - → нет: DEFAULT_CONTEXT_WINDOW / DEFAULT_MAX_OUTPUT_TOKENS -``` - ---- - -## 10. Текущие константы _token_budget.py (для справки) - -| Константа | Значение | Описание | -|-----------|----------|----------| -| `DEFAULT_CONTEXT_WINDOW` | 64000 | | -| `DEFAULT_MAX_OUTPUT_TOKENS` | 16384 | | -| `REASONING_OVERHEAD` | 2000 | | -| `OUTPUT_PER_ROW_PER_LANG` | 120 | | -| `JSON_OVERHEAD_PER_ROW` | 50 | | -| `PROMPT_BASE_TOKENS` | 600 | | -| `DICT_TOKENS_PER_ENTRY` | 20 | | -| `DICT_TOKENS_MAX` | 5000 | | -| `CHARS_PER_TOKEN_MIXED` | 2.2 | | -| `MIN_MAX_TOKENS` | 4096 | | -| `MAX_OUTPUT_HEADROOM` | 3000 | | diff --git a/frontend/src/lib/components/git/GitEnvironmentTimeline.svelte b/frontend/src/lib/components/git/GitEnvironmentTimeline.svelte index 29debec0e..8031e71db 100644 --- a/frontend/src/lib/components/git/GitEnvironmentTimeline.svelte +++ b/frontend/src/lib/components/git/GitEnvironmentTimeline.svelte @@ -44,6 +44,7 @@ selectedB = null as string | null, onClearSelection = () => {}, onRequestDiff = () => {}, + deploymentStatus = null as { environments: any[]; current_content_hash: string | null } | null, } = $props(); const ENVIRONMENTS = ['dev', 'preprod', 'prod'] as const; @@ -69,14 +70,14 @@ const GRAPH_HEIGHT = 240; const ROW_Y: Record = { dev: 40, preprod: 120, prod: 200 }; const environmentColors: Record = { - dev: 'border-blue-300 bg-blue-50 text-blue-700', - preprod: 'border-amber-300 bg-amber-50 text-amber-800', - prod: 'border-indigo-300 bg-indigo-50 text-indigo-700', + dev: 'border-primary/30 bg-primary-light text-primary', + preprod: 'border-warning/30 bg-warning-light text-warning', + prod: 'border-info/30 bg-info-light text-info', }; const nodeColors: Record = { - dev: { head: 'border-primary bg-primary text-white', version: 'border-blue-300 text-blue-700 hover:border-primary' }, - preprod: { head: 'border-warning bg-warning text-white', version: 'border-amber-300 text-amber-800 hover:border-warning' }, - prod: { head: 'border-indigo-500 bg-indigo-500 text-white', version: 'border-indigo-300 text-indigo-700 hover:border-indigo-500' }, + dev: { head: 'border-primary bg-primary text-white', version: 'border-primary/30 text-primary hover:border-primary' }, + preprod: { head: 'border-warning bg-warning text-white', version: 'border-warning/30 text-warning hover:border-warning' }, + prod: { head: 'border-info bg-info text-white', version: 'border-info/30 text-info hover:border-info' }, }; let featureBranches = $derived( @@ -84,6 +85,7 @@ ); let selectedEnvironment = $state(null); let comparisonArmed = $state(false); + let isCollapsed = $state(true); let graphVersions = $derived.by(() => { const versions: Commit[] = []; @@ -291,16 +293,22 @@
{$t.git?.viz_title || 'Версии дашборда по стадиям'}
-
{$t.git?.viz_hint || 'Какая версия дашборда сейчас активна в Разработке, Предпроде и Продакшне. Видно отставание и можно сравнить, что именно изменилось.'}
-
{$t.git?.viz_bi_purpose || 'Помогает аналитикам понять состояние развёртывания перед валидацией или промоушеном.'}
+
{$t.git?.viz_hint || 'Какая версия дашборда сейчас активна в Разработке, Предпроде и Продакшне. Видно отставание и можно сравнить, что именно изменилось.'}
+
{$t.git?.viz_bi_purpose || 'Помогает аналитикам понять состояние развёртывания перед валидацией или промоушеном.'}
{#if graphVersions.length > 0} -
+
{graphVersions.length} {$t.git?.viz_unique_versions || 'уникальных версий'} показано {#if isWindowTruncated} · {$t.git?.viz_recent_window || 'recent window (older may exist)'}{/if}
{/if}
+ {#if !isCollapsed} + + {/if} {#if historiesLoading} {/if} @@ -324,82 +332,84 @@
-
+
{$t.git?.viz_legend_current || 'Текущая в стадии'} {$t.git?.viz_legend_promoted || 'Бейдж PRE = продвинута (линия только для выбранной)'} {#if comparisonArmed}{$t.git?.viz_choose_comparison || 'Выберите версию для сравнения'}{/if} - + как читать
- - {#if graphVersions.length > 0} -
- DEV активна - PRE {environmentStatus('preprod').label} - PROD {environmentStatus('prod').label} - {#if prodLag > 0} - Отставание: {prodLag} версий - {/if} -
- {/if} + {#if !isCollapsed} +
+ + {#if graphVersions.length > 0} +
+ DEV активна + PRE {environmentStatus('preprod').label} + PROD {environmentStatus('prod').label} + {#if prodLag > 0} + Отставание: {prodLag} версий + {/if} +
+ {/if} -
-
-
-
- {#each ENVIRONMENTS as environment (environment)} -
- -
-
{getStageLabel(environment)}
-
{getStageShort(environment)}
-
{environmentStatus(environment).label}
- {#if environment === 'prod' && prodLag > 0} -
- отстаёт на {prodLag} -
- {/if} -
-
- {/each} -
- -
-
+
+
+
{#each ENVIRONMENTS as environment (environment)} - - {#if (environmentHistories[environment] || []).length > 1} - - {/if} +
+ +
+
{getStageLabel(environment)}
+
{getStageShort(environment)}
+
{environmentStatus(environment).label}
+ {#if environment === 'prod' && prodLag > 0} +
+ отстаёт на {prodLag} +
+ {/if} +
+
{/each} - - {#if selectedA} - {@const selCommit = graphVersions.find(v => v.hash === selectedA)} - {#if selCommit} - {@const selX = xFor(selectedA)} - {#each promotedTo('dev', selCommit) as target (target)} - - {/each} - {#each promotedTo('preprod', selCommit) as target (target)} - - {/each} - {/if} - {/if} - - ← Более старые версии дашборда - Новые версии (последние изменения) → - +
- {#each ENVIRONMENTS as environment (environment)} -
+
+ + + {#each ENVIRONMENTS as environment (environment)} +
{#if (environmentHistories[environment] || []).length === 0} -
- Нет версии в {getStageLabel(environment).toLowerCase()} +
+ {t.git?.viz_no_version || 'No version in'} {getStageLabel(environment).toLowerCase()} {#if environment === 'prod'} - + {/if}
{:else} @@ -412,7 +422,7 @@ {#if environment === 'dev' || head || isKeyPromotion} {#if head} - + {getStageLabel(environment)} текущая · {formatTime(commit.timestamp) || ''} {:else if environment === 'dev' && promotedTo(environment, commit).length > 0} - + {promotedTo(environment, commit).map(t => getStageShort(t)).join('')} {/if} @@ -440,13 +450,13 @@ {#if graphVersions.length === 0 && !historiesLoading}
{$t.git?.viz_no_versions || 'Нет истории версий'}
- {$t.git?.viz_no_versions_hint || 'Инициализируйте репозиторий или синхронизируйте из Superset.'} + {$t.git?.viz_no_versions_hint || 'Инициализируйте репозиторий или синхронизируйте из Superset.'}
{/if}
{#if graphVersions.length > 0} -
+
{$t.git?.viz_older || 'Самая старая показанная'}: {formatTime(graphVersions[0].timestamp)} {$t.git?.viz_newer || 'Самая новая'}: {formatTime(graphVersions[graphVersions.length - 1].timestamp)} ({graphVersions.length} обновлений дашборда)
@@ -459,19 +469,19 @@
-
{$t.git?.viz_change_description || 'Change description'}
+
{$t.git?.viz_change_description || 'Change description'}
{selectedCommit.message || '—'}
-
+
{formatTime(selectedCommit.timestamp)} · {selectedCommit.author || 'unknown'}
{#if selectedCommitStatus} -
{selectedCommitStatus.label}
+
{selectedCommitStatus.label}
{/if} -
-
DEV {selectedVersionEnvironments.includes('dev') ? '✓' : '—'}
-
PRE {selectedVersionEnvironments.includes('preprod') ? '✓' : '—'}
-
PROD {selectedVersionEnvironments.includes('prod') ? '✓' : '—'}
+
+
DEV {selectedVersionEnvironments.includes('dev') ? '✓' : '—'}
+
PRE {selectedVersionEnvironments.includes('preprod') ? '✓' : '—'}
+
PROD {selectedVersionEnvironments.includes('prod') ? '✓' : '—'}
@@ -490,13 +500,13 @@
{$t.git?.viz_impact || 'Dashboard config impact'}
{(selectedCommit.files_changed?.length ?? 0)} files
-
Это изменения в файлах определения дашборда — они определяют, какие чарты, фильтры и данные увидят пользователи при продвижении версии.
+
Это изменения в файлах определения дашборда — они определяют, какие чарты, фильтры и данные увидят пользователи при продвижении версии.
{#if selectedCommit.files_changed && selectedCommit.files_changed.length > 0}
-
{$t.git?.viz_files_sample || 'Example updated files'}
-
+
{$t.git?.viz_files_sample || 'Example updated files'}
+
{#each selectedCommit.files_changed.slice(0, 3) as f (f)} {f} {/each} @@ -506,40 +516,40 @@ {/if} -
+
Git commit: {shortHash(selectedCommit.hash)} - +
{#if selectedB} -
+
{($t.git?.viz_comparing_with || 'Comparing with version {hash}').replace('{hash}', shortHash(selectedB))} {#if (getVersionDistance(selectedA, selectedB) > 0)} — {getVersionDistance(selectedA, selectedB)} updates apart {/if}
-
+
{$t.git?.viz_comparison_hint || 'Diff покажет, что именно изменилось в конфигурации дашборда (чарты, фильтры, настройки) между этими двумя версиями.'}
{/if} {#if selectedA && !selectedB} -
{$t.git?.viz_primary_selected || 'Select another version to compare (or use button below)'}
+
{$t.git?.viz_primary_selected || 'Select another version to compare (or use button below)'}
{/if}
{#if selectedB} {:else} - + {#if selectedA && environmentHistories.preprod?.[0] && !selectedVersionEnvironments.includes('preprod')} {/if} {#if selectedA && environmentHistories.dev?.[0] && selectedA !== environmentHistories.dev[0].hash} {/if} {/if} @@ -549,25 +559,25 @@
Статус по стадиям
-
-
DEV
активна
-
PRE
{environmentStatus('preprod').label}
-
PROD
{environmentStatus('prod').label}
+
+
DEV
активна
+
PRE
{environmentStatus('preprod').label}
+
PROD
{environmentStatus('prod').label}
{#if prodLag > 0} -
+
Продакшн отстаёт. Следующий промоушен принесёт {prodLag} обновлений (чарты/фильтры). Сначала провалидируйте.
{:else if environmentHistories.prod?.length} -
+
PROD на актуальной версии. Можно безопасно тестировать/публиковать.
{:else} -
+ {/if} -
Выберите точку на графике для деталей и сравнения.
+
Выберите точку на графике для деталей и сравнения.
{/if} @@ -578,12 +588,34 @@
{$t.git?.viz_unreleased || 'Нереализованные изменения (не продвинуты)'}
{#each featureBranches.slice(0, 6) as branch (branch.name)} - {branch.name.replace('feature/', '').replace('hotfix/', '')} + {branch.name.replace('feature/', '').replace('hotfix/', '')} {/each} {#if featureBranches.length > 6}+{featureBranches.length - 6}{/if}
-
{$t.git?.viz_unreleased_hint || 'These are draft changes. They will appear in the main timeline only after merge to Development.'}
+
{$t.git?.viz_unreleased_hint || 'These are draft changes. They will appear in the main timeline only after merge to Development.'}
{/if} +
+{:else} + + +{/if}
diff --git a/frontend/src/lib/components/git/GitLifecycleHeader.svelte b/frontend/src/lib/components/git/GitLifecycleHeader.svelte index eb88a9191..36ebadc5e 100644 --- a/frontend/src/lib/components/git/GitLifecycleHeader.svelte +++ b/frontend/src/lib/components/git/GitLifecycleHeader.svelte @@ -7,7 +7,7 @@ - + -
-
- -
+
+
+ + + + +
{stage} {currentBranch} + + | + + + + {#if hasChanges} + {($t.git?.changes_count || '{count} changes').replace('{count}', String(changedFilesCount))} + {:else} + {$t.git?.lifecycle?.no_changes || 'No changes'} + {/if} + + + | + +
- - | - - - - - {#if hasChanges} - {($t.git?.changes_count || '{count} changes').replace('{count}', String(changedFilesCount))} - {:else} - {$t.git?.lifecycle?.no_changes || 'No changes'} - {/if} - - - | - - - - -
- - -
diff --git a/frontend/src/lib/components/git/GitManager.svelte b/frontend/src/lib/components/git/GitManager.svelte index 32441fb26..c24cb58fe 100644 --- a/frontend/src/lib/components/git/GitManager.svelte +++ b/frontend/src/lib/components/git/GitManager.svelte @@ -91,24 +91,39 @@ function handleRecommendedAction() { if (model.recommendedAction === 'sync') { - void model.handleSync(); + model.autoNavigateTab = 'workspace'; model.activeTab = 'workspace'; + void model.handleSync(); return; } if (model.recommendedAction === 'commit') { + model.autoNavigateTab = 'workspace'; model.activeTab = 'workspace'; return; } if (model.recommendedAction === 'promote') { + model.autoNavigateTab = 'release'; model.activeTab = 'release'; return; } + model.autoNavigateTab = 'operations'; model.activeTab = 'operations'; } $effect(() => { if (model.showDeployConfirm) deployConfirmInput = ''; }); + // ── Auto-navigate tab pulse indicator ── + let pulseTab: string | null = $state(null); + $effect(() => { + if (model.autoNavigateTab) { + pulseTab = model.autoNavigateTab; + model.autoNavigateTab = null; + const timer = setTimeout(() => { pulseTab = null; }, 2000); + return () => clearTimeout(timer); + } + }); + function handleBackdropClick(e) { if (e.target === e.currentTarget) closeModal(); } onMount(() => { @@ -151,7 +166,7 @@ class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-text-muted transition-colors hover:bg-surface-muted hover:text-text disabled:opacity-50" aria-label={$t.common?.refresh || 'Refresh'} > - + {$t.common?.refresh || 'Refresh'} - - -
- - -
-
-

{$t.git?.commit_message || 'Сообщение коммита'}

- -
- -
- - +
- + {$t.git?.files_with_changes || 'Файлов с изменениями:'} {changedFilesCount}
@@ -267,6 +233,7 @@ disabled={committing || workspaceLoading || !commitMessage || !hasWorkspaceChanges} isLoading={committing} class="w-full" + size="lg" > {$t.git?.commit_button || 'Создать коммит'} @@ -277,13 +244,48 @@ {$t.git?.auto_push_after_commit || 'Сделать push после commit в'} {pushProviderLabel}
+ + +
+

{$t.git?.commit_message || 'Сообщение коммита'}

+ +
+ + +
+ + +
- + {$t.git?.diff_title || 'Diff (изменения)'}
{#if hasWorkspaceChanges} @@ -300,7 +302,7 @@
{/if} {#if hasWorkspaceChanges && changeSummary.length > 0} -
-
- - {$t.git?.semantic_summary || 'Change summary'} -
-
+
+
{$t.git?.semantic_summary || 'Change summary'}
+
{#each changeSummary as category} -
- - {categoryLabel(category.key)} ({category.files.length}) - -
    - {#each category.files.slice(0, 6) as file} -
  • {file}
  • - {/each} - {#if category.files.length > 6} -
  • {($t.git?.semantic_summary_more || '+{count} more').replace('{count}', String(category.files.length - 6))}
  • - {/if} -
-
+ + {categoryLabel(category.key)} ({category.files.length}) + {/each}
@@ -467,8 +456,8 @@ line-height: 1.6; } :global(.diff-view .d2h-file-header) { - background: #f8fafc; - border-color: #e2e8f0; + background: hsl(var(--surface-muted)); + border-color: hsl(var(--border)); padding: 8px 12px; font-size: 12px; font-weight: 600; @@ -477,10 +466,10 @@ padding: 0 12px; } :global(.diff-view .d2h-ins) { - background-color: #f0fdf4; + background-color: hsl(var(--success-light)); } :global(.diff-view .d2h-del) { - background-color: #fef2f2; + background-color: hsl(var(--destructive-light)); } :global(.diff-view .d2h-code-side-linenumber) { width: 48px; diff --git a/frontend/src/lib/i18n/locales/en/git.json b/frontend/src/lib/i18n/locales/en/git.json index e7d0384a6..bd4c767d8 100644 --- a/frontend/src/lib/i18n/locales/en/git.json +++ b/frontend/src/lib/i18n/locales/en/git.json @@ -400,6 +400,7 @@ "viz_no_versions_hint": "Initialize the repository or sync from Superset to see deployment timeline.", "viz_bi_purpose": "Helps BI analysts understand deployment state before validation or promotion.", "viz_lag_tooltip": "Number of newer dashboard updates in Development not yet in Production", + "viz_lag": "lag", "viz_rec_up_to_date": "Production is current. Safe for validation/testing scenarios.", "viz_rec_lag": "Production lags by {count} updates. Review changes and promote after validation.", "viz_rec_no_prod": "No production data. Initialize/sync the repo to track live versions.", @@ -418,5 +419,17 @@ "viz_status_window": "Outside recent window (older)", "viz_primary_selected": "Select another version to compare (or use button below)", "viz_unique_versions": "unique versions", - "viz_recent_window": "recent window (older may exist)" + "viz_recent_window": "recent window (older may exist)", + "working_branch": "Working branch (current changes):", + "viz_expand_timeline": "Expand timeline", + "viz_show_timeline": "Show timeline", + "sync_compact": "Sync", + "viz_axis_older": "← Older dashboard versions", + "viz_axis_newer": "Newer versions (latest changes) →", + "viz_no_version": "No version in", + "viz_init": "Initialize", + "viz_arm_comparison": "Select A/B to compare", + "viz_compare_preprod": "Compare with PREPROD", + "viz_compare_dev": "Compare with DEV", + "viz_collapse_timeline": "Collapse timeline" } diff --git a/frontend/src/lib/i18n/locales/ru/git.json b/frontend/src/lib/i18n/locales/ru/git.json index b5d1c72ca..8c0c4e190 100644 --- a/frontend/src/lib/i18n/locales/ru/git.json +++ b/frontend/src/lib/i18n/locales/ru/git.json @@ -400,6 +400,7 @@ "viz_no_versions_hint": "Инициализируйте репозиторий или синхронизируйте из Superset.", "viz_bi_purpose": "Помогает аналитикам понять состояние развёртывания перед валидацией или промоушеном.", "viz_lag_tooltip": "Количество более новых обновлений дашборда в Разработке, которых ещё нет в Продакшне", + "viz_lag": "отставание", "viz_rec_up_to_date": "Продакшн актуален. Можно безопасно проводить валидацию и тестовые сценарии.", "viz_rec_lag": "Продакшн отстаёт на {count} обновлений. Изучите изменения и промоутните после валидации.", "viz_rec_no_prod": "Нет данных по продакшну. Инициализируйте/синхронизируйте репозиторий.", @@ -418,5 +419,17 @@ "viz_status_window": "За пределами окна истории (старше)", "viz_primary_selected": "Выберите точку B на графике", "viz_unique_versions": "уникальных версий", - "viz_recent_window": "окно недавних (более старые могут быть)" + "viz_recent_window": "окно недавних (более старые могут быть)", + "working_branch": "Рабочая ветка (текущие изменения):", + "viz_expand_timeline": "Развернуть таймлайн", + "viz_show_timeline": "Показать таймлайн", + "sync_compact": "Sync", + "viz_axis_older": "← Более старые версии дашборда", + "viz_axis_newer": "Новые версии (последние изменения) →", + "viz_no_version": "Нет версии в", + "viz_init": "Инициализировать", + "viz_arm_comparison": "Выбрать как A / B для сравнения", + "viz_compare_preprod": "Сравнить с текущей PREPROD", + "viz_compare_dev": "Сравнить с текущей DEV", + "viz_collapse_timeline": "Свернуть таймлайн" } diff --git a/frontend/src/lib/models/GitManagerModel.svelte.ts b/frontend/src/lib/models/GitManagerModel.svelte.ts index d47bffc73..178e54620 100644 --- a/frontend/src/lib/models/GitManagerModel.svelte.ts +++ b/frontend/src/lib/models/GitManagerModel.svelte.ts @@ -37,6 +37,11 @@ // @RELATION DEPENDS_ON -> [EXT:frontend:gitService] // @RELATION DEPENDS_ON -> [EXT:frontend:api] // @RELATION DEPENDS_ON -> [GitUtils] +// @INVARIANT DECOMPOSITION GATE: ~1076 lines (limit: 400). Split plan: +// GitManager.InitModel — handleInit, handleCreateRemoteRepo, checkGitConfig (repo lifecycle) +// GitManager.SyncModel — handleSync, handleCommit, handlePull, handlePush, refreshStatus (sync ops) +// GitManager.DeployModel — handlePromote, handleDeploy, openDeployModal (release/deploy) +// GitManager.MergeModel — loadMergeRecoveryState, handleResolveConflicts, abort/continue merge // @RELATION DEPENDS_ON -> [ToastsModule] // @RATIONALE Model-first architecture chosen because Git workspace state spans 8 operations (status, sync, commit, // pull, push, promote, merge, remote) with cross-operation invariants (loading flags are mutually exclusive, @@ -301,6 +306,8 @@ export class GitManagerModel { environmentHistories: Record = $state({}); /** Loading state for environment-history fetches (non-blocking). */ environmentHistoriesLoading: boolean = $state(false); + /** Deployment status from GET /deployment-status: per-environment real deploy state. */ + deploymentStatus: { environments: any[]; current_content_hash: string | null } | null = $state(null); /** Selected version hashes for details / compare (primary + optional secondary). */ selectedVersionA: string | null = $state(null); selectedVersionB: string | null = $state(null); @@ -313,6 +320,10 @@ export class GitManagerModel { /** Provider label for the create-repo dialog prompt. */ createRepoProviderLabel: string = $state(''); + // ── Auto Navigate Tab ─────────────────────────────────────── + /** When set by CTA action, GitManager auto-switches to this tab and shows a visual pulse. */ + autoNavigateTab: string | null = $state(null); + // ── Error Banner ──────────────────────────────────────────── gitError: GitErrorPayload | null = $state(null); gitErrorType: string = $state('error'); @@ -464,6 +475,21 @@ export class GitManagerModel { } finally { this.environmentHistoriesLoading = false; } + + // Also fetch real deployment status (non-blocking) + this.loadDeploymentStatus(); + } + + /** Fetch per-environment deployment status from deployment_records. */ + async loadDeploymentStatus(): Promise { + if (!this.dashboardId) return; + try { + this.deploymentStatus = await gitService.getDeploymentStatus( + this.dashboardId, this.resolvedEnvId + ) as any; + } catch { + this.deploymentStatus = null; + } } /** Select a version (commit hash) for the details panel. Supports compare via shift. */ diff --git a/frontend/src/services/gitService.ts b/frontend/src/services/gitService.ts index 3b5f7fabd..fea640851 100644 --- a/frontend/src/services/gitService.ts +++ b/frontend/src/services/gitService.ts @@ -460,6 +460,15 @@ export const gitService = { }, // #endregion deleteBranch + // #region getDeploymentStatus [C:2] [TYPE Function] [SEMANTICS git, deployment, versioning, status] + // @BRIEF Fetch per-environment deployment status with content-hash comparison. + // @POST Returns DeploymentStatusResponse with environments array and current_content_hash. + async getDeploymentStatus(dashboardRef: string | number, envId: string | number | null = null): Promise { + log("gitService", "REASON", "Fetching deployment status", { dashboardRef }); + return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/deployment-status', envId)); + }, + // #endregion getDeploymentStatus + // #region getBranchProtectionRules [C:2] [TYPE Function] [SEMANTICS git, branch, protection] // @BRIEF Fetch branch protection rules for environment branches. // @POST Returns list of protection rule objects.