pulse-engine 0.2.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 (150) hide show
  1. pulse_engine/__init__.py +0 -0
  2. pulse_engine/adapters/__init__.py +58 -0
  3. pulse_engine/adapters/audio_transcription.py +167 -0
  4. pulse_engine/adapters/batcher.py +36 -0
  5. pulse_engine/adapters/digital_news.py +128 -0
  6. pulse_engine/adapters/digital_news_metadata.py +536 -0
  7. pulse_engine/adapters/exceptions.py +10 -0
  8. pulse_engine/adapters/models.py +134 -0
  9. pulse_engine/adapters/opensearch_storage.py +160 -0
  10. pulse_engine/adapters/speech_content.py +130 -0
  11. pulse_engine/adapters/speech_metadata.py +374 -0
  12. pulse_engine/adapters/twitter.py +423 -0
  13. pulse_engine/adapters/youtube_downloader.py +186 -0
  14. pulse_engine/adapters/youtube_metadata.py +261 -0
  15. pulse_engine/api/__init__.py +0 -0
  16. pulse_engine/api/v1/__init__.py +0 -0
  17. pulse_engine/api/v1/auth.py +91 -0
  18. pulse_engine/api/v1/health.py +62 -0
  19. pulse_engine/api/v1/router.py +16 -0
  20. pulse_engine/chain_recovery.py +131 -0
  21. pulse_engine/cli/__init__.py +0 -0
  22. pulse_engine/cli/main.py +169 -0
  23. pulse_engine/cli/templates/cookiecutter.json +4 -0
  24. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/.gitignore +13 -0
  25. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/Dockerfile +32 -0
  26. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pipeline.yaml +17 -0
  27. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pyproject.toml +25 -0
  28. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/src/pulse_{{cookiecutter.product_slug}}/__init__.py +8 -0
  29. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/__init__.py +0 -0
  30. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/__init__.py +0 -0
  31. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/test_manifest.py +15 -0
  32. pulse_engine/client.py +95 -0
  33. pulse_engine/config.py +157 -0
  34. pulse_engine/core/__init__.py +0 -0
  35. pulse_engine/core/error_handlers.py +64 -0
  36. pulse_engine/core/exceptions.py +67 -0
  37. pulse_engine/core/job_token.py +109 -0
  38. pulse_engine/core/logging.py +45 -0
  39. pulse_engine/core/scope.py +23 -0
  40. pulse_engine/core/security.py +130 -0
  41. pulse_engine/database.py +30 -0
  42. pulse_engine/dependencies.py +166 -0
  43. pulse_engine/deployment/__init__.py +0 -0
  44. pulse_engine/deployment/backend_deployment_repository.py +83 -0
  45. pulse_engine/deployment/backends/__init__.py +0 -0
  46. pulse_engine/deployment/backends/base.py +50 -0
  47. pulse_engine/deployment/backends/exceptions.py +20 -0
  48. pulse_engine/deployment/backends/native_lambda.py +125 -0
  49. pulse_engine/deployment/backends/prefect_ecs.py +116 -0
  50. pulse_engine/deployment/backends/prefect_k8s.py +131 -0
  51. pulse_engine/deployment/backends/registry.py +50 -0
  52. pulse_engine/deployment/infra_provisioner.py +285 -0
  53. pulse_engine/deployment/job_launcher.py +178 -0
  54. pulse_engine/deployment/models.py +48 -0
  55. pulse_engine/deployment/repository.py +54 -0
  56. pulse_engine/deployment/router.py +22 -0
  57. pulse_engine/deployment/schemas.py +18 -0
  58. pulse_engine/deployment/service.py +65 -0
  59. pulse_engine/extractor/__init__.py +0 -0
  60. pulse_engine/extractor/adapters/__init__.py +0 -0
  61. pulse_engine/extractor/base.py +48 -0
  62. pulse_engine/extractor/models.py +50 -0
  63. pulse_engine/extractor/orchestrator/__init__.py +15 -0
  64. pulse_engine/extractor/orchestrator/base.py +34 -0
  65. pulse_engine/extractor/orchestrator/noop.py +37 -0
  66. pulse_engine/extractor/orchestrator/prefect.py +163 -0
  67. pulse_engine/extractor/repository.py +163 -0
  68. pulse_engine/extractor/router.py +102 -0
  69. pulse_engine/extractor/schemas.py +93 -0
  70. pulse_engine/extractor/service.py +431 -0
  71. pulse_engine/extractor/stage_models.py +36 -0
  72. pulse_engine/extractor/stage_repository.py +109 -0
  73. pulse_engine/main.py +195 -0
  74. pulse_engine/mcp/__init__.py +0 -0
  75. pulse_engine/mcp/__main__.py +5 -0
  76. pulse_engine/mcp/server.py +108 -0
  77. pulse_engine/mcp/tools_jobs.py +159 -0
  78. pulse_engine/mcp/tools_kb.py +88 -0
  79. pulse_engine/mcp/tools_modules.py +115 -0
  80. pulse_engine/mcp/tools_pipelines.py +215 -0
  81. pulse_engine/mcp/tools_processor.py +208 -0
  82. pulse_engine/middleware/__init__.py +0 -0
  83. pulse_engine/middleware/rate_limit.py +144 -0
  84. pulse_engine/middleware/request_id.py +16 -0
  85. pulse_engine/middleware/security_headers.py +25 -0
  86. pulse_engine/middleware/tenant.py +90 -0
  87. pulse_engine/pipeline/__init__.py +0 -0
  88. pulse_engine/pipeline/config_parser.py +148 -0
  89. pulse_engine/pipeline/expression.py +268 -0
  90. pulse_engine/pipeline/models.py +98 -0
  91. pulse_engine/pipeline/repositories.py +224 -0
  92. pulse_engine/pipeline/router_modules.py +66 -0
  93. pulse_engine/pipeline/router_pipelines.py +198 -0
  94. pulse_engine/pipeline/schemas.py +200 -0
  95. pulse_engine/pipeline/service.py +250 -0
  96. pulse_engine/pipeline/translators/__init__.py +44 -0
  97. pulse_engine/pipeline/translators/airflow_status.py +11 -0
  98. pulse_engine/pipeline/translators/airflow_translator.py +22 -0
  99. pulse_engine/pipeline/translators/base.py +42 -0
  100. pulse_engine/pipeline/translators/prefect_status.py +93 -0
  101. pulse_engine/pipeline/translators/prefect_translator.py +195 -0
  102. pulse_engine/processor/__init__.py +0 -0
  103. pulse_engine/processor/base.py +36 -0
  104. pulse_engine/processor/core/__init__.py +0 -0
  105. pulse_engine/processor/core/analysis.py +148 -0
  106. pulse_engine/processor/core/chunking.py +158 -0
  107. pulse_engine/processor/core/prompts.py +340 -0
  108. pulse_engine/processor/core/topic_splitter.py +105 -0
  109. pulse_engine/processor/defaults/__init__.py +11 -0
  110. pulse_engine/processor/defaults/core_processor.py +12 -0
  111. pulse_engine/processor/defaults/postprocessor.py +12 -0
  112. pulse_engine/processor/defaults/preprocessor.py +12 -0
  113. pulse_engine/processor/llm/__init__.py +0 -0
  114. pulse_engine/processor/llm/provider.py +58 -0
  115. pulse_engine/processor/ocr/gemini.py +52 -0
  116. pulse_engine/processor/pipeline.py +107 -0
  117. pulse_engine/processor/postprocessor/__init__.py +0 -0
  118. pulse_engine/processor/postprocessor/embeddings.py +34 -0
  119. pulse_engine/processor/postprocessor/tasks.py +180 -0
  120. pulse_engine/processor/preprocessor/__init__.py +0 -0
  121. pulse_engine/processor/preprocessor/tasks.py +71 -0
  122. pulse_engine/processor/router.py +192 -0
  123. pulse_engine/processor/schemas.py +167 -0
  124. pulse_engine/registry.py +117 -0
  125. pulse_engine/runners/__init__.py +0 -0
  126. pulse_engine/runners/lambda_runner.py +26 -0
  127. pulse_engine/runners/pipeline_runner.py +43 -0
  128. pulse_engine/runners/prefect_pipeline_flow.py +904 -0
  129. pulse_engine/runners/prefect_runner.py +33 -0
  130. pulse_engine/s3.py +72 -0
  131. pulse_engine/secrets.py +46 -0
  132. pulse_engine/services/__init__.py +0 -0
  133. pulse_engine/services/bootstrap.py +211 -0
  134. pulse_engine/services/opensearch.py +84 -0
  135. pulse_engine/storage/__init__.py +0 -0
  136. pulse_engine/storage/connectors/__init__.py +0 -0
  137. pulse_engine/storage/connectors/athena.py +226 -0
  138. pulse_engine/storage/connectors/base.py +32 -0
  139. pulse_engine/storage/connectors/opensearch.py +344 -0
  140. pulse_engine/storage/knowledge_base.py +68 -0
  141. pulse_engine/storage/router.py +78 -0
  142. pulse_engine/storage/schemas.py +93 -0
  143. pulse_engine/testing/__init__.py +13 -0
  144. pulse_engine/testing/fixtures.py +50 -0
  145. pulse_engine/testing/mocks.py +104 -0
  146. pulse_engine/worker.py +53 -0
  147. pulse_engine-0.2.0.dist-info/METADATA +654 -0
  148. pulse_engine-0.2.0.dist-info/RECORD +150 -0
  149. pulse_engine-0.2.0.dist-info/WHEEL +4 -0
  150. pulse_engine-0.2.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,125 @@
