diff --git a/backend/alembic/versions/d4e5f6a7b8c9_add_active_translation_run_guard.py b/backend/alembic/versions/d4e5f6a7b8c9_add_active_translation_run_guard.py new file mode 100644 index 000000000..4a6a815f4 --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_add_active_translation_run_guard.py @@ -0,0 +1,52 @@ +# #region Alembic.TranslateActiveRunGuard [C:3] [TYPE Module] [SEMANTICS alembic,migration,translate,concurrency] +# @BRIEF Enforces at most one pending/running translation run per job. +# @RELATION DEPENDS_ON -> [EXT:SQLAlchemy:Alembic] +# @POST Concurrent manual/scheduled triggers cannot create duplicate active runs. +"""add unique active translation run guard + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "d4e5f6a7b8c9" +down_revision: str | Sequence[str] | None = "c3d4e5f6a7b8" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create a partial unique index on supported application databases.""" + bind = op.get_bind() + dialect = bind.dialect.name + if dialect == "postgresql": + op.create_index( + "uq_translation_runs_one_active_per_job", + "translation_runs", + ["job_id"], + unique=True, + postgresql_where=sa.text("status IN ('PENDING', 'RUNNING')"), + ) + elif dialect == "sqlite": + op.create_index( + "uq_translation_runs_one_active_per_job", + "translation_runs", + ["job_id"], + unique=True, + sqlite_where=sa.text("status IN ('PENDING', 'RUNNING')"), + ) + + +def downgrade() -> None: + """Drop the active-run guard.""" + bind = op.get_bind() + if bind.dialect.name in {"postgresql", "sqlite"}: + op.drop_index("uq_translation_runs_one_active_per_job", table_name="translation_runs") + + +# #endregion Alembic.TranslateActiveRunGuard diff --git a/backend/src/api/routes/translate/_run_routes.py b/backend/src/api/routes/translate/_run_routes.py index 8d6c7f13d..6e73bf824 100644 --- a/backend/src/api/routes/translate/_run_routes.py +++ b/backend/src/api/routes/translate/_run_routes.py @@ -10,6 +10,7 @@ from datetime import UTC, datetime import asyncio from fastapi import Depends, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from ....core.config_manager import ConfigManager @@ -19,6 +20,8 @@ from ....core.logger import logger from ....dependencies import get_config_manager, get_current_user, has_permission from ....plugins.translate.orchestrator import TranslationOrchestrator from ....schemas.auth import User +from ....schemas.translate import RunPreflightResponse +from ....plugins.translate.run_preflight import TranslationRunPreflight from ._helpers import _run_to_response from ._router import router @@ -33,6 +36,7 @@ from ._router import router async def run_translation( job_id: str, full_translation: bool = Query(False, description="Translate ALL rows (skip new-key-only filter)"), + language_detection: str = Query("auto", pattern="^(auto|skip)$"), current_user: User = Depends(get_current_user), _ = Depends(has_permission("translate.job", "EXECUTE")), db: Session = Depends(get_db), @@ -49,7 +53,12 @@ async def run_translation( ) try: orch = TranslationOrchestrator(db, config_manager, current_user.username) - run = orch.start_run(job_id=job_id, is_scheduled=False, full_translation=full_translation) + run = orch.start_run( + job_id=job_id, + is_scheduled=False, + full_translation=full_translation, + language_detection=language_detection, + ) # The request-scoped db session will be closed after this handler returns. # The background task must use its OWN session. _request_trace_id = get_trace_id() @@ -124,12 +133,41 @@ async def run_translation( return _run_to_response(run) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This translation job already has an active run. Wait for it to finish or cancel it before starting another run.", + ) except Exception as e: logger.explore("run_translation failed", extra={"src": "translate_routes", "error": str(e)}) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Run failed: {e}") # #endregion run_translation +# #region calculate_run_preflight [C:4] [TYPE Function] [SEMANTICS translate,api,preflight] +# @BRIEF Calculate the actual run scope and Lingua distribution without creating a translation run. +# @PRE User can execute the translation job. +# @POST Returns scope, token/cost estimate, and aggregate language distribution only. +@router.post("/jobs/{job_id}/run-preflight", response_model=RunPreflightResponse) +async def calculate_run_preflight( + job_id: str, + full_translation: bool = Query(False), + current_user: User = Depends(get_current_user), + _ = Depends(has_permission("translate.job", "EXECUTE")), + db: Session = Depends(get_db), + config_manager: ConfigManager = Depends(get_config_manager), +): + try: + return await TranslationRunPreflight(db, config_manager).calculate(job_id, full_translation) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.explore("calculate_run_preflight failed", extra={"src": "translate_routes", "error": str(e)}) + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Run preflight failed: {e}") +# #endregion calculate_run_preflight + + # #region retry_run [C:4] [TYPE Function] # @ingroup Api # @BRIEF Retry failed batches in a translation run. diff --git a/backend/src/models/translate.py b/backend/src/models/translate.py index 6edb828be..c170a74bc 100644 --- a/backend/src/models/translate.py +++ b/backend/src/models/translate.py @@ -103,6 +103,16 @@ class TranslationRun(Base): created_at = Column(DateTime, default=lambda: datetime.now(UTC)) language_stats = relationship("TranslationRunLanguageStats", back_populates="run") + + __table_args__ = ( + Index( + "uq_translation_runs_one_active_per_job", + "job_id", + unique=True, + postgresql_where=status.in_(("PENDING", "RUNNING")), + sqlite_where=status.in_(("PENDING", "RUNNING")), + ), + ) # #endregion TranslationRun diff --git a/backend/src/plugins/translate/_batch_proc.py b/backend/src/plugins/translate/_batch_proc.py index c4c905b03..9f4577148 100644 --- a/backend/src/plugins/translate/_batch_proc.py +++ b/backend/src/plugins/translate/_batch_proc.py @@ -23,7 +23,7 @@ from sqlalchemy.orm import Session from ...core.config_manager import ConfigManager from ...core.logger import belief_scope, logger -from ...models.translate import TranslationBatch, TranslationJob, TranslationLanguage, TranslationRecord +from ...models.translate import TranslationBatch, TranslationJob, TranslationLanguage, TranslationRecord, TranslationRun from ...services.llm_provider import LLMProviderService from ._batch_insert import insert_batch_to_target from ._lang_detect import batch_detect @@ -68,8 +68,14 @@ class BatchProcessingService: tls = job.target_languages or [job.target_dialect or "en"] tls = [str(tls)] if not isinstance(tls, list) else tls - # ★ Run local language detection on all rows (heuristic, no LLM) - await self._detect_languages(batch_rows, tls) + run = self.db.query(TranslationRun).filter(TranslationRun.id == run_id).first() + detection_mode = (run.config_snapshot or {}).get("language_detection", "auto") if run else "auto" + if detection_mode == "skip": + for row in batch_rows: + row["_detected_lang"] = "und" + else: + # ★ Run local language detection on all rows (heuristic, no LLM) + await self._detect_languages(batch_rows, tls) # Emit LANGUAGE_DETECTION_COMPLETED aggregate event (no source text in payload) lang_counts: dict[str, int] = {} @@ -83,7 +89,7 @@ class BatchProcessingService: run_id=run_id, event_type="LANGUAGE_DETECTION_COMPLETED", payload={ - "detector": "lingua", + "detector": "lingua" if detection_mode != "skip" else "skipped", "source_language_distribution": dict(sorted(lang_counts.items())), "und_count": und_count, "und_rate": round(und_count / total, 4) if total > 0 else 0.0, diff --git a/backend/src/plugins/translate/orchestrator.py b/backend/src/plugins/translate/orchestrator.py index 63030ebd5..ac066a65f 100644 --- a/backend/src/plugins/translate/orchestrator.py +++ b/backend/src/plugins/translate/orchestrator.py @@ -73,6 +73,7 @@ class TranslationOrchestrator: is_scheduled: bool = False, trigger_type: str | None = None, full_translation: bool = False, + language_detection: str = "auto", ) -> TranslationRun: with belief_scope("TranslationOrchestrator.start_run"): return self._planner.plan_run( @@ -80,6 +81,7 @@ class TranslationOrchestrator: is_scheduled=is_scheduled, trigger_type=trigger_type, full_translation=full_translation, + language_detection=language_detection, ) # endregion start_run diff --git a/backend/src/plugins/translate/orchestrator_planner.py b/backend/src/plugins/translate/orchestrator_planner.py index e939cfb1a..653886421 100644 --- a/backend/src/plugins/translate/orchestrator_planner.py +++ b/backend/src/plugins/translate/orchestrator_planner.py @@ -46,12 +46,27 @@ class TranslationPlanner: is_scheduled: bool = False, trigger_type: str | None = None, full_translation: bool = False, + language_detection: str = "auto", ) -> TranslationRun: with belief_scope("TranslationPlanner.plan_run"): job = self.db.query(TranslationJob).filter(TranslationJob.id == job_id).first() if not job: raise ValueError(f"Translation job '{job_id}' not found") + active_run = ( + self.db.query(TranslationRun) + .filter( + TranslationRun.job_id == job_id, + TranslationRun.status.in_(("PENDING", "RUNNING")), + ) + .first() + ) + if isinstance(active_run, TranslationRun): + raise ValueError( + f"Translation job '{job_id}' already has an active run '{active_run.id}'. " + "Wait for it to finish or cancel it before starting another run." + ) + validate_job_preconditions(job, self.db, is_scheduled=is_scheduled) config_hash_value = compute_config_hash(job) @@ -75,6 +90,7 @@ class TranslationPlanner: "upsert_strategy": job.upsert_strategy, "dictionary_ids": dict_hash_value, "full_translation": full_translation, + "language_detection": language_detection, } key_hash_input = json.dumps({ diff --git a/backend/src/plugins/translate/run_preflight.py b/backend/src/plugins/translate/run_preflight.py new file mode 100644 index 000000000..af262efa0 --- /dev/null +++ b/backend/src/plugins/translate/run_preflight.py @@ -0,0 +1,126 @@ +# #region TranslationRunPreflight [C:4] [TYPE Class] [SEMANTICS translate,preflight,scope,lingua,cost] +# @BRIEF Calculate the real translation scope, token cost, and local language distribution before a run. +# @LAYER Domain +# @INVARIANT The incremental preflight applies the same new-key filter as execution. +# @INVARIANT Source texts are never returned by this service; only aggregate language counts leave the backend. +# @RELATION DEPENDS_ON -> [RunSourceFetcher] +# @RELATION DEPENDS_ON -> [LanguageDetectService] +# @RELATION DEPENDS_ON -> [TranslationPlanner] + +from __future__ import annotations + +import asyncio +import time +import uuid +from typing import Any + +from sqlalchemy.orm import Session + +from ...core.config_manager import ConfigManager +from ...core.logger import belief_scope +from ...models.translate import TranslationJob +from ._lang_detect import batch_detect +from ._run_service import RunExecutionService +from ._run_source import fetch_source_rows +from ._utils import estimate_row_tokens +from .orchestrator_config import compute_config_hash +from .preview_token_estimator import TokenEstimator + +LINGUA_MAX_SECONDS_PER_1000_ROWS = 2.0 + + +class TranslationRunPreflight: + """Calculate a non-persisted, current-state execution estimate for a job.""" + + def __init__(self, db: Session, config_manager: ConfigManager) -> None: + self.db = db + self.config_manager = config_manager + + # #region TranslationRunPreflight.calculate [C:4] [TYPE Function] [SEMANTICS translate,preflight,calculate] + # @BRIEF Fetch real source rows, apply run-mode scope, and benchmark Lingua before a translation run. + # @PRE Job exists and is runnable enough to fetch source rows. + # @POST Returns only aggregate execution evidence; does not create TranslationRun or call an LLM. + # @SIDE_EFFECT Reads source data through Superset and performs CPU-bound local detection. + async def calculate(self, job_id: str, full_translation: bool) -> dict[str, Any]: + with belief_scope("TranslationRunPreflight.calculate"): + job = self.db.query(TranslationJob).filter(TranslationJob.id == job_id).first() + if not job: + raise ValueError(f"Translation job '{job_id}' not found") + + source_rows = await fetch_source_rows( + self.db, self.config_manager, job_id, f"preflight-{uuid.uuid4()}" + ) + total_source_rows = len(source_rows) + eligible_rows = source_rows + if not full_translation: + eligible_rows = RunExecutionService( + self.db, self.config_manager + )._filter_new_keys(job, "preflight", source_rows) + + target_languages = job.target_languages or [job.target_dialect or "en"] + if not isinstance(target_languages, list): + target_languages = [str(target_languages)] + + source_tokens = sum( + estimate_row_tokens( + str(row.get("source_text", "")), row.get("source_data"), job + ) + for row in eligible_rows + ) + output_tokens = TokenEstimator.estimate_output_tokens( + len(eligible_rows), len(target_languages) + ) + estimated_tokens = source_tokens + output_tokens + + distribution, duration_ms = await self._detect_distribution( + eligible_rows, target_languages + ) + rows_per_second = ( + round(len(eligible_rows) / (duration_ms / 1000), 2) + if duration_ms > 0 else 0.0 + ) + seconds_per_1000 = ( + round((duration_ms / 1000) * 1000 / len(eligible_rows), 3) + if eligible_rows else 0.0 + ) + # A short run is dominated by detector warm-up/dispatch overhead. The SLA is + # explicitly a 1,000-row throughput criterion, so do not discard useful + # language evidence for smaller scopes because of extrapolation noise. + lingua_accepted = ( + len(eligible_rows) < 1000 + or seconds_per_1000 <= LINGUA_MAX_SECONDS_PER_1000_ROWS + ) + return { + "config_hash": compute_config_hash(job), + "full_translation": full_translation, + "total_source_rows": total_source_rows, + "eligible_rows": len(eligible_rows), + "skipped_rows": total_source_rows - len(eligible_rows), + "target_language_count": len(target_languages), + "estimated_tokens": estimated_tokens, + "estimated_cost": TokenEstimator.estimate_cost(estimated_tokens), + "language_distribution": distribution if lingua_accepted else None, + "lingua_duration_ms": duration_ms, + "lingua_rows_per_second": rows_per_second, + "lingua_seconds_per_1000": seconds_per_1000, + "lingua_accepted": lingua_accepted, + "recommended_language_detection": "auto" if lingua_accepted else "skip", + } + + # #endregion TranslationRunPreflight.calculate + + async def _detect_distribution( + self, rows: list[dict[str, Any]], target_languages: list[str] + ) -> tuple[dict[str, int], int]: + """Run Lingua off the event loop and return aggregate counts with elapsed time.""" + texts = [str(row.get("source_text", "")) for row in rows] + started = time.perf_counter() + detected = await asyncio.to_thread(batch_detect, texts, target_languages) + duration_ms = int((time.perf_counter() - started) * 1000) + distribution: dict[str, int] = {} + for language in detected: + distribution[language] = distribution.get(language, 0) + 1 + return dict(sorted(distribution.items())), duration_ms + + +# #endregion TranslationRunPreflight diff --git a/backend/src/schemas/translate.py b/backend/src/schemas/translate.py index 98ef1816d..1ca03deb0 100644 --- a/backend/src/schemas/translate.py +++ b/backend/src/schemas/translate.py @@ -294,6 +294,26 @@ class PreviewRequest(BaseModel): # #endregion PreviewRequest +# #region RunPreflightResponse [C:1] [TYPE Class] +# @BRIEF Response for the non-persisted run scope and Lingua performance calculation. +class RunPreflightResponse(BaseModel): + config_hash: str + full_translation: bool + total_source_rows: int + eligible_rows: int + skipped_rows: int + target_language_count: int + estimated_tokens: int + estimated_cost: float + language_distribution: dict[str, int] | None = None + lingua_duration_ms: int + lingua_rows_per_second: float + lingua_seconds_per_1000: float + lingua_accepted: bool + recommended_language_detection: str +# #endregion RunPreflightResponse + + # #region PreviewRowUpdate [TYPE Class] # @defgroup Schemas Module group. # @BRIEF Schema for approving/editing/rejecting a preview row. @@ -723,7 +743,7 @@ class InlineCorrectionSubmit(BaseModel): # @BRIEF Schema for requesting target table schema validation. class TargetSchemaValidationRequest(BaseModel): environment_id: str = Field(..., description="Superset environment ID") - target_database_id: str = Field(..., description="Superset database ID for the target DB (SQL Lab)") + target_database_id: str | None = Field(None, description="Superset database ID for the target DB (SQL Lab)") target_schema: str = Field("public", description="Target table schema (default: public)") target_table: str = Field(..., description="Target table name") # Column mapping from job config (everything that affects expected columns) @@ -736,6 +756,15 @@ class TargetSchemaValidationRequest(BaseModel): # Direct DB support — when insert_method=direct_db, use connection_id instead of target_database_id insert_method: str | None = Field(None, description="Insert method: 'sqllab' or 'direct_db'") connection_id: str | None = Field(None, description="Direct DB connection ID (required when insert_method=direct_db)") + + @model_validator(mode="after") + def validate_backend_target(self): + if self.insert_method == "direct_db": + if not self.connection_id: + raise ValueError("connection_id is required when insert_method=direct_db") + elif not self.target_database_id: + raise ValueError("target_database_id is required when insert_method=sqllab") + return self # #endregion TargetSchemaValidationRequest diff --git a/backend/tests/plugins/translate/test_orchestrator_planner.py b/backend/tests/plugins/translate/test_orchestrator_planner.py index f774de6c2..310a58059 100644 --- a/backend/tests/plugins/translate/test_orchestrator_planner.py +++ b/backend/tests/plugins/translate/test_orchestrator_planner.py @@ -20,6 +20,7 @@ from unittest.mock import MagicMock, patch import pytest from src.plugins.translate.orchestrator_planner import TranslationPlanner +from src.models.translate import TranslationRun class TestTranslationPlanner: @@ -77,11 +78,46 @@ class TestTranslationPlanner: assert result.config_snapshot["target_dialect"] == "clickhouse" assert result.config_snapshot["batch_size"] == 50 assert result.config_snapshot["full_translation"] is False + assert result.config_snapshot["language_detection"] == "auto" db.add.assert_called_once_with(result) db.flush.assert_called() db.commit.assert_called() db.refresh.assert_called_once_with(result) + @patch("src.plugins.translate.orchestrator_planner.compute_config_hash", return_value="hash1") + @patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash", return_value="hash2") + @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") + def test_plan_run_snapshots_lingua_fallback(self, _validate, _dict_hash, _config_hash): + """A preflight-selected Lingua fallback is immutable for the created run.""" + db = MagicMock() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + + result = TranslationPlanner(db, MagicMock(), "user").plan_run( + "job-1", language_detection="skip" + ) + + assert result.config_snapshot["language_detection"] == "skip" + + @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") + def test_active_run_blocks_duplicate_manual_trigger(self, mock_validate): + """An existing PENDING/RUNNING run blocks a second run for the same job.""" + db = MagicMock() + event_log = MagicMock() + job = self._make_job() + active = TranslationRun(id="run-active", job_id="job-1", status="RUNNING") + job_query = MagicMock() + job_query.filter.return_value.first.return_value = job + active_query = MagicMock() + active_query.filter.return_value.first.return_value = active + db.query.side_effect = [job_query, active_query] + + planner = TranslationPlanner(db, event_log, "user") + with pytest.raises(ValueError, match="already has an active run"): + planner.plan_run("job-1") + + mock_validate.assert_not_called() + @patch("src.plugins.translate.orchestrator_planner.compute_config_hash") @patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash") @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") diff --git a/backend/tests/plugins/translate/test_run_preflight.py b/backend/tests/plugins/translate/test_run_preflight.py new file mode 100644 index 000000000..a318a1efd --- /dev/null +++ b/backend/tests/plugins/translate/test_run_preflight.py @@ -0,0 +1,66 @@ +# #region Test.Translate.RunPreflight [C:3] [TYPE Module] [SEMANTICS test,translate,preflight,lingua] +# @BRIEF Verify run preflight returns aggregate scope only and applies the Lingua SLA fallback. +# @RELATION BINDS_TO -> [TranslationRunPreflight] +# @TEST_EDGE: missing_job -> ValueError +# @TEST_EDGE: slow_lingua -> distribution omitted and skip recommended +# @TEST_EDGE: incremental_scope -> existing rows excluded before cost calculation + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.plugins.translate.run_preflight import TranslationRunPreflight + + +def _job(): + return SimpleNamespace( + id="job-1", source_dialect="postgres", target_dialect="en", + source_datasource_id="ds-1", translation_column="text", context_columns=[], + target_languages=["en", "ru"], provider_id="provider-1", batch_size=50, + upsert_strategy="MERGE", source_key_cols=["id"], target_key_cols=["id"], + ) + + +def _service(job=None): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = job + return TranslationRunPreflight(db, MagicMock()) + + +@pytest.mark.asyncio +async def test_preflight_returns_aggregate_scope_and_lingua_distribution(): + service = _service(_job()) + rows = [ + {"source_text": "Hello", "source_data": {"id": 1}}, + {"source_text": "Привет", "source_data": {"id": 2}}, + ] + with patch("src.plugins.translate.run_preflight.fetch_source_rows", new=AsyncMock(return_value=rows)), \ + patch.object(service, "_detect_distribution", new=AsyncMock(return_value=({"en": 1, "ru": 1}, 10))), \ + patch("src.plugins.translate.run_preflight.compute_config_hash", return_value="cfg-1"): + result = await service.calculate("job-1", full_translation=True) + + assert result["total_source_rows"] == 2 + assert result["eligible_rows"] == 2 + assert result["language_distribution"] == {"en": 1, "ru": 1} + assert result["lingua_accepted"] is True + assert result["recommended_language_detection"] == "auto" + + +@pytest.mark.asyncio +async def test_preflight_hides_distribution_when_lingua_misses_sla(): + service = _service(_job()) + rows = [{"source_text": "Hello", "source_data": {"id": i}} for i in range(1000)] + with patch("src.plugins.translate.run_preflight.fetch_source_rows", new=AsyncMock(return_value=rows)), \ + patch.object(service, "_detect_distribution", new=AsyncMock(return_value=({"en": 1}, 3_000))): + result = await service.calculate("job-1", full_translation=True) + + assert result["language_distribution"] is None + assert result["lingua_accepted"] is False + assert result["recommended_language_detection"] == "skip" + + +@pytest.mark.asyncio +async def test_preflight_rejects_unknown_job(): + with pytest.raises(ValueError, match="job-404"): + await _service(None).calculate("job-404", full_translation=False) diff --git a/frontend/src/lib/components/translate/RunTabContent.svelte b/frontend/src/lib/components/translate/RunTabContent.svelte index 8cffb8752..0dc7b43a2 100644 --- a/frontend/src/lib/components/translate/RunTabContent.svelte +++ b/frontend/src/lib/components/translate/RunTabContent.svelte @@ -8,9 +8,9 @@ - - - + + + @@ -61,6 +61,7 @@ let metricsLoading = $state(false); let metricsError = $state(null); let wasRunActive = false; + let secondaryDetailsOpen = $state(false); // Confirmation dialog state let showConfirmDialog = $state(false); @@ -124,6 +125,7 @@ }); $effect(() => { + if (isRunActive) secondaryDetailsOpen = false; if (wasRunActive && !isRunActive) loadJobMetrics(); wasRunActive = isRunActive; }); @@ -199,12 +201,13 @@