QuickLab-MCP 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. ml_mcp/__init__.py +3 -0
  2. ml_mcp/application/__init__.py +1 -0
  3. ml_mcp/application/analysis/__init__.py +15 -0
  4. ml_mcp/application/analysis/diagnostics.py +122 -0
  5. ml_mcp/application/analysis/service.py +155 -0
  6. ml_mcp/application/artifacts/__init__.py +5 -0
  7. ml_mcp/application/artifacts/service.py +149 -0
  8. ml_mcp/application/datasets/__init__.py +6 -0
  9. ml_mcp/application/datasets/service.py +178 -0
  10. ml_mcp/application/datasets/validator.py +156 -0
  11. ml_mcp/application/experiments/__init__.py +5 -0
  12. ml_mcp/application/experiments/service.py +254 -0
  13. ml_mcp/application/models/__init__.py +6 -0
  14. ml_mcp/application/models/catalog_seed.py +131 -0
  15. ml_mcp/application/models/service.py +177 -0
  16. ml_mcp/application/projects/__init__.py +5 -0
  17. ml_mcp/application/projects/service.py +93 -0
  18. ml_mcp/config/__init__.py +5 -0
  19. ml_mcp/config/settings.py +176 -0
  20. ml_mcp/domain/__init__.py +1 -0
  21. ml_mcp/domain/errors/__init__.py +39 -0
  22. ml_mcp/domain/errors/base.py +155 -0
  23. ml_mcp/domain/errors/codes.py +23 -0
  24. ml_mcp/domain/policies/__init__.py +16 -0
  25. ml_mcp/domain/policies/rbac.py +126 -0
  26. ml_mcp/domain/policies/tenant_isolation.py +34 -0
  27. ml_mcp/domain/policies/tool_policy.py +185 -0
  28. ml_mcp/domain/value_objects/__init__.py +41 -0
  29. ml_mcp/domain/value_objects/dataset.py +60 -0
  30. ml_mcp/domain/value_objects/experiment.py +131 -0
  31. ml_mcp/domain/value_objects/model.py +48 -0
  32. ml_mcp/infrastructure/__init__.py +1 -0
  33. ml_mcp/infrastructure/object_storage/__init__.py +5 -0
  34. ml_mcp/infrastructure/object_storage/s3.py +272 -0
  35. ml_mcp/infrastructure/postgres/__init__.py +14 -0
  36. ml_mcp/infrastructure/postgres/base.py +40 -0
  37. ml_mcp/infrastructure/postgres/models.py +356 -0
  38. ml_mcp/infrastructure/postgres/repositories/__init__.py +21 -0
  39. ml_mcp/infrastructure/postgres/repositories/artifacts.py +34 -0
  40. ml_mcp/infrastructure/postgres/repositories/audit.py +34 -0
  41. ml_mcp/infrastructure/postgres/repositories/datasets.py +85 -0
  42. ml_mcp/infrastructure/postgres/repositories/experiments.py +134 -0
  43. ml_mcp/infrastructure/postgres/repositories/metrics.py +61 -0
  44. ml_mcp/infrastructure/postgres/repositories/models.py +61 -0
  45. ml_mcp/infrastructure/postgres/repositories/outbox.py +47 -0
  46. ml_mcp/infrastructure/postgres/repositories/projects.py +83 -0
  47. ml_mcp/infrastructure/postgres/session.py +117 -0
  48. ml_mcp/infrastructure/queue/__init__.py +6 -0
  49. ml_mcp/infrastructure/queue/outbox_processor.py +67 -0
  50. ml_mcp/infrastructure/queue/task_queue.py +98 -0
  51. ml_mcp/infrastructure/redis/__init__.py +11 -0
  52. ml_mcp/infrastructure/redis/client.py +64 -0
  53. ml_mcp/infrastructure/redis/rate_limiter.py +129 -0
  54. ml_mcp/infrastructure/telemetry/__init__.py +23 -0
  55. ml_mcp/infrastructure/telemetry/logging.py +155 -0
  56. ml_mcp/infrastructure/telemetry/otel.py +141 -0
  57. ml_mcp/mcp/__init__.py +1 -0
  58. ml_mcp/mcp/prompts.py +38 -0
  59. ml_mcp/mcp/resources.py +35 -0
  60. ml_mcp/mcp/schemas/__init__.py +1 -0
  61. ml_mcp/mcp/schemas/tools.py +124 -0
  62. ml_mcp/mcp/tools/__init__.py +63 -0
  63. ml_mcp/mcp/tools/analysis.py +37 -0
  64. ml_mcp/mcp/tools/datasets.py +82 -0
  65. ml_mcp/mcp/tools/discovery.py +82 -0
  66. ml_mcp/mcp/tools/experiments.py +96 -0
  67. ml_mcp/mcp/tools/projects.py +39 -0
  68. ml_mcp/mcp/tools/results.py +59 -0
  69. ml_mcp/server/__init__.py +1 -0
  70. ml_mcp/server/app.py +488 -0
  71. ml_mcp/server/auth.py +146 -0
  72. ml_mcp/server/context.py +59 -0
  73. ml_mcp/server/http.py +183 -0
  74. ml_mcp/server/middleware.py +121 -0
  75. ml_mcp/server/stdio.py +42 -0
  76. ml_mcp/workers/__init__.py +1 -0
  77. ml_mcp/workers/daemon.py +130 -0
  78. ml_mcp/workers/metrics.py +110 -0
  79. ml_mcp/workers/preprocessing.py +170 -0
  80. ml_mcp/workers/runner.py +250 -0
  81. ml_mcp/workers/sandbox.py +35 -0
  82. ml_mcp/workers/trainers/__init__.py +52 -0
  83. ml_mcp/workers/trainers/base.py +52 -0
  84. ml_mcp/workers/trainers/catboost_model.py +85 -0
  85. ml_mcp/workers/trainers/lightgbm_model.py +81 -0
  86. ml_mcp/workers/trainers/sklearn_models.py +266 -0
  87. ml_mcp/workers/trainers/xgboost_model.py +82 -0
  88. quicklab_mcp-0.1.0.dist-info/METADATA +540 -0
  89. quicklab_mcp-0.1.0.dist-info/RECORD +92 -0
  90. quicklab_mcp-0.1.0.dist-info/WHEEL +5 -0
  91. quicklab_mcp-0.1.0.dist-info/entry_points.txt +4 -0
  92. quicklab_mcp-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ """PostgreSQL Repositories package."""
