Files
microfish/backend/app/models/task.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
2026-08-31 13:05:21 +07:00

361 lines
14 KiB
Python

"""Durable task status management with a test-only in-memory fallback.
The Flask application configures a SQLAlchemy session factory at startup. Code
that uses TaskManager outside an application (small unit tests and legacy
adapters) keeps the old in-memory behavior, but production requests do not.
"""
from __future__ import annotations
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Any, Dict, Optional, cast
from flask import current_app, has_app_context
from sqlalchemy import delete, select
from ..models.operations import Job, JobStatus
from ..utils.locale import t
class TaskStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class Task:
task_id: str
task_type: str
status: TaskStatus
created_at: datetime
updated_at: datetime
progress: int = 0
message: str = ""
result: Optional[Dict] = None
error: Optional[str] = None
metadata: Dict = field(default_factory=dict)
progress_detail: Dict = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"task_id": self.task_id,
"task_type": self.task_type,
"status": self.status.value,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"progress": self.progress,
"message": self.message,
"progress_detail": self.progress_detail,
"result": self.result,
"error": self.error,
"metadata": self.metadata,
}
class TaskManager:
"""Thread-safe task facade bound to one app/session factory."""
_configured_session_factory = None
_config_lock = threading.Lock()
_fallback_tasks: Dict[str, Task] = {}
_fallback_lock = threading.Lock()
def __init__(self, session_factory=None):
"""Bind this manager to an explicit or current-app session factory.
A manager created during a request/app context keeps that app's factory
for background work, but it cannot be reused inside a different Flask
app. The explicit class configuration remains only for legacy tests and
callers that run outside Flask.
"""
self._bound_app = None
if has_app_context():
app = cast(Any, current_app)._get_current_object()
current_factory = app.extensions.get("crowdsight_session_factory")
if not callable(current_factory):
raise RuntimeError("task_session_factory_required")
if session_factory is not None and session_factory is not current_factory:
raise RuntimeError("task_session_factory_mismatch")
bound_factory = current_factory
self._bound_app = app
elif session_factory is not None:
bound_factory = session_factory
else:
# No Flask app context: use the explicit legacy/test binding.
bound_factory = type(self)._configured_session_factory
self._session_factory = bound_factory
self._tasks = type(self)._fallback_tasks
self._task_lock = type(self)._fallback_lock
@classmethod
def configure(cls, session_factory) -> None:
"""Set an explicit outside-Flask binding for tests/legacy adapters."""
with cls._config_lock:
cls._configured_session_factory = session_factory
with cls._fallback_lock:
cls._fallback_tasks.clear()
def _factory(self):
if not has_app_context():
return self._session_factory
app = cast(Any, current_app)._get_current_object()
current_factory = app.extensions.get("crowdsight_session_factory")
if not callable(current_factory):
raise RuntimeError("task_session_factory_required")
if self._bound_app is not None and self._bound_app is not app:
raise RuntimeError("task_app_context_mismatch")
if self._session_factory is not None and self._session_factory is not current_factory:
raise RuntimeError("task_session_factory_mismatch")
if self._session_factory is None:
self._session_factory = current_factory
self._bound_app = app
return self._session_factory
@staticmethod
def _bounded_text(value: Any, limit: int = 4000) -> str:
if value is None:
return ""
return str(value)[:limit]
@staticmethod
def _job_status(status: TaskStatus | str | None) -> str | None:
if status is None:
return None
value = status.value if isinstance(status, TaskStatus) else str(status)
return {
TaskStatus.PENDING.value: JobStatus.QUEUED.value,
TaskStatus.PROCESSING.value: JobStatus.RUNNING.value,
TaskStatus.COMPLETED.value: JobStatus.SUCCEEDED.value,
TaskStatus.FAILED.value: JobStatus.FAILED.value,
JobStatus.QUEUED.value: JobStatus.QUEUED.value,
JobStatus.RUNNING.value: JobStatus.RUNNING.value,
JobStatus.SUCCEEDED.value: JobStatus.SUCCEEDED.value,
JobStatus.FAILED.value: JobStatus.FAILED.value,
JobStatus.CANCELLED.value: JobStatus.CANCELLED.value,
}.get(value)
@staticmethod
def _task_status(status: str | JobStatus) -> TaskStatus:
value = status.value if isinstance(status, JobStatus) else str(status)
return {
JobStatus.QUEUED.value: TaskStatus.PENDING,
JobStatus.RUNNING.value: TaskStatus.PROCESSING,
JobStatus.SUCCEEDED.value: TaskStatus.COMPLETED,
JobStatus.FAILED.value: TaskStatus.FAILED,
JobStatus.CANCELLED.value: TaskStatus.FAILED,
}.get(value, TaskStatus.FAILED)
@classmethod
def _from_job(cls, job: Job) -> Task:
metadata = job.job_metadata if isinstance(job.job_metadata, dict) else {}
result = job.result if isinstance(job.result, dict) else job.result
detail = job.progress_detail if isinstance(job.progress_detail, dict) else {}
return Task(
task_id=job.id,
task_type=job.operation,
status=cls._task_status(job.status),
created_at=job.created_at,
updated_at=job.updated_at,
progress=job.progress,
message=job.message,
result=result,
error=job.error_code,
metadata=metadata,
progress_detail=detail,
)
def create_task(self, task_type: str, metadata: Optional[Dict] = None) -> str:
metadata = metadata or {}
factory = self._factory()
if factory is not None:
organization_id = metadata.get("organization_id")
if not isinstance(organization_id, str) or not organization_id:
raise ValueError("task_scope_required")
with factory() as session:
job = Job(
organization_id=organization_id,
owner_user_id=metadata.get("owner_user_id"),
project_id=metadata.get("project_id"),
graph_id=metadata.get("graph_id"),
operation=self._bounded_text(task_type, 120),
status=JobStatus.QUEUED.value,
job_metadata=metadata,
progress_detail={},
)
session.add(job)
session.commit()
return job.id
task_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
task = Task(
task_id=task_id,
task_type=task_type,
status=TaskStatus.PENDING,
created_at=now,
updated_at=now,
metadata=metadata,
)
with self._task_lock:
self._tasks[task_id] = task
return task_id
def get_task(
self,
task_id: str,
*,
organization_id: Optional[str] = None,
owner_user_id: Optional[str] = None,
) -> Optional[Task]:
factory = self._factory()
if factory is not None:
with factory() as session:
statement = select(Job).where(Job.id == task_id)
if organization_id is not None:
statement = statement.where(Job.organization_id == organization_id)
if owner_user_id is not None:
statement = statement.where(Job.owner_user_id == owner_user_id)
job = session.scalar(statement)
return self._from_job(job) if job is not None else None
with self._task_lock:
task = self._tasks.get(task_id)
if task is None:
return None
if organization_id is not None and task.metadata.get("organization_id") != organization_id:
return None
if owner_user_id is not None and task.metadata.get("owner_user_id") != owner_user_id:
return None
return task
def update_task(
self,
task_id: str,
status: Optional[TaskStatus] = None,
progress: Optional[int] = None,
message: Optional[str] = None,
result: Optional[Dict] = None,
error: Optional[str] = None,
progress_detail: Optional[Dict] = None,
):
factory = self._factory()
if factory is not None:
with factory() as session:
job = session.get(Job, task_id)
if job is None:
return
mapped_status = self._job_status(status)
if mapped_status is not None:
job.status = mapped_status
if mapped_status in {
JobStatus.SUCCEEDED.value,
JobStatus.FAILED.value,
JobStatus.CANCELLED.value,
}:
job.finished_at = datetime.now(timezone.utc)
if progress is not None:
job.progress = min(max(int(progress), 0), 100)
if message is not None:
job.message = self._bounded_text(message)
if result is not None:
job.result = result
if error is not None:
job.error_code = self._bounded_text(error, 120)
if progress_detail is not None:
job.progress_detail = progress_detail
job.updated_at = datetime.now(timezone.utc)
session.commit()
return
with self._task_lock:
task = self._tasks.get(task_id)
if task:
task.updated_at = datetime.now(timezone.utc)
if status is not None:
task.status = status
if progress is not None:
task.progress = min(max(int(progress), 0), 100)
if message is not None:
task.message = message
if result is not None:
task.result = result
if error is not None:
task.error = error
if progress_detail is not None:
task.progress_detail = progress_detail
def complete_task(self, task_id: str, result: Dict):
self.update_task(
task_id,
status=TaskStatus.COMPLETED,
progress=100,
message=t("progress.taskComplete"),
result=result,
)
def fail_task(self, task_id: str, error: str):
self.update_task(
task_id,
status=TaskStatus.FAILED,
message=t("progress.taskFailed"),
error=error,
)
def list_tasks(
self,
task_type: Optional[str] = None,
*,
organization_id: Optional[str] = None,
owner_user_id: Optional[str] = None,
) -> list:
factory = self._factory()
if factory is not None:
with factory() as session:
statement = select(Job).order_by(Job.created_at.desc())
if task_type:
statement = statement.where(Job.operation == task_type)
if organization_id is not None:
statement = statement.where(Job.organization_id == organization_id)
if owner_user_id is not None:
statement = statement.where(Job.owner_user_id == owner_user_id)
jobs = session.scalars(statement.limit(100)).all()
return [self._from_job(job) for job in jobs]
with self._task_lock:
tasks = list(self._tasks.values())
if task_type:
tasks = [task for task in tasks if task.task_type == task_type]
if organization_id is not None:
tasks = [task for task in tasks if task.metadata.get("organization_id") == organization_id]
if owner_user_id is not None:
tasks = [task for task in tasks if task.metadata.get("owner_user_id") == owner_user_id]
return [task for task in sorted(tasks, key=lambda item: item.created_at, reverse=True)]
def cleanup_old_tasks(self, max_age_hours: int = 24):
cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
factory = self._factory()
if factory is not None:
with factory() as session:
session.execute(
delete(Job).where(
Job.created_at < cutoff,
Job.status.in_([JobStatus.SUCCEEDED.value, JobStatus.FAILED.value]),
)
)
session.commit()
return
with self._task_lock:
old_ids = [
task_id
for task_id, task in self._tasks.items()
if task.created_at < cutoff and task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED]
]
for task_id in old_ids:
del self._tasks[task_id]