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,148 @@
1
+ from __future__ import annotations
2
+
3
+ import yaml # type: ignore[import-untyped]
4
+ from pydantic import ValidationError
5
+
6
+ from pulse_engine.pipeline.expression import parse_expression
7
+ from pulse_engine.pipeline.schemas import PipelineConfig
8
+
9
+
10
+ class PipelineConfigError(Exception):
11
+ """Raised when pipeline config is invalid."""
12
+
13
+ def __init__(self, errors: list[str]) -> None:
14
+ self.errors = errors
15
+ super().__init__(f"Pipeline config validation failed: {'; '.join(errors)}")
16
+
17
+
18
+ def parse_pipeline_config(yaml_content: str) -> PipelineConfig:
19
+ """Parse YAML string into validated PipelineConfig."""
20
+ try:
21
+ raw = yaml.safe_load(yaml_content)
22
+ except yaml.YAMLError as e:
23
+ raise PipelineConfigError([f"YAML parse error: {e}"]) from e
24
+
25
+ if not isinstance(raw, dict):
26
+ raise PipelineConfigError(["YAML content must be a mapping"])
27
+
28
+ try:
29
+ config = PipelineConfig(**raw)
30
+ except ValidationError as e:
31
+ errors = [f"{err['loc']}: {err['msg']}" for err in e.errors()]
32
+ raise PipelineConfigError(errors) from e
33
+
34
+ validate_dag(config)
35
+ return config
36
+
37
+
38
+ def validate_dag(config: PipelineConfig) -> None:
39
+ """Validate DAG structure for v2 pipelines."""
40
+ errors: list[str] = []
41
+ module_names = {m.name for m in config.modules}
42
+ module_map = {m.name: m for m in config.modules}
43
+ step_names: set[str] = set()
44
+
45
+ # Pass 1: duplicate steps + module reference check
46
+ for s in config.dag:
47
+ if s.step in step_names:
48
+ errors.append(f"Duplicate step: '{s.step}'")
49
+ step_names.add(s.step)
50
+
51
+ if s.module not in module_names:
52
+ errors.append(f"Step '{s.step}' references unknown module '{s.module}'")
53
+
54
+ # Pass 2: expression + collect_from validation
55
+ for s in config.dag:
56
+ # Validate collect_from references
57
+ if s.collect_from is not None:
58
+ refs = (
59
+ [s.collect_from]
60
+ if isinstance(s.collect_from, str)
61
+ else list(s.collect_from)
62
+ )
63
+ for ref in refs:
64
+ if ref not in step_names:
65
+ errors.append(
66
+ f"Step '{s.step}' collect_from references unknown step '{ref}'"
67
+ )
68
+
69
+ # Validate for_each expression
70
+ if s.for_each is not None:
71
+ module = module_map.get(s.module)
72
+ try:
73
+ expr = parse_expression(s.for_each)
74
+
75
+ if expr.source == "args":
76
+ # $args.X requires module to have arg X
77
+ if module is not None and expr.field not in module.args:
78
+ errors.append(
79
+ f"Step '{s.step}' for_each '{s.for_each}' but "
80
+ f"module has no arg '{expr.field}'"
81
+ )
82
+ # $args fan-out cannot be combined with collect_from
83
+ if s.collect_from is not None:
84
+ errors.append(
85
+ f"Step '{s.step}' cannot combine '$args' fan-out "
86
+ f"with collect_from"
87
+ )
88
+
89
+ elif expr.source == "steps":
90
+ if expr.step not in step_names:
91
+ errors.append(
92
+ f"Step '{s.step}' for_each references unknown step "
93
+ f"'{expr.step}'"
94
+ )
95
+
96
+ elif expr.source == "collect":
97
+ if s.collect_from is None:
98
+ errors.append(
99
+ f"Step '{s.step}' uses '$collect.{expr.field}' "
100
+ f"but has no collect_from"
101
+ )
102
+
103
+ except ValueError as exc:
104
+ errors.append(f"Step '{s.step}' has invalid for_each expression: {exc}")
105
+
106
+ # Cycle detection via Kahn's algorithm
107
+ # Edges: collect_from deps + for_each $steps.S.* deps
108
+ in_degree: dict[str, int] = {s.step: 0 for s in config.dag}
109
+ adjacency: dict[str, list[str]] = {s.step: [] for s in config.dag}
110
+
111
+ for s in config.dag:
112
+ deps: set[str] = set()
113
+
114
+ if s.collect_from is not None:
115
+ refs = (
116
+ [s.collect_from]
117
+ if isinstance(s.collect_from, str)
118
+ else list(s.collect_from)
119
+ )
120
+ deps.update(r for r in refs if r in step_names)
121
+
122
+ if s.for_each is not None:
123
+ try:
124
+ expr = parse_expression(s.for_each)
125
+ if expr.source == "steps" and expr.step and expr.step in step_names:
126
+ deps.add(expr.step)
127
+ except ValueError:
128
+ pass
129
+
130
+ for dep in deps:
131
+ adjacency[dep].append(s.step)
132
+ in_degree[s.step] += 1
133
+
134
+ queue = [name for name, deg in in_degree.items() if deg == 0]
135
+ visited = 0
136
+ while queue:
137
+ node = queue.pop(0)
138
+ visited += 1
139
+ for neighbor in adjacency[node]:
140
+ in_degree[neighbor] -= 1
141
+ if in_degree[neighbor] == 0:
142
+ queue.append(neighbor)
143
+
144
+ if visited != len(config.dag):
145
+ errors.append("Circular dependency detected in DAG")
146
+
147
+ if errors:
148
+ raise PipelineConfigError(errors)
@@ -0,0 +1,268 @@
1
+ """Pure expression parser and resolver for v2 pipeline for_each expressions.
2
+
3
+ No Prefect, no AWS. All functions are synchronous and unit-testable in isolation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+
14
+ @dataclass
15
+ class ParsedExpression:
16
+ source: str # "args", "steps", or "collect"
17
+ step: str | None # for source="steps": the referenced step name
18
+ # for "args": arg key; for "steps"/"collect": optional output field
19
+ field: str | None
20
+
21
+
22
+ def parse_expression(expr: str) -> ParsedExpression:
23
+ """Parse a for_each expression string.
24
+
25
+ Valid forms:
26
+ $args.FIELD
27
+ $steps.STEP_NAME.output
28
+ $steps.STEP_NAME.output.FIELD
29
+ $collect.FIELD
30
+
31
+ Step names may contain hyphens (e.g. $steps.batch-yt.output.batches).
32
+ """
33
+ m = re.match(r"^\$args\.(\w+)$", expr)
34
+ if m:
35
+ return ParsedExpression(source="args", step=None, field=m.group(1))
36
+
37
+ m = re.match(r"^\$steps\.([\w-]+)\.output(?:\.(\w+))?$", expr)
38
+ if m:
39
+ return ParsedExpression(source="steps", step=m.group(1), field=m.group(2))
40
+
41
+ m = re.match(r"^\$collect\.(\w+)$", expr)
42
+ if m:
43
+ return ParsedExpression(source="collect", step=None, field=m.group(1))
44
+
45
+ raise ValueError(f"Invalid for_each expression: {expr!r}")
46
+
47
+
48
+ def resolve_for_each_items(
49
+ expr: ParsedExpression,
50
+ module_args: dict[str, Any],
51
+ step_results: dict[str, Any],
52
+ collect_from: list[str] | None,
53
+ ) -> list[Any]:
54
+ """Return the list of items to fan out over for the given expression.
55
+
56
+ Raises ValueError if an $args source is not a list.
57
+ Returns [] for missing/empty sources rather than raising.
58
+ """
59
+ if expr.source == "args":
60
+ assert expr.field is not None # guaranteed by parse_expression for "args"
61
+ items = module_args.get(expr.field, [])
62
+ if not isinstance(items, list):
63
+ raise ValueError(
64
+ f"module.args['{expr.field}'] must be a list for fan-out, "
65
+ f"got {type(items).__name__}"
66
+ )
67
+ return items
68
+
69
+ if expr.source == "steps":
70
+ assert expr.step is not None # guaranteed by parse_expression for "steps"
71
+ output = step_results.get(expr.step)
72
+ if output is None:
73
+ return []
74
+ if expr.field:
75
+ return list(output.get(expr.field, [])) if isinstance(output, dict) else []
76
+ return output if isinstance(output, list) else [output]
77
+
78
+ if expr.source == "collect":
79
+ field = expr.field
80
+ assert field is not None # guaranteed by parse_expression for "collect"
81
+ sources = collect_from or []
82
+ collected: list[Any] = []
83
+ for step_name in sources:
84
+ result = step_results.get(step_name)
85
+ if result is None:
86
+ continue
87
+ if isinstance(result, list):
88
+ for r in result:
89
+ if isinstance(r, dict):
90
+ collected.extend(r.get(field, []))
91
+ elif isinstance(result, dict):
92
+ collected.extend(result.get(field, []))
93
+ return collected
94
+
95
+ return []
96
+
97
+
98
+ def build_task_args(
99
+ expr: ParsedExpression,
100
+ module_args: dict[str, Any],
101
+ item: Any,
102
+ ) -> tuple[dict[str, Any], Any]:
103
+ """Build (task_args, inputs) for a single fan-out task.
104
+
105
+ task_args is always module_args unchanged — args stays static across all shards.
106
+ inputs is the fan-out item — the runtime value this shard processes.
107
+ """
108
+ return dict(module_args), item
109
+
110
+
111
+ def _unwrap_item(item: Any) -> list[Any]:
112
+ """Unwrap a step output item into a flat list of data items.
113
+
114
+ If item is a dict with exactly ONE key and that value is a list, return
115
+ that list — this handles the common pattern where a module returns
116
+ {"videos": [...]} or {"speeches": [...]} and the caller wants the inner list.
117
+
118
+ Dicts with multiple keys are never unwrapped to avoid accidentally extracting
119
+ a list field (e.g. "segments") from a rich data object like a video item.
120
+ """
121
+ if isinstance(item, dict) and len(item) == 1:
122
+ (val,) = item.values()
123
+ if isinstance(val, list):
124
+ return val
125
+ return [item]
126
+
127
+
128
+ def collect_inputs(
129
+ collect_from: list[str],
130
+ step_results: dict[str, Any],
131
+ ) -> list[Any]:
132
+ """Flatten outputs from multiple steps into a single list for inputs (collect_from).
133
+
134
+ Each collected item is unwrapped via _unwrap_item so that modules returning
135
+ {"videos": [...]} or {"speeches": [...]} contribute their inner list items
136
+ rather than the wrapper dict.
137
+ """
138
+ result: list[Any] = []
139
+ for step_name in collect_from:
140
+ val = step_results.get(step_name)
141
+ if val is None:
142
+ continue
143
+ items = val if isinstance(val, list) else [val]
144
+ for item in items:
145
+ result.extend(_unwrap_item(item))
146
+ return result
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # when: expression evaluator
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def _resolve_when_references(
155
+ expr: str,
156
+ step_results: dict[str, Any],
157
+ step_statuses: dict[str, str],
158
+ item: Any,
159
+ ) -> str:
160
+ """Replace $steps.* and $item.* with repr() of their resolved values."""
161
+
162
+ def _steps_sub(m: re.Match[str]) -> str:
163
+ step = m.group(1)
164
+ rest = m.group(2)
165
+ if rest == "status":
166
+ return repr(step_statuses.get(step, "unknown"))
167
+ if rest == "skipped":
168
+ return repr(step_statuses.get(step) == "skipped")
169
+ if rest.startswith("output"):
170
+ output = step_results.get(step, {})
171
+ field_path = rest[len("output") :].lstrip(".")
172
+ if field_path and isinstance(output, dict):
173
+ val = output.get(field_path)
174
+ else:
175
+ val = output
176
+ return repr(val)
177
+ return repr(None)
178
+
179
+ def _item_sub(m: re.Match[str]) -> str:
180
+ field = m.group(1)
181
+ if isinstance(item, dict):
182
+ return repr(item.get(field))
183
+ return repr(None)
184
+
185
+ expr = re.sub(r"\$steps\.([\w-]+)\.([\w.]+)", _steps_sub, expr)
186
+ expr = re.sub(r"\$item\.(\w+)", _item_sub, expr)
187
+ expr = re.sub(r"\bnull\b", "None", expr)
188
+ expr = re.sub(r"\btrue\b", "True", expr)
189
+ expr = re.sub(r"\bfalse\b", "False", expr)
190
+ return expr
191
+
192
+
193
+ class _SafeEval(ast.NodeVisitor):
194
+ """Evaluate a boolean AST over constants, comparisons, and bool ops only."""
195
+
196
+ def visit(self, node: ast.AST) -> Any:
197
+ return super().visit(node)
198
+
199
+ def visit_Expression(self, node: ast.Expression) -> Any:
200
+ return self.visit(node.body)
201
+
202
+ def visit_Constant(self, node: ast.Constant) -> Any:
203
+ return node.value
204
+
205
+ def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
206
+ if isinstance(node.op, ast.Not):
207
+ return not self.visit(node.operand)
208
+ raise ValueError(f"Unsupported unary op: {type(node.op).__name__}")
209
+
210
+ def visit_BoolOp(self, node: ast.BoolOp) -> Any:
211
+ vals = [self.visit(v) for v in node.values]
212
+ if isinstance(node.op, ast.And):
213
+ return all(vals)
214
+ if isinstance(node.op, ast.Or):
215
+ return any(vals)
216
+ raise ValueError(f"Unsupported bool op: {type(node.op).__name__}")
217
+
218
+ def visit_Compare(self, node: ast.Compare) -> Any:
219
+ left = self.visit(node.left)
220
+ for op, comp in zip(node.ops, node.comparators):
221
+ right = self.visit(comp)
222
+ try:
223
+ if isinstance(op, ast.Eq):
224
+ result = left == right
225
+ elif isinstance(op, ast.NotEq):
226
+ result = left != right
227
+ elif isinstance(op, ast.Lt):
228
+ result = left < right
229
+ elif isinstance(op, ast.LtE):
230
+ result = left <= right
231
+ elif isinstance(op, ast.Gt):
232
+ result = left > right
233
+ elif isinstance(op, ast.GtE):
234
+ result = left >= right
235
+ elif isinstance(op, ast.Is):
236
+ result = left is right
237
+ elif isinstance(op, ast.IsNot):
238
+ result = left is not right
239
+ else:
240
+ raise ValueError(f"Unsupported compare op: {type(op).__name__}")
241
+ except TypeError:
242
+ return False
243
+ if not result:
244
+ return False
245
+ left = right
246
+ return True
247
+
248
+ def generic_visit(self, node: ast.AST) -> Any:
249
+ raise ValueError(f"Unsupported AST node: {type(node).__name__}")
250
+
251
+
252
+ def evaluate_when(
253
+ expr: str,
254
+ step_results: dict[str, Any],
255
+ step_statuses: dict[str, str],
256
+ item: Any = None,
257
+ ) -> bool:
258
+ """Evaluate a when: expression. Returns True if the step/item should proceed.
259
+
260
+ On eval error (malformed expression, type mismatch) returns True so that
261
+ steps are never silently skipped due to a bad expression string.
262
+ """
263
+ try:
264
+ resolved = _resolve_when_references(expr, step_results, step_statuses, item)
265
+ tree = ast.parse(resolved, mode="eval")
266
+ return bool(_SafeEval().visit(tree))
267
+ except Exception:
268
+ return True
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from sqlalchemy import JSON, DateTime, Index, String, Text, func
8
+ from sqlalchemy.orm import Mapped, mapped_column
9
+
10
+ from pulse_engine.database import Base
11
+
12
+
13
+ class ModuleRegistryModel(Base):
14
+ __tablename__ = "module_registry"
15
+
16
+ id: Mapped[str] = mapped_column(
17
+ String(36), primary_key=True, default=lambda: str(uuid.uuid4())
18
+ )
19
+ tenant_id: Mapped[str] = mapped_column(String(255), nullable=False)
20
+ product: Mapped[str] = mapped_column(String(255), nullable=False)
21
+ module_name: Mapped[str] = mapped_column(String(255), nullable=False)
22
+ image: Mapped[str] = mapped_column(Text, nullable=False)
23
+ created_at: Mapped[datetime] = mapped_column(
24
+ DateTime(timezone=True), server_default=func.now()
25
+ )
26
+ updated_at: Mapped[datetime] = mapped_column(
27
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
28
+ )
29
+
30
+ __table_args__ = (
31
+ Index(
32
+ "uq_module_registry_product_module",
33
+ "tenant_id",
34
+ "product",
35
+ "module_name",
36
+ unique=True,
37
+ ),
38
+ )
39
+
40
+
41
+ class PipelineRunModel(Base):
42
+ __tablename__ = "pipeline_runs"
43
+
44
+ id: Mapped[str] = mapped_column(
45
+ String(36), primary_key=True, default=lambda: str(uuid.uuid4())
46
+ )
47
+ tenant_id: Mapped[str] = mapped_column(String(255), nullable=False)
48
+ product: Mapped[str] = mapped_column(String(255), nullable=False)
49
+ orchestrator: Mapped[str] = mapped_column(String(50), nullable=False)
50
+ orchestrator_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
51
+ config_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
52
+ global_config: Mapped[dict[str, Any]] = mapped_column(
53
+ JSON, nullable=False, default=dict
54
+ )
55
+ status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
56
+ created_at: Mapped[datetime] = mapped_column(
57
+ DateTime(timezone=True), server_default=func.now()
58
+ )
59
+ updated_at: Mapped[datetime] = mapped_column(
60
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
61
+ )
62
+
63
+ __table_args__ = (
64
+ Index("ix_pipeline_runs_tenant_status", "tenant_id", "status"),
65
+ Index("ix_pipeline_runs_tenant_product", "tenant_id", "product"),
66
+ )
67
+
68
+
69
+ class PipelineStepRunModel(Base):
70
+ __tablename__ = "pipeline_step_runs"
71
+
72
+ id: Mapped[str] = mapped_column(
73
+ String(36), primary_key=True, default=lambda: str(uuid.uuid4())
74
+ )
75
+ pipeline_run_id: Mapped[str] = mapped_column(String(36), nullable=False)
76
+ tenant_id: Mapped[str] = mapped_column(String(255), nullable=False)
77
+ step_name: Mapped[str] = mapped_column(String(255), nullable=False)
78
+ module_type: Mapped[str] = mapped_column(String(255), nullable=False, default="")
79
+ status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
80
+ started_at: Mapped[datetime | None] = mapped_column(
81
+ DateTime(timezone=True), nullable=True
82
+ )
83
+ completed_at: Mapped[datetime | None] = mapped_column(
84
+ DateTime(timezone=True), nullable=True
85
+ )
86
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
87
+ output_ref: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
88
+ created_at: Mapped[datetime] = mapped_column(
89
+ DateTime(timezone=True), server_default=func.now()
90
+ )
91
+ updated_at: Mapped[datetime] = mapped_column(
92
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
93
+ )
94
+
95
+ __table_args__ = (
96
+ Index("ix_pipeline_step_runs_run_id", "pipeline_run_id"),
97
+ Index("ix_pipeline_step_runs_tenant_status", "tenant_id", "status"),
98
+ )