2
+
3
+ from ml_mcp.infrastructure.postgres.repositories.artifacts import ArtifactRepository
4
+ from ml_mcp.infrastructure.postgres.repositories.audit import AuditRepository
5
+ from ml_mcp.infrastructure.postgres.repositories.datasets import DatasetRepository
6
+ from ml_mcp.infrastructure.postgres.repositories.experiments import ExperimentRepository
7
+ from ml_mcp.infrastructure.postgres.repositories.metrics import MetricRepository
8
+ from ml_mcp.infrastructure.postgres.repositories.models import ModelRepository
9
+ from ml_mcp.infrastructure.postgres.repositories.outbox import OutboxRepository
10
+ from ml_mcp.infrastructure.postgres.repositories.projects import ProjectRepository
11
+
12
+ __all__ = [
13
+ "ModelRepository",
14
+ "DatasetRepository",
15
+ "ExperimentRepository",
16
+ "MetricRepository",
17
+ "ArtifactRepository",
18
+ "OutboxRepository",
19
+ "AuditRepository",
20
+ "ProjectRepository",
21
+ ]
@@ -0,0 +1,34 @@
1
+ """Repository for experiment artifact metadata."""
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from ml_mcp.infrastructure.postgres.models import ArtifactOrm
7
+
8
+
9
+ class ArtifactRepository:
10
+ """Tenant-scoped data access for persisted analytical and model artifacts."""
11
+
12
+ def __init__(self, session: AsyncSession) -> None:
13
+ self.session = session
14
+
15
+ async def record_artifact(self, artifact: ArtifactOrm) -> ArtifactOrm:
16
+ self.session.add(artifact)
17
+ await self.session.flush()
18
+ return artifact
19
+
20
+ async def list_artifacts(self, tenant_id: str, experiment_id: str) -> list[ArtifactOrm]:
21
+ stmt = (
22
+ select(ArtifactOrm)
23
+ .where(ArtifactOrm.tenant_id == tenant_id, ArtifactOrm.experiment_id == experiment_id)
24
+ .order_by(ArtifactOrm.created_at.desc())
25
+ )
26
+ result = await self.session.execute(stmt)
27
+ return list(result.scalars().all())
28
+
29
+ async def get_artifact(self, tenant_id: str, artifact_id: str) -> ArtifactOrm | None:
30
+ stmt = select(ArtifactOrm).where(
31
+ ArtifactOrm.tenant_id == tenant_id, ArtifactOrm.id == artifact_id
32
+ )
33
+ result = await self.session.execute(stmt)
34
+ return result.scalar_one_or_none()
@@ -0,0 +1,34 @@
1
+ """Repository for immutable audit log records."""
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from ml_mcp.infrastructure.postgres.models import AuditEventOrm
7
+
8
+
9
+ class AuditRepository:
10
+ """Append-only audit trail repository."""
11
+
12
+ def __init__(self, session: AsyncSession) -> None:
13
+ self.session = session
14
+
15
+ async def record_event(self, event: AuditEventOrm) -> AuditEventOrm:
16
+ self.session.add(event)
17
+ await self.session.flush()
18
+ return event
19
+
20
+ async def list_events(
21
+ self,
22
+ tenant_id: str,
23
+ limit: int = 100,
24
+ offset: int = 0,
25
+ ) -> list[AuditEventOrm]:
26
+ stmt = (
27
+ select(AuditEventOrm)
28
+ .where(AuditEventOrm.tenant_id == tenant_id)
29
+ .order_by(AuditEventOrm.timestamp.desc())
30
+ .limit(limit)
31
+ .offset(offset)
32
+ )
33
+ result = await self.session.execute(stmt)
34
+ return list(result.scalars().all())
@@ -0,0 +1,85 @@
1
+ """Repository for dataset and dataset version persistence."""
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy.orm import selectinload
6
+
7
+ from ml_mcp.infrastructure.postgres.models import DatasetOrm, DatasetVersionOrm
8
+
9
+
10
+ class DatasetRepository:
11
+ """Tenant-scoped data access for registered datasets and versions."""
12
+
13
+ def __init__(self, session: AsyncSession) -> None:
14
+ self.session = session
15
+
16
+ async def create_dataset(self, dataset: DatasetOrm) -> DatasetOrm:
17
+ self.session.add(dataset)
18
+ await self.session.flush()
19
+ return dataset
20
+
21
+ async def get_dataset(self, tenant_id: str, dataset_id: str) -> DatasetOrm | None:
22
+ stmt = (
23
+ select(DatasetOrm)
24
+ .where(DatasetOrm.tenant_id == tenant_id, DatasetOrm.id == dataset_id)
25
+ .options(selectinload(DatasetOrm.versions))
26
+ )
27
+ result = await self.session.execute(stmt)
28
+ return result.scalar_one_or_none()
29
+
30
+ async def list_datasets(
31
+ self, tenant_id: str, project_id: str | None = None
32
+ ) -> list[DatasetOrm]:
33
+ stmt = select(DatasetOrm).where(DatasetOrm.tenant_id == tenant_id)
34
+ if project_id:
35
+ stmt = stmt.where(DatasetOrm.project_id == project_id)
36
+ stmt = stmt.options(selectinload(DatasetOrm.versions)).order_by(
37
+ DatasetOrm.created_at.desc()
38
+ )
39
+ result = await self.session.execute(stmt)
40
+ return list(result.scalars().all())
41
+
42
+ async def create_version(self, version: DatasetVersionOrm) -> DatasetVersionOrm:
43
+ self.session.add(version)
44
+ await self.session.flush()
45
+ return version
46
+
47
+ async def get_version(
48
+ self, tenant_id: str, dataset_id: str, version: str
49
+ ) -> DatasetVersionOrm | None:
50
+ stmt = (
51
+ select(DatasetVersionOrm)
52
+ .join(DatasetOrm, DatasetVersionOrm.dataset_id == DatasetOrm.id)
53
+ .where(
54
+ DatasetOrm.tenant_id == tenant_id,
55
+ DatasetVersionOrm.dataset_id == dataset_id,
56
+ DatasetVersionOrm.version == version,
57
+ )
58
+ )
59
+ result = await self.session.execute(stmt)
60
+ return result.scalar_one_or_none()
61
+
62
+ async def get_version_by_id(self, tenant_id: str, version_id: str) -> DatasetVersionOrm | None:
63
+ stmt = (
64
+ select(DatasetVersionOrm)
65
+ .join(DatasetOrm, DatasetVersionOrm.dataset_id == DatasetOrm.id)
66
+ .where(
67
+ DatasetOrm.tenant_id == tenant_id,
68
+ DatasetVersionOrm.id == version_id,
69
+ )
70
+ )
71
+ result = await self.session.execute(stmt)
72
+ return result.scalar_one_or_none()
73
+
74
+ async def list_versions(self, tenant_id: str, dataset_id: str) -> list[DatasetVersionOrm]:
75
+ stmt = (
76
+ select(DatasetVersionOrm)
77
+ .join(DatasetOrm, DatasetVersionOrm.dataset_id == DatasetOrm.id)
78
+ .where(
79
+ DatasetOrm.tenant_id == tenant_id,
80
+ DatasetVersionOrm.dataset_id == dataset_id,
81
+ )
82
+ .order_by(DatasetVersionOrm.created_at.desc())
83
+ )
84
+ result = await self.session.execute(stmt)
85
+ return list(result.scalars().all())
@@ -0,0 +1,134 @@
1
+ """Repository for experiments, runs, and transactional outbox emission."""
2
+
3
+ from typing import Any
4
+
5
+ from sqlalchemy import select, update
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlalchemy.orm import selectinload
8
+
9
+ from ml_mcp.infrastructure.postgres.base import utc_now
10
+ from ml_mcp.infrastructure.postgres.models import (
11
+ ExperimentOrm,
12
+ ExperimentRunOrm,
13
+ ModelVersionOrm,
14
+ OutboxEventOrm,
15
+ )
16
+
17
+
18
+ class ExperimentRepository:
19
+ """Tenant-scoped data access for experiments and execution runs."""
20
+
21
+ def __init__(self, session: AsyncSession) -> None:
22
+ self.session = session
23
+
24
+ async def create_experiment(
25
+ self,
26
+ experiment: ExperimentOrm,
27
+ initial_outbox_event: OutboxEventOrm | None = None,
28
+ ) -> ExperimentOrm:
29
+ """Create experiment and optional transactional outbox event atomically."""
30
+ self.session.add(experiment)
31
+ if initial_outbox_event:
32
+ self.session.add(initial_outbox_event)
33
+ await self.session.flush()
34
+ return experiment
35
+
36
+ async def get_by_idempotency_key(
37
+ self,
38
+ tenant_id: str,
39
+ project_id: str,
40
+ idempotency_key: str,
41
+ ) -> ExperimentOrm | None:
42
+ stmt = (
43
+ select(ExperimentOrm)
44
+ .where(
45
+ ExperimentOrm.tenant_id == tenant_id,
46
+ ExperimentOrm.project_id == project_id,
47
+ ExperimentOrm.idempotency_key == idempotency_key,
48
+ )
49
+ .options(selectinload(ExperimentOrm.runs))
50
+ )
51
+ result = await self.session.execute(stmt)
52
+ return result.scalar_one_or_none()
53
+
54
+ async def get_experiment(self, tenant_id: str, experiment_id: str) -> ExperimentOrm | None:
55
+ stmt = (
56
+ select(ExperimentOrm)
57
+ .where(ExperimentOrm.tenant_id == tenant_id, ExperimentOrm.id == experiment_id)
58
+ .options(
59
+ selectinload(ExperimentOrm.runs),
60
+ selectinload(ExperimentOrm.metrics),
61
+ selectinload(ExperimentOrm.artifacts),
62
+ selectinload(ExperimentOrm.analysis),
63
+ selectinload(ExperimentOrm.model_version).selectinload(ModelVersionOrm.model),
64
+ selectinload(ExperimentOrm.dataset_version),
65
+ )
66
+ )
67
+ result = await self.session.execute(stmt)
68
+ return result.scalar_one_or_none()
69
+
70
+ async def list_experiments(
71
+ self,
72
+ tenant_id: str,
73
+ project_id: str | None = None,
74
+ status: str | None = None,
75
+ limit: int = 50,
76
+ offset: int = 0,
77
+ ) -> list[ExperimentOrm]:
78
+ stmt = select(ExperimentOrm).where(ExperimentOrm.tenant_id == tenant_id)
79
+ if project_id:
80
+ stmt = stmt.where(ExperimentOrm.project_id == project_id)
81
+ if status:
82
+ stmt = stmt.where(ExperimentOrm.status == status)
83
+ stmt = (
84
+ stmt.options(selectinload(ExperimentOrm.runs))
85
+ .order_by(ExperimentOrm.created_at.desc())
86
+ .limit(limit)
87
+ .offset(offset)
88
+ )
89
+ result = await self.session.execute(stmt)
90
+ return list(result.scalars().all())
91
+
92
+ async def update_status(self, tenant_id: str, experiment_id: str, new_status: str) -> bool:
93
+ stmt = (
94
+ update(ExperimentOrm)
95
+ .where(ExperimentOrm.tenant_id == tenant_id, ExperimentOrm.id == experiment_id)
96
+ .values(status=new_status, updated_at=utc_now())
97
+ )
98
+ result = await self.session.execute(stmt)
99
+ return result.rowcount > 0
100
+
101
+ async def create_run(self, run: ExperimentRunOrm) -> ExperimentRunOrm:
102
+ self.session.add(run)
103
+ await self.session.flush()
104
+ return run
105
+
106
+ async def update_run(
107
+ self,
108
+ run_id: str,
109
+ status: str,
110
+ duration_seconds: float | None = None,
111
+ failure_reason: str | None = None,
112
+ end_time: bool = False,
113
+ ) -> bool:
114
+ values: dict[str, Any] = {"status": status}
115
+ if duration_seconds is not None:
116
+ values["duration_seconds"] = duration_seconds
117
+ if failure_reason is not None:
118
+ values["failure_reason"] = failure_reason
119
+ if end_time:
120
+ values["end_time"] = utc_now()
121
+
122
+ stmt = update(ExperimentRunOrm).where(ExperimentRunOrm.id == run_id).values(**values)
123
+ result = await self.session.execute(stmt)
124
+ return result.rowcount > 0
125
+
126
+ async def get_latest_run(self, experiment_id: str) -> ExperimentRunOrm | None:
127
+ stmt = (
128
+ select(ExperimentRunOrm)
129
+ .where(ExperimentRunOrm.experiment_id == experiment_id)
130
+ .order_by(ExperimentRunOrm.run_number.desc())
131
+ .limit(1)
132
+ )
133
+ result = await self.session.execute(stmt)
134
+ return result.scalar_one_or_none()
@@ -0,0 +1,61 @@
1
+ """Repository for evaluation metrics recording and cross-experiment comparison."""
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from ml_mcp.infrastructure.postgres.models import ExperimentOrm, MetricOrm
7
+
8
+
9
+ class MetricRepository:
10
+ """Tenant-scoped data access for experiment evaluation metrics."""
11
+
12
+ def __init__(self, session: AsyncSession) -> None:
13
+ self.session = session
14
+
15
+ async def record_metrics(self, metrics: list[MetricOrm]) -> list[MetricOrm]:
16
+ self.session.add_all(metrics)
17
+ await self.session.flush()
18
+ return metrics
19
+
20
+ async def get_metrics(
21
+ self,
22
+ tenant_id: str,
23
+ experiment_id: str,
24
+ split: str | None = None,
25
+ ) -> list[MetricOrm]:
26
+ stmt = (
27
+ select(MetricOrm)
28
+ .join(ExperimentOrm, MetricOrm.experiment_id == ExperimentOrm.id)
29
+ .where(ExperimentOrm.tenant_id == tenant_id, MetricOrm.experiment_id == experiment_id)
30
+ )
31
+ if split:
32
+ stmt = stmt.where(MetricOrm.split == split)
33
+ stmt = stmt.order_by(MetricOrm.metric_name)
34
+ result = await self.session.execute(stmt)
35
+ return list(result.scalars().all())
36
+
37
+ async def compare_metrics(
38
+ self,
39
+ tenant_id: str,
40
+ experiment_ids: list[str],
41
+ ) -> dict[str, dict[str, float]]:
42
+ """Return {experiment_id: {metric_name: metric_value}} across authorized experiments."""
43
+ if not experiment_ids:
44
+ return {}
45
+
46
+ stmt = (
47
+ select(MetricOrm)
48
+ .join(ExperimentOrm, MetricOrm.experiment_id == ExperimentOrm.id)
49
+ .where(
50
+ ExperimentOrm.tenant_id == tenant_id,
51
+ MetricOrm.experiment_id.in_(experiment_ids),
52
+ MetricOrm.split == "validation",
53
+ )
54
+ )
55
+ result = await self.session.execute(stmt)
56
+ rows = result.scalars().all()
57
+
58
+ comparison: dict[str, dict[str, float]] = {eid: {} for eid in experiment_ids}
59
+ for row in rows:
60
+ comparison[row.experiment_id][row.metric_name] = row.metric_value
61
+ return comparison
@@ -0,0 +1,61 @@
1
+ """Repository for registered models and model versions."""
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy.orm import selectinload
6
+
7
+ from ml_mcp.infrastructure.postgres.models import ModelOrm, ModelVersionOrm
8
+
9
+
10
+ class ModelRepository:
11
+ """Data access operations for the approved model catalog."""
12
+
13
+ def __init__(self, session: AsyncSession) -> None:
14
+ self.session = session
15
+
16
+ async def get_by_id(self, model_id: str) -> ModelOrm | None:
17
+ stmt = (
18
+ select(ModelOrm).where(ModelOrm.id == model_id).options(selectinload(ModelOrm.versions))
19
+ )
20
+ result = await self.session.execute(stmt)
21
+ return result.scalar_one_or_none()
22
+
23
+ async def list_models(self) -> list[ModelOrm]:
24
+ stmt = select(ModelOrm).options(selectinload(ModelOrm.versions)).order_by(ModelOrm.id)
25
+ result = await self.session.execute(stmt)
26
+ return list(result.scalars().all())
27
+
28
+ async def get_version(self, model_id: str, version: str) -> ModelVersionOrm | None:
29
+ stmt = select(ModelVersionOrm).where(
30
+ ModelVersionOrm.model_id == model_id, ModelVersionOrm.version == version
31
+ )
32
+ result = await self.session.execute(stmt)
33
+ return result.scalar_one_or_none()
34
+
35
+ async def get_version_by_id(self, version_id: str) -> ModelVersionOrm | None:
36
+ stmt = (
37
+ select(ModelVersionOrm)
38
+ .where(ModelVersionOrm.id == version_id)
39
+ .options(selectinload(ModelVersionOrm.model))
40
+ )
41
+ result = await self.session.execute(stmt)
42
+ return result.scalar_one_or_none()
43
+
44
+ async def list_versions(self, model_id: str) -> list[ModelVersionOrm]:
45
+ stmt = (
46
+ select(ModelVersionOrm)
47
+ .where(ModelVersionOrm.model_id == model_id)
48
+ .order_by(ModelVersionOrm.created_at.desc())
49
+ )
50
+ result = await self.session.execute(stmt)
51
+ return list(result.scalars().all())
52
+
53
+ async def create_model(self, model: ModelOrm) -> ModelOrm:
54
+ self.session.add(model)
55
+ await self.session.flush()
56
+ return model
57
+
58
+ async def create_version(self, version: ModelVersionOrm) -> ModelVersionOrm:
59
+ self.session.add(version)
60
+ await self.session.flush()
61
+ return version
@@ -0,0 +1,47 @@
1
+ """Repository for Outbox event lifecycle management."""
2
+
3
+ from sqlalchemy import select, update
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from ml_mcp.infrastructure.postgres.base import utc_now
7
+ from ml_mcp.infrastructure.postgres.models import OutboxEventOrm
8
+
9
+
10
+ class OutboxRepository:
11
+ """Operations for polling and updating transactional outbox events."""
12
+
13
+ def __init__(self, session: AsyncSession) -> None:
14
+ self.session = session
15
+
16
+ async def fetch_pending(self, limit: int = 50) -> list[OutboxEventOrm]:
17
+ """Fetch pending outbox events scheduled for delivery."""
18
+ stmt = (
19
+ select(OutboxEventOrm)
20
+ .where(
21
+ OutboxEventOrm.status == "PENDING",
22
+ OutboxEventOrm.scheduled_at <= utc_now(),
23
+ )
24
+ .order_by(OutboxEventOrm.scheduled_at.asc())
25
+ .limit(limit)
26
+ )
27
+ result = await self.session.execute(stmt)
28
+ return list(result.scalars().all())
29
+
30
+ async def mark_delivered(self, event_id: str) -> bool:
31
+ stmt = (
32
+ update(OutboxEventOrm)
33
+ .where(OutboxEventOrm.id == event_id)
34
+ .values(status="DELIVERED", processed_at=utc_now())
35
+ )
36
+ result = await self.session.execute(stmt)
37
+ return result.rowcount > 0
38
+
39
+ async def mark_failed(self, event_id: str, retry_count: int, max_retries: int = 5) -> bool:
40
+ new_status = "FAILED" if retry_count >= max_retries else "PENDING"
41
+ stmt = (
42
+ update(OutboxEventOrm)
43
+ .where(OutboxEventOrm.id == event_id)
44
+ .values(status=new_status, retry_count=retry_count)
45
+ )
46
+ result = await self.session.execute(stmt)
47
+ return result.rowcount > 0
@@ -0,0 +1,83 @@
1
+ """Repository for project control plane persistence."""
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from ml_mcp.infrastructure.postgres.models import ProjectOrm, TenantOrm
7
+
8
+
9
+ class ProjectRepository:
10
+ """Tenant-scoped data access for projects."""
11
+
12
+ def __init__(self, session: AsyncSession) -> None:
13
+ self.session = session
14
+
15
+ async def create_project(self, project: ProjectOrm) -> ProjectOrm:
16
+ self.session.add(project)
17
+ await self.session.flush()
18
+ return project
19
+
20
+ async def get_project(self, tenant_id: str, project_id: str) -> ProjectOrm | None:
21
+ stmt = select(ProjectOrm).where(
22
+ ProjectOrm.tenant_id == tenant_id,
23
+ ProjectOrm.id == project_id,
24
+ )
25
+ result = await self.session.execute(stmt)
26
+ return result.scalar_one_or_none()
27
+
28
+ async def get_project_by_name(self, tenant_id: str, name: str) -> ProjectOrm | None:
29
+ stmt = select(ProjectOrm).where(
30
+ ProjectOrm.tenant_id == tenant_id,
31
+ ProjectOrm.name == name,
32
+ )
33
+ result = await self.session.execute(stmt)
34
+ return result.scalar_one_or_none()
35
+
36
+ async def list_projects(
37
+ self, tenant_id: str, limit: int = 50, offset: int = 0
38
+ ) -> list[ProjectOrm]:
39
+ stmt = (
40
+ select(ProjectOrm)
41
+ .where(ProjectOrm.tenant_id == tenant_id)
42
+ .order_by(ProjectOrm.created_at.desc())
43
+ .limit(limit)
44
+ .offset(offset)
45
+ )
46
+ result = await self.session.execute(stmt)
47
+ return list(result.scalars().all())
48
+
49
+ async def get_or_create_default_project(
50
+ self, tenant_id: str = "default-tenant"
51
+ ) -> ProjectOrm:
52
+ """Ensure tenant and default project exist, returning the project."""
53
+ # 1. Ensure tenant exists
54
+ tenant_stmt = select(TenantOrm).where(TenantOrm.id == tenant_id)
55
+ tenant_res = await self.session.execute(tenant_stmt)
56
+ tenant = tenant_res.scalar_one_or_none()
57
+ if not tenant:
58
+ tenant = TenantOrm(
59
+ id=tenant_id,
60
+ name="Default Tenant" if tenant_id == "default-tenant" else tenant_id,
61
+ )
62
+ self.session.add(tenant)
63
+ await self.session.flush()
64
+
65
+ # 2. Ensure default-project exists
66
+ proj_id = "default-project" if tenant_id == "default-tenant" else f"{tenant_id}-default-project"
67
+ proj_stmt = select(ProjectOrm).where(
68
+ ProjectOrm.tenant_id == tenant_id,
69
+ (ProjectOrm.id == proj_id) | (ProjectOrm.name == "Default Project"),
70
+ )
71
+ proj_res = await self.session.execute(proj_stmt)
72
+ project = proj_res.scalar_one_or_none()
73
+ if not project:
74
+ project = ProjectOrm(
75
+ id=proj_id,
76
+ tenant_id=tenant_id,
77
+ name="Default Project",
78
+ description="Default ModelLab experimentation project",
79
+ )
80
+ self.session.add(project)
81
+ await self.session.flush()
82
+
83
+ return project
@@ -0,0 +1,117 @@
1
+ """Async database engine, session factory, and lifecycle management."""
2
+
3
+ import logging
4
+ from collections.abc import AsyncGenerator
5
+ from contextlib import asynccontextmanager
6
+ from typing import Any
7
+
8
+ from sqlalchemy import text
9
+ from sqlalchemy.ext.asyncio import (
10
+ AsyncEngine,
11
+ AsyncSession,
12
+ async_sessionmaker,
13
+ create_async_engine,
14
+ )
15
+
16
+ from ml_mcp.config import Settings, get_settings
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class DatabaseManager:
22
+ """Manages async PostgreSQL connection pool and session factories."""
23
+
24
+ def __init__(self, settings: Settings | None = None) -> None:
25
+ self.settings = settings or get_settings()
26
+ self._engine: AsyncEngine | None = None
27
+ self._sessionmaker: async_sessionmaker[AsyncSession] | None = None
28
+
29
+ def initialize(self, custom_url: str | None = None) -> None:
30
+ """Initialize the async engine and sessionmaker."""
31
+ db_url = custom_url or self.settings.database.url
32
+
33
+ engine_kwargs: dict[str, Any] = {
34
+ "echo": self.settings.database.echo,
35
+ "pool_pre_ping": True,
36
+ }
37
+
38
+ # SQLite requires StaticPool for in-memory and does not support pool_size or max_overflow
39
+ if db_url.startswith("sqlite"):
40
+ from sqlalchemy.pool import StaticPool
41
+
42
+ engine_kwargs["poolclass"] = StaticPool
43
+ engine_kwargs["connect_args"] = {"check_same_thread": False}
44
+ else:
45
+ engine_kwargs.update(
46
+ {
47
+ "pool_size": self.settings.database.pool_size,
48
+ "max_overflow": self.settings.database.max_overflow,
49
+ "pool_timeout": self.settings.database.pool_timeout,
50
+ "pool_recycle": self.settings.database.pool_recycle,
51
+ }
52
+ )
53
+
54
+ self._engine = create_async_engine(db_url, **engine_kwargs)
55
+ self._sessionmaker = async_sessionmaker(
56
+ bind=self._engine,
57
+ class_=AsyncSession,
58
+ expire_on_commit=False,
59
+ autoflush=False,
60
+ )
61
+ logger.info(
62
+ "Database engine initialized for %s", db_url.split("@")[-1] if "@" in db_url else db_url
63
+ )
64
+
65
+ @property
66
+ def engine(self) -> AsyncEngine:
67
+ if self._engine is None:
68
+ self.initialize()
69
+ assert self._engine is not None
70
+ return self._engine
71
+
72
+ @property
73
+ def sessionmaker(self) -> async_sessionmaker[AsyncSession]:
74
+ if self._sessionmaker is None:
75
+ self.initialize()
76
+ assert self._sessionmaker is not None
77
+ return self._sessionmaker
78
+
79
+ @asynccontextmanager
80
+ async def session(self) -> AsyncGenerator[AsyncSession]:
81
+ """Provide a transactional async session scope."""
82
+ async with self.sessionmaker() as sess:
83
+ try:
84
+ yield sess
85
+ await sess.commit()
86
+ except Exception:
87
+ await sess.rollback()
88
+ raise
89
+
90
+ async def check_health(self) -> bool:
91
+ """Perform a quick healthcheck query (SELECT 1)."""
92
+ try:
93
+ async with self.session() as sess:
94
+ result = await sess.execute(text("SELECT 1"))
95
+ return result.scalar() == 1
96
+ except Exception as exc:
97
+ logger.error("Database healthcheck failed: %s", exc)
98
+ return False
99
+
100
+ async def close(self) -> None:
101
+ """Dispose the underlying connection pool."""
102
+ if self._engine is not None:
103
+ await self._engine.dispose()
104
+ self._engine = None
105
+ self._sessionmaker = None
106
+ logger.info("Database connection pool disposed")
107
+
108
+
109
+ _db_manager: DatabaseManager | None = None
110
+
111
+
112
+ def get_db_manager() -> DatabaseManager:
113
+ """Return singleton DatabaseManager instance."""
114
+ global _db_manager
115
+ if _db_manager is None:
116
+ _db_manager = DatabaseManager()
117
+ return _db_manager
@@ -0,0 +1,6 @@
1
+ """Queue infrastructure package."""
2
+
3
+ from ml_mcp.infrastructure.queue.outbox_processor import OutboxProcessor
4
+ from ml_mcp.infrastructure.queue.task_queue import RedisTaskQueue, TaskMessage
5
+
6
+ __all__ = ["TaskMessage", "RedisTaskQueue", "OutboxProcessor"]