1
+ """Native Lambda runner backend — invokes Lambda directly via boto3.
2
+
3
+ When InfraProvisioner is configured, Lambda functions are auto-provisioned
4
+ from container images at registration time. Otherwise falls back to
5
+ expecting pre-provisioned functions (backward compatible).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import uuid
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import boto3
15
+ import structlog
16
+
17
+ from pulse_engine.deployment.backends.base import BaseRunnerBackend
18
+ from pulse_engine.extractor.orchestrator.base import OrchestratorRunStatus
19
+
20
+ if TYPE_CHECKING:
21
+ from pulse_engine.config import Settings
22
+
23
+ logger = structlog.get_logger(__name__)
24
+
25
+
26
+ class NativeLambdaBackend(BaseRunnerBackend):
27
+ """Invokes Lambda functions directly via boto3 (no Prefect involved).
28
+
29
+ Lambda functions follow the naming convention: {product}-{stage}.
30
+ If pipeline infrastructure settings are configured, functions are
31
+ auto-provisioned from container images. Otherwise they must be
32
+ pre-provisioned by Terraform.
33
+ """
34
+
35
+ def __init__(self, settings: Settings) -> None:
36
+ self._settings = settings
37
+ self._region = settings.aws_region
38
+ self._provisioner: Any = None
39
+
40
+ def _get_provisioner(self) -> Any:
41
+ if self._provisioner is not None:
42
+ return self._provisioner
43
+ role_arn = getattr(self._settings, "lambda_execution_role_arn", "")
44
+ if not isinstance(role_arn, str) or not role_arn:
45
+ return None
46
+ try:
47
+ from pulse_engine.deployment.infra_provisioner import InfraProvisioner
48
+
49
+ self._provisioner = InfraProvisioner(
50
+ region=self._settings.aws_region,
51
+ pipeline_cluster_name=self._settings.pipeline_cluster_name,
52
+ pipeline_execution_role_arn=self._settings.pipeline_execution_role_arn,
53
+ pipeline_task_role_arn=self._settings.pipeline_task_role_arn,
54
+ pipeline_log_group=self._settings.pipeline_log_group,
55
+ pipeline_subnets=self._settings.pipeline_subnet_list,
56
+ pipeline_security_groups=self._settings.pipeline_sg_list,
57
+ lambda_execution_role_arn=self._settings.lambda_execution_role_arn,
58
+ lambda_subnets=self._settings.lambda_subnet_list,
59
+ lambda_security_groups=self._settings.lambda_sg_list,
60
+ lambda_log_group=self._settings.lambda_log_group,
61
+ )
62
+ return self._provisioner
63
+ except Exception:
64
+ logger.warning("Failed to create InfraProvisioner", exc_info=True)
65
+ return None
66
+
67
+ async def prepare(self) -> None:
68
+ pass
69
+
70
+ async def register(
71
+ self,
72
+ product: str,
73
+ stage: str,
74
+ image: str,
75
+ entrypoint: str | None = None,
76
+ ) -> str:
77
+ func_name = f"{product}-{stage}"
78
+
79
+ # Auto-provision Lambda function from container image
80
+ provisioner = self._get_provisioner()
81
+ if provisioner and image:
82
+ provisioner.ensure_lambda_function(func_name, image)
83
+ logger.info(
84
+ "lambda_auto_provisioned",
85
+ function=func_name,
86
+ image=image,
87
+ )
88
+
89
+ return func_name
90
+
91
+ async def trigger(self, handle: str, parameters: dict[str, Any]) -> str:
92
+ from botocore.exceptions import ClientError
93
+
94
+ from pulse_engine.core.exceptions import NotFoundError
95
+
96
+ run_id = str(uuid.uuid4())
97
+ payload = {**parameters, "run_id": run_id}
98
+ client = boto3.client("lambda", region_name=self._region)
99
+ try:
100
+ client.invoke(
101
+ FunctionName=handle,
102
+ InvocationType="Event", # async fire-and-forget
103
+ Payload=json.dumps(payload),
104
+ )
105
+ except ClientError as exc:
106
+ code = exc.response["Error"]["Code"]
107
+ if code == "ResourceNotFoundException":
108
+ raise NotFoundError(
109
+ f"Lambda function not found: {handle}. "
110
+ "Ensure pipeline infrastructure settings are configured "
111
+ "or the function is deployed manually.",
112
+ function=handle,
113
+ ) from exc
114
+ raise
115
+ logger.info("lambda_triggered", function=handle, run_id=run_id)
116
+ return run_id
117
+
118
+ async def get_run_status(self, run_id: str) -> OrchestratorRunStatus:
119
+ # Status arrives via Lambda callback to POST /jobs/{id}/status
120
+ # The engine's job record is the source of truth — not Lambda
121
+ return OrchestratorRunStatus(run_id=run_id, status="unknown")
122
+
123
+ async def cancel_run(self, run_id: str) -> bool:
124
+ # Async Lambda invocations cannot be cancelled post-fire
125
+ return False
@@ -0,0 +1,116 @@
1
+ """Prefect + ECS runner backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import httpx
9
+ import structlog
10
+
11
+ from pulse_engine.deployment.backends.base import BaseRunnerBackend
12
+ from pulse_engine.extractor.orchestrator.base import OrchestratorRunStatus
13
+
14
+ if TYPE_CHECKING:
15
+ from pulse_engine.config import Settings
16
+
17
+ logger = structlog.get_logger(__name__)
18
+
19
+ _PREFECT_STATE_MAP: dict[str, str] = {
20
+ "COMPLETED": "completed",
21
+ "FAILED": "failed",
22
+ "CRASHED": "failed",
23
+ "CANCELLED": "cancelled",
24
+ "CANCELLING": "cancelled",
25
+ "RUNNING": "running",
26
+ "PENDING": "pending",
27
+ "SCHEDULED": "pending",
28
+ }
29
+
30
+
31
+ class PrefectECSBackend(BaseRunnerBackend):
32
+ """Runs Prefect flow runs on an ECS work pool."""
33
+
34
+ def __init__(self, settings: Settings) -> None:
35
+ self._work_pool_name = settings.prefect_ecs_work_pool_name
36
+ headers: dict[str, str] = {"Content-Type": "application/json"}
37
+ if settings.prefect_api_key:
38
+ encoded = base64.b64encode(settings.prefect_api_key.encode()).decode()
39
+ headers["Authorization"] = f"Basic {encoded}"
40
+ self._client = httpx.AsyncClient(
41
+ base_url=settings.prefect_api_url.rstrip("/"),
42
+ headers=headers,
43
+ timeout=10.0,
44
+ )
45
+
46
+ async def prepare(self) -> None:
47
+ resp = await self._client.get(f"/work_pools/{self._work_pool_name}")
48
+ if resp.is_success:
49
+ return
50
+ payload: dict[str, Any] = {
51
+ "name": self._work_pool_name,
52
+ "type": "ecs",
53
+ }
54
+ resp = await self._client.post("/work_pools/", json=payload)
55
+ resp.raise_for_status()
56
+
57
+ async def register(
58
+ self,
59
+ product: str,
60
+ stage: str,
61
+ image: str,
62
+ entrypoint: str | None = None,
63
+ ) -> str:
64
+ name = f"{product}-{stage}"
65
+ flow_id = await self._get_or_create_flow_id(name)
66
+ payload: dict[str, Any] = {
67
+ "name": name,
68
+ "flow_id": flow_id,
69
+ "entrypoint": entrypoint,
70
+ "work_pool_name": self._work_pool_name,
71
+ "job_variables": {"image": image},
72
+ }
73
+ resp = await self._client.post("/deployments/", json=payload)
74
+ resp.raise_for_status()
75
+ data: dict[str, Any] = resp.json()
76
+ return str(data["id"])
77
+
78
+ async def trigger(self, handle: str, parameters: dict[str, Any]) -> str:
79
+ resp = await self._client.post(
80
+ f"/deployments/{handle}/create_flow_run",
81
+ json={"parameters": parameters},
82
+ )
83
+ resp.raise_for_status()
84
+ data: dict[str, Any] = resp.json()
85
+ return str(data["id"])
86
+
87
+ async def get_run_status(self, run_id: str) -> OrchestratorRunStatus:
88
+ try:
89
+ resp = await self._client.get(f"/flow_runs/{run_id}")
90
+ resp.raise_for_status()
91
+ data = resp.json()
92
+ raw_state = data.get("state", {}).get("type", "UNKNOWN")
93
+ canonical = _PREFECT_STATE_MAP.get(raw_state.upper(), "unknown")
94
+ return OrchestratorRunStatus(
95
+ run_id=run_id, status=canonical, raw_state=raw_state
96
+ )
97
+ except Exception:
98
+ logger.warning("ecs_backend_status_failed", run_id=run_id, exc_info=True)
99
+ return OrchestratorRunStatus(run_id=run_id, status="unknown")
100
+
101
+ async def cancel_run(self, run_id: str) -> bool:
102
+ try:
103
+ resp = await self._client.post(
104
+ f"/flow_runs/{run_id}/set_state",
105
+ json={"state": {"type": "CANCELLED"}},
106
+ )
107
+ return resp.is_success
108
+ except Exception:
109
+ logger.warning("ecs_backend_cancel_failed", run_id=run_id, exc_info=True)
110
+ return False
111
+
112
+ async def _get_or_create_flow_id(self, flow_name: str) -> str:
113
+ resp = await self._client.post("/flows/", json={"name": flow_name})
114
+ resp.raise_for_status()
115
+ data: dict[str, Any] = resp.json()
116
+ return str(data["id"])
@@ -0,0 +1,131 @@
1
+ """Prefect + Kubernetes runner backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import httpx
9
+ import structlog
10
+
11
+ from pulse_engine.deployment.backends.base import BaseRunnerBackend
12
+ from pulse_engine.deployment.backends.prefect_ecs import _PREFECT_STATE_MAP
13
+ from pulse_engine.extractor.orchestrator.base import OrchestratorRunStatus
14
+
15
+ if TYPE_CHECKING:
16
+ from pulse_engine.config import Settings
17
+
18
+ logger = structlog.get_logger(__name__)
19
+
20
+
21
+ class PrefectK8sBackend(BaseRunnerBackend):
22
+ """Runs Prefect flow runs on a Kubernetes work pool.
23
+
24
+ A Prefect Kubernetes worker runs inside the cluster (deployed via Terraform/Helm).
25
+ S3 access uses IRSA — no credentials are injected here.
26
+ CPU/memory defaults come from settings; overrides can be passed in
27
+ trigger parameters via config["cpu"] and config["memory"].
28
+ """
29
+
30
+ def __init__(self, settings: Settings) -> None:
31
+ self._work_pool_name = settings.prefect_k8s_work_pool_name
32
+ self._namespace = settings.prefect_k8s_namespace
33
+ self._default_cpu = settings.prefect_k8s_default_cpu
34
+ self._default_memory = settings.prefect_k8s_default_memory
35
+ headers: dict[str, str] = {"Content-Type": "application/json"}
36
+ if settings.prefect_api_key:
37
+ encoded = base64.b64encode(settings.prefect_api_key.encode()).decode()
38
+ headers["Authorization"] = f"Basic {encoded}"
39
+ self._client = httpx.AsyncClient(
40
+ base_url=settings.prefect_api_url.rstrip("/"),
41
+ headers=headers,
42
+ timeout=10.0,
43
+ )
44
+
45
+ async def prepare(self) -> None:
46
+ resp = await self._client.get(f"/work_pools/{self._work_pool_name}")
47
+ if resp.is_success:
48
+ return
49
+ payload: dict[str, Any] = {
50
+ "name": self._work_pool_name,
51
+ "type": "kubernetes",
52
+ }
53
+ resp = await self._client.post("/work_pools/", json=payload)
54
+ resp.raise_for_status()
55
+
56
+ async def register(
57
+ self,
58
+ product: str,
59
+ stage: str,
60
+ image: str,
61
+ entrypoint: str | None = None,
62
+ ) -> str:
63
+ name = f"{product}-{stage}"
64
+ flow_id = await self._get_or_create_flow_id(name)
65
+ payload: dict[str, Any] = {
66
+ "name": name,
67
+ "flow_id": flow_id,
68
+ "entrypoint": entrypoint,
69
+ "work_pool_name": self._work_pool_name,
70
+ "job_variables": {
71
+ "image": image,
72
+ "namespace": self._namespace,
73
+ "cpu": self._default_cpu,
74
+ "memory": self._default_memory,
75
+ },
76
+ }
77
+ resp = await self._client.post("/deployments/", json=payload)
78
+ resp.raise_for_status()
79
+ dep_data: dict[str, Any] = resp.json()
80
+ return str(dep_data["id"])
81
+
82
+ async def trigger(self, handle: str, parameters: dict[str, Any]) -> str:
83
+ config: dict[str, Any] = parameters.get("config", {})
84
+ job_variables: dict[str, Any] = {}
85
+ if "cpu" in config:
86
+ job_variables["cpu"] = config["cpu"]
87
+ if "memory" in config:
88
+ job_variables["memory"] = config["memory"]
89
+
90
+ payload: dict[str, Any] = {"parameters": parameters}
91
+ if job_variables:
92
+ payload["job_variables"] = job_variables
93
+
94
+ resp = await self._client.post(
95
+ f"/deployments/{handle}/create_flow_run",
96
+ json=payload,
97
+ )
98
+ resp.raise_for_status()
99
+ run_data: dict[str, Any] = resp.json()
100
+ return str(run_data["id"])
101
+
102
+ async def get_run_status(self, run_id: str) -> OrchestratorRunStatus:
103
+ try:
104
+ resp = await self._client.get(f"/flow_runs/{run_id}")
105
+ resp.raise_for_status()
106
+ data = resp.json()
107
+ raw_state = data.get("state", {}).get("type", "UNKNOWN")
108
+ canonical = _PREFECT_STATE_MAP.get(raw_state.upper(), "unknown")
109
+ return OrchestratorRunStatus(
110
+ run_id=run_id, status=canonical, raw_state=raw_state
111
+ )
112
+ except Exception:
113
+ logger.warning("k8s_backend_status_failed", run_id=run_id, exc_info=True)
114
+ return OrchestratorRunStatus(run_id=run_id, status="unknown")
115
+
116
+ async def cancel_run(self, run_id: str) -> bool:
117
+ try:
118
+ resp = await self._client.post(
119
+ f"/flow_runs/{run_id}/set_state",
120
+ json={"state": {"type": "CANCELLED"}},
121
+ )
122
+ return resp.is_success
123
+ except Exception:
124
+ logger.warning("k8s_backend_cancel_failed", run_id=run_id, exc_info=True)
125
+ return False
126
+
127
+ async def _get_or_create_flow_id(self, flow_name: str) -> str:
128
+ resp = await self._client.post("/flows/", json={"name": flow_name})
129
+ resp.raise_for_status()
130
+ flow_data: dict[str, Any] = resp.json()
131
+ return str(flow_data["id"])
@@ -0,0 +1,50 @@
1
+ """Registry mapping (orchestrator, compute) pairs to BaseRunnerBackend instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pulse_engine.deployment.backends.base import BaseRunnerBackend
9
+ from pulse_engine.deployment.backends.exceptions import BackendNotAvailableError
10
+
11
+ if TYPE_CHECKING:
12
+ from pulse_engine.config import Settings
13
+
14
+
15
+ class BackendRegistry:
16
+ """Lazy-initializing registry. Backends are instantiated on first get()."""
17
+
18
+ def __init__(self, settings: Settings) -> None:
19
+ self._settings = settings
20
+ self._instances: dict[tuple[str, str], BaseRunnerBackend] = {}
21
+
22
+ def get(self, orchestrator: str, compute: str) -> BaseRunnerBackend:
23
+ key = (orchestrator, compute)
24
+ if key not in self._instances:
25
+ self._instances[key] = self._create(orchestrator, compute)
26
+ return self._instances[key]
27
+
28
+ def available_backends(self) -> list[tuple[str, str]]:
29
+ return [
30
+ ("prefect", "ecs"),
31
+ ("prefect", "kubernetes"),
32
+ ("native", "lambda"),
33
+ ]
34
+
35
+ def _create(self, orchestrator: str, compute: str) -> BaseRunnerBackend:
36
+ from pulse_engine.deployment.backends.native_lambda import NativeLambdaBackend
37
+ from pulse_engine.deployment.backends.prefect_ecs import PrefectECSBackend
38
+ from pulse_engine.deployment.backends.prefect_k8s import PrefectK8sBackend
39
+
40
+ # IMPORTANT: use lambdas so only the matched backend is instantiated.
41
+ # A plain dict literal would call all three constructors on every _create().
42
+ factories: dict[tuple[str, str], Callable[[], BaseRunnerBackend]] = {
43
+ ("prefect", "ecs"): lambda: PrefectECSBackend(self._settings),
44
+ ("prefect", "kubernetes"): lambda: PrefectK8sBackend(self._settings),
45
+ ("native", "lambda"): lambda: NativeLambdaBackend(self._settings),
46
+ }
47
+ if (orchestrator, compute) not in factories:
48
+ available = [f"{o}×{c}" for o, c in self.available_backends()]
49
+ raise BackendNotAvailableError(orchestrator, compute, available)
50
+ return factories[(orchestrator, compute)]()