topos-node 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 (249) hide show
  1. shared/__init__.py +59 -0
  2. shared/filtering.py +640 -0
  3. shared/schema_registry.py +229 -0
  4. topos/__init__.py +5 -0
  5. topos/__version__.py +6 -0
  6. topos/analytics/__init__.py +15 -0
  7. topos/analytics/duckdb_adapter.py +48 -0
  8. topos/analytics/messenger_communities.py +349 -0
  9. topos/analytics/messenger_graph.py +522 -0
  10. topos/analytics/messenger_labels.py +321 -0
  11. topos/analytics/profiles.py +22 -0
  12. topos/analytics/query_engine.py +64 -0
  13. topos/analytics/raw_queries.py +174 -0
  14. topos/api/__init__.py +1 -0
  15. topos/api/analytics.py +52 -0
  16. topos/api/app_registry.py +31 -0
  17. topos/api/backup.py +15 -0
  18. topos/api/compute_remote.py +175 -0
  19. topos/api/data_commit.py +158 -0
  20. topos/api/data_explorer_table_prefs.py +81 -0
  21. topos/api/db.py +10 -0
  22. topos/api/device.py +25 -0
  23. topos/api/enrichment.py +959 -0
  24. topos/api/filter_lab.py +195 -0
  25. topos/api/health.py +61 -0
  26. topos/api/ingestion_api.py +37 -0
  27. topos/api/ingestion_compat.py +21 -0
  28. topos/api/ingestion_sources.py +600 -0
  29. topos/api/llm.py +76 -0
  30. topos/api/local_mcp.py +46 -0
  31. topos/api/messenger_analytics.py +385 -0
  32. topos/api/query_api.py +13 -0
  33. topos/api/sanitization_ollama_config.py +64 -0
  34. topos/api/source_install.py +324 -0
  35. topos/api/sources.py +13 -0
  36. topos/api/sync.py +10 -0
  37. topos/api/ui_config.py +83 -0
  38. topos/api/uma_data.py +311 -0
  39. topos/api/usage.py +49 -0
  40. topos/api/user_identity.py +46 -0
  41. topos/app.py +239 -0
  42. topos/auth.py +17 -0
  43. topos/canonicalization/__init__.py +1 -0
  44. topos/canonicalization/mappers/__init__.py +22 -0
  45. topos/canonicalization/mappers/base.py +26 -0
  46. topos/canonicalization/mappers/chatgpt_mapper.py +40 -0
  47. topos/canonicalization/mappers/grok_mapper.py +17 -0
  48. topos/canonicalization/mappers/messenger_mapper.py +58 -0
  49. topos/canonicalization/models.py +31 -0
  50. topos/canonicalization/resolver.py +23 -0
  51. topos/cli/__init__.py +1 -0
  52. topos/cli/__main__.py +6 -0
  53. topos/cli/commands.py +132 -0
  54. topos/config/__init__.py +1 -0
  55. topos/config/sanitization_ollama.py +189 -0
  56. topos/config/settings.py +310 -0
  57. topos/contacts/__init__.py +5 -0
  58. topos/contacts/identity.py +24 -0
  59. topos/control_plane_client.py +300 -0
  60. topos/core/__init__.py +1 -0
  61. topos/core/api_models.py +128 -0
  62. topos/core/connection_resilience.py +99 -0
  63. topos/core/device_helpers.py +8 -0
  64. topos/core/errors.py +13 -0
  65. topos/core/events.py +12 -0
  66. topos/core/handlers.py +5625 -0
  67. topos/core/logging.py +175 -0
  68. topos/core/metrics.py +21 -0
  69. topos/core/startup_banner.py +62 -0
  70. topos/core/state.py +682 -0
  71. topos/core/table_layers.py +45 -0
  72. topos/core/types.py +13 -0
  73. topos/data_explorer_table_prefs.py +150 -0
  74. topos/engine/__init__.py +29 -0
  75. topos/engine/backends/__init__.py +50 -0
  76. topos/engine/backends/base.py +21 -0
  77. topos/engine/backends/huggingface.py +151 -0
  78. topos/engine/backends/ollama.py +181 -0
  79. topos/engine/backends/stub.py +22 -0
  80. topos/engine/engine.py +165 -0
  81. topos/engine/intake.py +32 -0
  82. topos/engine/queue_manager.py +112 -0
  83. topos/engine/registration.py +126 -0
  84. topos/engine/result_formatter.py +38 -0
  85. topos/engine/router.py +19 -0
  86. topos/engine/scoped_token.py +82 -0
  87. topos/engine/tasks.py +154 -0
  88. topos/engine/transport.py +44 -0
  89. topos/engine/usage_guard.py +100 -0
  90. topos/engine/usage_observation.py +129 -0
  91. topos/engine/validator.py +23 -0
  92. topos/enrichment/__init__.py +1 -0
  93. topos/enrichment/derived_tables.py +214 -0
  94. topos/enrichment/jobs/__init__.py +30 -0
  95. topos/enrichment/jobs/base.py +54 -0
  96. topos/enrichment/jobs/canonical/__init__.py +1 -0
  97. topos/enrichment/jobs/canonical/embeddings_job.py +27 -0
  98. topos/enrichment/jobs/canonical/emo_27_job.py +97 -0
  99. topos/enrichment/jobs/canonical/entities_job.py +27 -0
  100. topos/enrichment/jobs/canonical/sentiment_job.py +27 -0
  101. topos/enrichment/jobs/canonical/topics_job.py +27 -0
  102. topos/enrichment/jobs/raw/__init__.py +1 -0
  103. topos/enrichment/jobs/raw/attachments_job.py +12 -0
  104. topos/enrichment/jobs/raw/language_job.py +12 -0
  105. topos/enrichment/jobs/raw/time_normalization_job.py +12 -0
  106. topos/enrichment/jobs/raw/tool_calls_job.py +12 -0
  107. topos/enrichment/models/__init__.py +1 -0
  108. topos/enrichment/models/manager.py +8 -0
  109. topos/enrichment/models/registry.py +71 -0
  110. topos/enrichment/models/versioning.py +8 -0
  111. topos/enrichment/orchestrator.py +177 -0
  112. topos/enrichment/processor.py +17 -0
  113. topos/enrichment/progress_bar.py +122 -0
  114. topos/enrichment/website_classifier.py +31 -0
  115. topos/filter_lab/__init__.py +1 -0
  116. topos/filter_lab/bundles.py +300 -0
  117. topos/filter_lab/schema.py +86 -0
  118. topos/filter_lab/service.py +167 -0
  119. topos/filter_lab/store.py +374 -0
  120. topos/filter_lab/worker.py +250 -0
  121. topos/hosted_pool_lease.py +153 -0
  122. topos/ingestion/__init__.py +1 -0
  123. topos/ingestion/checkpoints/__init__.py +6 -0
  124. topos/ingestion/checkpoints/checkpoint_store.py +24 -0
  125. topos/ingestion/checkpoints/sqlite_checkpoint_store.py +82 -0
  126. topos/ingestion/ingest_helpers.py +504 -0
  127. topos/ingestion/jobs.py +91 -0
  128. topos/ingestion/local_sync.py +823 -0
  129. topos/ingestion/log_preview.py +21 -0
  130. topos/ingestion/manager.py +1100 -0
  131. topos/ingestion/parser.py +174 -0
  132. topos/ingestion/parsers/__init__.py +32 -0
  133. topos/ingestion/parsers/base.py +24 -0
  134. topos/ingestion/parsers/browser_parser.py +171 -0
  135. topos/ingestion/parsers/calendar_parser.py +21 -0
  136. topos/ingestion/parsers/chatgpt_conversation_flattener.py +266 -0
  137. topos/ingestion/parsers/chatgpt_parser.py +67 -0
  138. topos/ingestion/parsers/grok_parser.py +21 -0
  139. topos/ingestion/parsers/messenger_parser.py +97 -0
  140. topos/ingestion/progress.py +54 -0
  141. topos/ingestion/sources/__init__.py +20 -0
  142. topos/ingestion/sources/base.py +39 -0
  143. topos/ingestion/sources/calendar.py +29 -0
  144. topos/ingestion/sources/chatgpt.py +29 -0
  145. topos/ingestion/sources/contact_importers.py +274 -0
  146. topos/ingestion/sources/grok.py +29 -0
  147. topos/ingestion/sources/imessage_reader.py +479 -0
  148. topos/ingestion/sources/signal_export_parser.py +132 -0
  149. topos/ingestion/sources/signal_reader.py +491 -0
  150. topos/ingestion/state_machine.py +70 -0
  151. topos/ingestion/triggers/__init__.py +1 -0
  152. topos/ingestion/triggers/file_trigger.py +36 -0
  153. topos/ingestion/triggers/sqlite_trigger.py +18 -0
  154. topos/ingestion/validation/__init__.py +1 -0
  155. topos/ingestion/validation/base.py +27 -0
  156. topos/ingestion/validation/schema_registry.py +111 -0
  157. topos/ingestion/validation/schema_validator.py +13 -0
  158. topos/lineage/__init__.py +1 -0
  159. topos/lineage/provenance.py +9 -0
  160. topos/lineage/tracker.py +9 -0
  161. topos/mcp_stdio_proxy.py +83 -0
  162. topos/observability/__init__.py +1 -0
  163. topos/observability/alerts.py +7 -0
  164. topos/observability/metrics.py +25 -0
  165. topos/observability/tracing.py +18 -0
  166. topos/openai_client.py +69 -0
  167. topos/projections/__init__.py +1 -0
  168. topos/projections/vector_index/__init__.py +1 -0
  169. topos/projections/vector_index/base.py +21 -0
  170. topos/projections/vector_index/builders.py +11 -0
  171. topos/projections/vector_index/health_checks.py +5 -0
  172. topos/rate_limit.py +43 -0
  173. topos/sanitization/__init__.py +16 -0
  174. topos/sanitization/ollama_transforms.py +276 -0
  175. topos/scope_resolution.py +89 -0
  176. topos/services/__init__.py +1 -0
  177. topos/services/container.py +46 -0
  178. topos/services/embeddings/__init__.py +1 -0
  179. topos/services/embeddings/base.py +7 -0
  180. topos/services/embeddings/local.py +9 -0
  181. topos/services/embeddings/remote.py +9 -0
  182. topos/services/interfaces.py +40 -0
  183. topos/services/llm/__init__.py +1 -0
  184. topos/services/llm/base.py +7 -0
  185. topos/services/llm/openai.py +126 -0
  186. topos/services/local.py +123 -0
  187. topos/services/postgres.py +385 -0
  188. topos/sources/__init__.py +6 -0
  189. topos/sources/definitions.py +114 -0
  190. topos/sources/install_service.py +836 -0
  191. topos/sources/registry.py +263 -0
  192. topos/sources/runtime_install.py +427 -0
  193. topos/storage/__init__.py +1 -0
  194. topos/storage/canonical/__init__.py +18 -0
  195. topos/storage/canonical/ai_chat/__init__.py +22 -0
  196. topos/storage/canonical/ai_chat/canonicalizer.py +147 -0
  197. topos/storage/canonical/ai_chat/mapper.py +168 -0
  198. topos/storage/canonical/ai_chat/model.py +87 -0
  199. topos/storage/canonical/ai_chat/tables.py +179 -0
  200. topos/storage/canonical/canonical_store.py +24 -0
  201. topos/storage/canonical/conversations_tables.py +1020 -0
  202. topos/storage/canonical/mapping_store.py +30 -0
  203. topos/storage/canonical/postgres.py +10 -0
  204. topos/storage/db/__init__.py +1 -0
  205. topos/storage/db/client.py +8 -0
  206. topos/storage/db/migrations/__init__.py +1 -0
  207. topos/storage/db/migrations/stage9_column_renames.py +78 -0
  208. topos/storage/db/paths.py +122 -0
  209. topos/storage/db/postgres.py +240 -0
  210. topos/storage/db/schema.py +6 -0
  211. topos/storage/enrichment/__init__.py +1 -0
  212. topos/storage/enrichment/canonical_enrichment_store.py +7 -0
  213. topos/storage/enrichment/raw_enrichment_store.py +18 -0
  214. topos/storage/normalized/__init__.py +1 -0
  215. topos/storage/normalized/normalized_store.py +24 -0
  216. topos/storage/oplog/__init__.py +1 -0
  217. topos/storage/oplog/decision.py +6 -0
  218. topos/storage/oplog/oplog_store.py +17 -0
  219. topos/storage/oplog/postgres.py +10 -0
  220. topos/storage/projections/__init__.py +1 -0
  221. topos/storage/projections/index_ops_store.py +6 -0
  222. topos/storage/projections/vector_index_store.py +6 -0
  223. topos/storage/raw/__init__.py +1 -0
  224. topos/storage/raw/browser_flat_tables.py +303 -0
  225. topos/storage/raw/file_store.py +100 -0
  226. topos/storage/raw/raw_store.py +29 -0
  227. topos/storage/raw/raw_tables_manager.py +295 -0
  228. topos/storage/raw/sqlite_raw_store.py +17 -0
  229. topos/storage/security/encryption.py +21 -0
  230. topos/storage/signal_identity.py +71 -0
  231. topos/storage/source_settings.py +116 -0
  232. topos/storage/user_identity.py +69 -0
  233. topos/sync/__init__.py +5 -0
  234. topos/sync/client.py +272 -0
  235. topos/sync_handlers.py +70 -0
  236. topos/testing/__init__.py +1 -0
  237. topos/testing/lifespan.py +7 -0
  238. topos/uma_contact_enrichment.py +1032 -0
  239. topos/uma_filters.py +669 -0
  240. topos/uma_resource_id.py +24 -0
  241. topos/uma_rpt.py +69 -0
  242. topos/utils/base_object.py +61 -0
  243. topos/websocket_client.py +21 -0
  244. topos_node-0.1.0.dist-info/METADATA +199 -0
  245. topos_node-0.1.0.dist-info/RECORD +249 -0
  246. topos_node-0.1.0.dist-info/WHEEL +5 -0
  247. topos_node-0.1.0.dist-info/entry_points.txt +2 -0
  248. topos_node-0.1.0.dist-info/licenses/LICENSE +201 -0
  249. topos_node-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,836 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ import threading
7
+ import uuid
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
12
+
13
+ from ..config.settings import settings
14
+ from ..core.state import get_db_connection
15
+ from ..enrichment.jobs import CANONICAL_JOBS, RAW_JOBS
16
+ from ..ingestion.parsers import PARSER_REGISTRY
17
+ from ..storage.db.postgres import connect_postgres, execute_query, fetch_all, fetch_one
18
+ from .runtime_install import RuntimeInstallHandle, install_source_definition
19
+
20
+ INSTALL_TABLE = "source_runtime_installs"
21
+ logger = logging.getLogger("topos.sources.install_service")
22
+ _SELECT_COLUMNS = (
23
+ "install_id",
24
+ "scope_key",
25
+ "source_id",
26
+ "version_id",
27
+ "status",
28
+ "is_active",
29
+ "source_definition_json",
30
+ "source_version_row_json",
31
+ "failure_reason",
32
+ "created_at",
33
+ "updated_at",
34
+ )
35
+
36
+
37
+ def _utc_now_iso() -> str:
38
+ return datetime.now(timezone.utc).isoformat()
39
+
40
+
41
+ def _scope_key(scope: Optional[Dict[str, Any]]) -> str:
42
+ scope = scope or {}
43
+ normalized = {
44
+ "user_id": str(scope.get("user_id") or "*").strip() or "*",
45
+ "device_id": str(scope.get("device_id") or "*").strip() or "*",
46
+ "topos_id": str(scope.get("topos_id") or scope.get("app_id") or "*").strip() or "*",
47
+ "dataset_id": str(scope.get("dataset_id") or "*").strip() or "*",
48
+ }
49
+ return json.dumps(normalized, separators=(",", ":"), sort_keys=True, ensure_ascii=True)
50
+
51
+
52
+ def _scope_dict(scope_key: str) -> Dict[str, str]:
53
+ try:
54
+ parsed = json.loads(scope_key)
55
+ if isinstance(parsed, dict):
56
+ return {
57
+ "user_id": str(parsed.get("user_id") or "*"),
58
+ "device_id": str(parsed.get("device_id") or "*"),
59
+ "topos_id": str(parsed.get("topos_id") or parsed.get("app_id") or "*"),
60
+ "dataset_id": str(parsed.get("dataset_id") or "*"),
61
+ }
62
+ except Exception:
63
+ pass
64
+ return {"user_id": "*", "device_id": "*", "topos_id": "*", "dataset_id": "*"}
65
+
66
+
67
+ _SCOPE_FIELDS = ("user_id", "device_id", "topos_id", "dataset_id")
68
+ _CONCRETE_INSTALL_FIELDS = ("user_id", "topos_id", "dataset_id")
69
+
70
+
71
+ def _topos_id_from_scope(scope: Dict[str, Any]) -> str:
72
+ return str(scope.get("topos_id") or scope.get("app_id") or "").strip()
73
+
74
+
75
+ def _validate_concrete_install_scope(scope: Optional[Dict[str, Any]]) -> None:
76
+ scope = scope or {}
77
+ missing: List[str] = []
78
+ if not str(scope.get("user_id") or "").strip() or str(scope.get("user_id") or "").strip() == "*":
79
+ missing.append("user_id")
80
+ if not _topos_id_from_scope(scope) or _topos_id_from_scope(scope) == "*":
81
+ missing.append("topos_id")
82
+ if not str(scope.get("dataset_id") or "").strip() or str(scope.get("dataset_id") or "").strip() == "*":
83
+ missing.append("dataset_id")
84
+ if missing:
85
+ raise ValueError(f"Concrete scope required: {', '.join(missing)}")
86
+
87
+
88
+ def _is_concrete_scope(scope: Optional[Dict[str, Any]]) -> bool:
89
+ scope = scope or {}
90
+ if not str(scope.get("user_id") or "").strip() or str(scope.get("user_id") or "").strip() == "*":
91
+ return False
92
+ if not _topos_id_from_scope(scope) or _topos_id_from_scope(scope) == "*":
93
+ return False
94
+ if not str(scope.get("dataset_id") or "").strip() or str(scope.get("dataset_id") or "").strip() == "*":
95
+ return False
96
+ return True
97
+
98
+
99
+ def _normalize_scope(scope: Optional[Dict[str, Any]]) -> Dict[str, str]:
100
+ scope = scope or {}
101
+ return {
102
+ field: str(scope.get(field) or "*").strip() or "*"
103
+ for field in _SCOPE_FIELDS
104
+ }
105
+
106
+
107
+ def _scope_field_matches(query_value: str, stored_value: str) -> bool:
108
+ query = str(query_value or "").strip()
109
+ stored = str(stored_value or "").strip()
110
+ if not query or query == "*":
111
+ return False
112
+ return query == stored
113
+
114
+
115
+ def _scope_matches(query: Dict[str, str], stored: Dict[str, str]) -> bool:
116
+ """Return True when stored install scope exactly matches the query scope."""
117
+ for field in _SCOPE_FIELDS:
118
+ if not _scope_field_matches(query.get(field, ""), stored.get(field, "")):
119
+ return False
120
+ return True
121
+
122
+
123
+ def _migrate_scope_keys_to_topos_id(conn: Any) -> None:
124
+ rows = fetch_all(
125
+ conn,
126
+ f"""
127
+ SELECT install_id, scope_key
128
+ FROM {INSTALL_TABLE}
129
+ """,
130
+ )
131
+ for row in rows:
132
+ install_id = str(_extract_row_value(row, 0, "install_id") or "").strip()
133
+ raw_scope_key = str(_extract_row_value(row, 1, "scope_key") or "").strip()
134
+ if not install_id or not raw_scope_key:
135
+ continue
136
+ try:
137
+ parsed = json.loads(raw_scope_key)
138
+ except Exception:
139
+ continue
140
+ if not isinstance(parsed, dict):
141
+ continue
142
+
143
+ if "app_id" not in parsed:
144
+ continue
145
+ topos_value = str(parsed.get("topos_id") or parsed.get("app_id") or "*").strip() or "*"
146
+ parsed["topos_id"] = topos_value
147
+ parsed.pop("app_id", None)
148
+ new_scope_key = json.dumps(parsed, separators=(",", ":"), sort_keys=True, ensure_ascii=True)
149
+ if new_scope_key == raw_scope_key:
150
+ continue
151
+
152
+ execute_query(
153
+ conn,
154
+ f"""
155
+ UPDATE {INSTALL_TABLE}
156
+ SET scope_key = %s
157
+ WHERE install_id = %s
158
+ """,
159
+ (new_scope_key, install_id),
160
+ )
161
+
162
+
163
+ def _parse_source_definition_from_version_row(version_row: Dict[str, Any]) -> Dict[str, Any]:
164
+ source_def = version_row.get("source_definition_json")
165
+ if isinstance(source_def, str):
166
+ source_def = json.loads(source_def)
167
+ if not isinstance(source_def, dict):
168
+ raise ValueError("source_version_row_json.source_definition_json must be a JSON object")
169
+ return source_def
170
+
171
+
172
+ def _is_valid_path_token(token: str) -> bool:
173
+ if not token:
174
+ return False
175
+ if token == "*":
176
+ return True
177
+ if token.endswith("[*]"):
178
+ token = token[:-3]
179
+ return token.replace("_", "").replace("-", "").isalnum()
180
+
181
+
182
+ def _validate_parser_extract_map(source_def: Dict[str, Any]) -> None:
183
+ ingest_shape = source_def.get("file_ingest_shape")
184
+ parser_extract_map = ingest_shape.get("parser_extract_map") if isinstance(ingest_shape, dict) else None
185
+ if parser_extract_map is None:
186
+ return
187
+ if not isinstance(parser_extract_map, dict):
188
+ raise ValueError("file_ingest_shape.parser_extract_map must be a JSON object")
189
+ canonical_mapper_id = str(source_def.get("canonical_mapper_id") or "").strip()
190
+ canonical_group_id = str(source_def.get("canonical_group_id") or "").strip()
191
+ canonical_mapping_connected = bool(source_def.get("canonical_mapping_connected"))
192
+ requires_canonical_contract = bool(canonical_mapping_connected or canonical_mapper_id or canonical_group_id)
193
+ if requires_canonical_contract:
194
+ required_keys = {"message_id", "sender_type", "content"}
195
+ missing = sorted([key for key in required_keys if key not in parser_extract_map])
196
+ if missing:
197
+ raise ValueError(f"parser_extract_map missing required keys: {', '.join(missing)}")
198
+ if "event_at" not in parser_extract_map and "created_at" not in parser_extract_map:
199
+ raise ValueError("parser_extract_map must include either event_at or created_at")
200
+ for key, value in parser_extract_map.items():
201
+ if not isinstance(value, str) or not value.strip():
202
+ raise ValueError(f"parser_extract_map.{key} must be a non-empty string path")
203
+ for token in [part.strip() for part in value.split(".") if part.strip()]:
204
+ if not _is_valid_path_token(token):
205
+ raise ValueError(f"parser_extract_map.{key} has invalid path token: {token}")
206
+
207
+
208
+ def _validate_mapper_contract(source_def: Dict[str, Any]) -> None:
209
+ source_type = str(source_def.get("source_type") or "").strip()
210
+ canonical_mapper_id = str(source_def.get("canonical_mapper_id") or "").strip()
211
+ canonical_group_id = str(source_def.get("canonical_group_id") or "").strip()
212
+ canonical_mapping_connected = bool(source_def.get("canonical_mapping_connected"))
213
+ requires_canonical_contract = bool(canonical_mapping_connected or canonical_mapper_id or canonical_group_id)
214
+ if source_type in {"file", "ui_stream"} and not requires_canonical_contract:
215
+ return
216
+ if requires_canonical_contract and not canonical_mapper_id:
217
+ raise ValueError("canonical_mapper_id is required when canonical mapping is connected")
218
+ if requires_canonical_contract and not canonical_group_id:
219
+ raise ValueError("canonical_group_id is required when canonical mapping is connected")
220
+ if canonical_group_id and canonical_group_id not in {"ai_messages", "conversations"}:
221
+ raise ValueError("canonical_group_id must be one of: ai_messages, conversations")
222
+
223
+
224
+ def _trusted_enrichment_function_catalog() -> Dict[str, Dict[str, str]]:
225
+ canonical_job_names = {job.get_job_name(): job.get_job_name() for job in CANONICAL_JOBS}
226
+ raw_job_names = {job.get_job_name(): job.get_job_name() for job in RAW_JOBS}
227
+ return {
228
+ **{job_name: {"stage": "canonical", "job_name": canonical_job_names[job_name]} for job_name in canonical_job_names},
229
+ **{job_name: {"stage": "raw", "job_name": raw_job_names[job_name]} for job_name in raw_job_names},
230
+ }
231
+
232
+
233
+ def _normalize_enrichment_bindings(source_def: Dict[str, Any]) -> Dict[str, Any]:
234
+ out = dict(source_def)
235
+ bindings = out.get("source_enrichments")
236
+ if not isinstance(bindings, list):
237
+ return out
238
+ catalog = _trusted_enrichment_function_catalog()
239
+ canonical_jobs: List[str] = []
240
+ raw_jobs: List[str] = []
241
+ canonical_trigger_modes: List[str] = []
242
+ for idx, binding in enumerate(bindings):
243
+ if not isinstance(binding, dict):
244
+ raise ValueError(f"source_enrichments[{idx}] must be an object")
245
+ function_id = str(binding.get("function_id") or "").strip()
246
+ if not function_id:
247
+ raise ValueError(f"source_enrichments[{idx}].function_id is required")
248
+ trusted = catalog.get(function_id)
249
+ if not trusted:
250
+ raise ValueError(
251
+ f"source_enrichments[{idx}].function_id={function_id!r} is not in trusted runtime catalog"
252
+ )
253
+ stage = str(binding.get("stage") or trusted["stage"]).strip()
254
+ trigger_mode = str(binding.get("trigger_mode") or "automatic").strip()
255
+ if trigger_mode not in {"automatic", "manual"}:
256
+ raise ValueError(f"source_enrichments[{idx}].trigger_mode must be automatic or manual")
257
+ if stage == "canonical":
258
+ canonical_jobs.append(trusted["job_name"])
259
+ canonical_trigger_modes.append(trigger_mode)
260
+ elif stage == "raw":
261
+ raw_jobs.append(trusted["job_name"])
262
+ else:
263
+ raise ValueError(f"source_enrichments[{idx}].stage must be canonical or raw")
264
+ if canonical_jobs:
265
+ out["canonical_enrichment_jobs"] = sorted(set(canonical_jobs))
266
+ out["enrichment_trigger"] = "manual" if "manual" in canonical_trigger_modes else "automatic"
267
+ if raw_jobs:
268
+ out["raw_enrichment_jobs"] = sorted(set(raw_jobs))
269
+ return out
270
+
271
+
272
+ def _validate_source_contract(source_def: Dict[str, Any]) -> None:
273
+ source_id = str(source_def.get("source_id") or "").strip()
274
+ schema_id = str(source_def.get("schema_id") or "").strip()
275
+ parser_id = str(source_def.get("parser_id") or "").strip()
276
+ source_type = str(source_def.get("source_type") or "").strip()
277
+ if not source_id:
278
+ raise ValueError("source_definition_json.source_id is required")
279
+ if not schema_id or not parser_id:
280
+ raise ValueError("source_definition_json.schema_id and parser_id are required")
281
+ if source_type not in {"file", "ui_stream", "local_sync"}:
282
+ raise ValueError("source_definition_json.source_type must be one of: file, ui_stream, local_sync")
283
+ _validate_parser_extract_map(source_def)
284
+ _validate_mapper_contract(source_def)
285
+
286
+
287
+ @dataclass(frozen=True)
288
+ class InstallRecord:
289
+ install_id: str
290
+ scope: Dict[str, str]
291
+ source_id: str
292
+ version_id: Optional[str]
293
+ status: str
294
+ is_active: bool
295
+ source_definition_json: Dict[str, Any]
296
+ source_version_row_json: Optional[Dict[str, Any]]
297
+ failure_reason: Optional[str]
298
+ created_at: str
299
+ updated_at: str
300
+
301
+ def to_dict(self) -> Dict[str, Any]:
302
+ return {
303
+ "install_id": self.install_id,
304
+ "scope": self.scope,
305
+ "source_id": self.source_id,
306
+ "version_id": self.version_id,
307
+ "status": self.status,
308
+ "is_active": self.is_active,
309
+ "source_definition_json": self.source_definition_json,
310
+ "source_version_row_json": self.source_version_row_json,
311
+ "failure_reason": self.failure_reason,
312
+ "created_at": self.created_at,
313
+ "updated_at": self.updated_at,
314
+ }
315
+
316
+
317
+ _LOCK = threading.RLock()
318
+ _ACTIVE_HANDLES: Dict[Tuple[str, str], RuntimeInstallHandle] = {}
319
+
320
+
321
+ @contextmanager
322
+ def _db_conn() -> Iterator[Any]:
323
+ """Yield a DB connection using configured database mode.
324
+
325
+ - local mode: shared SQLite connection from `get_db_connection()`
326
+ - postgres mode: transactional connection from `connect_postgres()`
327
+ """
328
+ if settings.topos_database_mode == "postgres":
329
+ with connect_postgres() as conn:
330
+ yield conn
331
+ return
332
+
333
+ conn = get_db_connection()
334
+ if not conn:
335
+ raise RuntimeError("Database not available")
336
+ yield conn
337
+
338
+
339
+ def ensure_install_schema() -> None:
340
+ with _db_conn() as conn:
341
+ execute_query(
342
+ conn,
343
+ f"""
344
+ CREATE TABLE IF NOT EXISTS {INSTALL_TABLE} (
345
+ install_id TEXT PRIMARY KEY,
346
+ scope_key TEXT NOT NULL,
347
+ source_id TEXT NOT NULL,
348
+ version_id TEXT,
349
+ status TEXT NOT NULL,
350
+ is_active INTEGER NOT NULL DEFAULT 0,
351
+ source_definition_json TEXT NOT NULL,
352
+ source_version_row_json TEXT,
353
+ failure_reason TEXT,
354
+ created_at TEXT NOT NULL,
355
+ updated_at TEXT NOT NULL
356
+ )
357
+ """,
358
+ )
359
+ execute_query(
360
+ conn,
361
+ f"""
362
+ CREATE INDEX IF NOT EXISTS idx_{INSTALL_TABLE}_scope_source
363
+ ON {INSTALL_TABLE}(scope_key, source_id, updated_at)
364
+ """,
365
+ )
366
+ _migrate_scope_keys_to_topos_id(conn)
367
+ if settings.topos_database_mode != "postgres":
368
+ conn.commit()
369
+
370
+
371
+ def _row_to_record(row: Any) -> InstallRecord:
372
+ if isinstance(row, dict):
373
+ install_id = str(row.get("install_id"))
374
+ scope_key = str(row.get("scope_key"))
375
+ source_id = str(row.get("source_id"))
376
+ version_id_value = row.get("version_id")
377
+ status_value = row.get("status")
378
+ is_active_value = row.get("is_active")
379
+ source_def_raw = row.get("source_definition_json")
380
+ source_row = row.get("source_version_row_json")
381
+ failure_reason_value = row.get("failure_reason")
382
+ created_at_value = row.get("created_at")
383
+ updated_at_value = row.get("updated_at")
384
+ else:
385
+ (
386
+ install_id,
387
+ scope_key,
388
+ source_id,
389
+ version_id_value,
390
+ status_value,
391
+ is_active_value,
392
+ source_def_raw,
393
+ source_row,
394
+ failure_reason_value,
395
+ created_at_value,
396
+ updated_at_value,
397
+ ) = row
398
+
399
+ source_def = json.loads(str(source_def_raw) or "{}")
400
+ source_row_json = None
401
+ if source_row:
402
+ source_row_json = json.loads(str(source_row))
403
+ return InstallRecord(
404
+ install_id=str(install_id),
405
+ scope=_scope_dict(str(scope_key)),
406
+ source_id=str(source_id),
407
+ version_id=str(version_id_value) if version_id_value else None,
408
+ status=str(status_value),
409
+ is_active=bool(is_active_value),
410
+ source_definition_json=source_def if isinstance(source_def, dict) else {},
411
+ source_version_row_json=source_row_json if isinstance(source_row_json, dict) else None,
412
+ failure_reason=str(failure_reason_value) if failure_reason_value else None,
413
+ created_at=str(created_at_value),
414
+ updated_at=str(updated_at_value),
415
+ )
416
+
417
+
418
+ def _get_active_record(conn: Any, scope_key: str, source_id: str) -> Optional[InstallRecord]:
419
+ rows = fetch_all(
420
+ conn,
421
+ f"""
422
+ SELECT {", ".join(_SELECT_COLUMNS)}
423
+ FROM {INSTALL_TABLE}
424
+ WHERE scope_key = %s AND source_id = %s AND is_active = 1
425
+ ORDER BY updated_at DESC
426
+ LIMIT 1
427
+ """,
428
+ (scope_key, source_id),
429
+ )
430
+ if not rows:
431
+ return None
432
+ return _row_to_record(rows[0])
433
+
434
+
435
+ def install_source(
436
+ *,
437
+ source_definition_json: Optional[Dict[str, Any]] = None,
438
+ source_version_row_json: Optional[Dict[str, Any]] = None,
439
+ version_id: Optional[str] = None,
440
+ scope: Optional[Dict[str, Any]] = None,
441
+ ) -> InstallRecord:
442
+ ensure_install_schema()
443
+ source_def = source_definition_json
444
+ if source_def is None and source_version_row_json is not None:
445
+ source_def = _parse_source_definition_from_version_row(source_version_row_json)
446
+ if not isinstance(source_def, dict):
447
+ raise ValueError("source_definition_json or source_version_row_json is required")
448
+ source_def = _normalize_enrichment_bindings(source_def)
449
+ _validate_source_contract(source_def)
450
+ source_id = str(source_def.get("source_id") or "").strip()
451
+
452
+ _validate_concrete_install_scope(scope)
453
+ scope_key = _scope_key(scope)
454
+ now = _utc_now_iso()
455
+ install_id = str(uuid.uuid4())
456
+ requested_version_id = (
457
+ str(version_id).strip()
458
+ if version_id
459
+ else (str(source_version_row_json.get("version_id")).strip() if isinstance(source_version_row_json, dict) and source_version_row_json.get("version_id") else None)
460
+ )
461
+
462
+ with _db_conn() as conn:
463
+ with _LOCK:
464
+ active_before = _get_active_record(conn, scope_key, source_id)
465
+ if (
466
+ active_before
467
+ and active_before.is_active
468
+ and active_before.version_id == requested_version_id
469
+ and active_before.source_definition_json == source_def
470
+ ):
471
+ # Runtime parser/mapper registrations are in-memory. After process restart we can have
472
+ # an active DB install record but no runtime parser bound anymore.
473
+ active_key = (scope_key, source_id)
474
+ parser_ids = {
475
+ item
476
+ for item in [
477
+ str(source_def.get("schema_id") or "").strip(),
478
+ str(source_def.get("parser_id") or "").strip(),
479
+ ]
480
+ if item
481
+ }
482
+ runtime_missing = active_key not in _ACTIVE_HANDLES or any(
483
+ parser_id not in PARSER_REGISTRY for parser_id in parser_ids
484
+ )
485
+ if runtime_missing:
486
+ _ACTIVE_HANDLES[active_key] = install_source_definition(source_def)
487
+ return active_before
488
+
489
+ try:
490
+ handle = install_source_definition(source_def)
491
+ except Exception as exc:
492
+ execute_query(
493
+ conn,
494
+ f"""
495
+ INSERT INTO {INSTALL_TABLE} (
496
+ install_id, scope_key, source_id, version_id, status, is_active,
497
+ source_definition_json, source_version_row_json, failure_reason,
498
+ created_at, updated_at
499
+ ) VALUES (%s, %s, %s, %s, 'failed', 0, %s, %s, %s, %s, %s)
500
+ """,
501
+ (
502
+ install_id,
503
+ scope_key,
504
+ source_id,
505
+ requested_version_id,
506
+ json.dumps(source_def, separators=(",", ":"), ensure_ascii=True),
507
+ json.dumps(source_version_row_json, separators=(",", ":"), ensure_ascii=True)
508
+ if isinstance(source_version_row_json, dict)
509
+ else None,
510
+ str(exc),
511
+ now,
512
+ now,
513
+ ),
514
+ )
515
+ if settings.topos_database_mode != "postgres":
516
+ conn.commit()
517
+ raise
518
+
519
+ # Mark previous active record as installed (inactive).
520
+ execute_query(
521
+ conn,
522
+ f"""
523
+ UPDATE {INSTALL_TABLE}
524
+ SET is_active = 0, status = 'installed', updated_at = %s
525
+ WHERE scope_key = %s AND source_id = %s AND is_active = 1
526
+ """,
527
+ (now, scope_key, source_id),
528
+ )
529
+ execute_query(
530
+ conn,
531
+ f"""
532
+ INSERT INTO {INSTALL_TABLE} (
533
+ install_id, scope_key, source_id, version_id, status, is_active,
534
+ source_definition_json, source_version_row_json, failure_reason,
535
+ created_at, updated_at
536
+ ) VALUES (%s, %s, %s, %s, 'active', 1, %s, %s, NULL, %s, %s)
537
+ """,
538
+ (
539
+ install_id,
540
+ scope_key,
541
+ source_id,
542
+ requested_version_id,
543
+ json.dumps(source_def, separators=(",", ":"), ensure_ascii=True),
544
+ json.dumps(source_version_row_json, separators=(",", ":"), ensure_ascii=True)
545
+ if isinstance(source_version_row_json, dict)
546
+ else None,
547
+ now,
548
+ now,
549
+ ),
550
+ )
551
+ if settings.topos_database_mode != "postgres":
552
+ conn.commit()
553
+ _ACTIVE_HANDLES[(scope_key, source_id)] = handle
554
+ return InstallRecord(
555
+ install_id=install_id,
556
+ scope=_scope_dict(scope_key),
557
+ source_id=source_id,
558
+ version_id=requested_version_id,
559
+ status="active",
560
+ is_active=True,
561
+ source_definition_json=source_def,
562
+ source_version_row_json=source_version_row_json if isinstance(source_version_row_json, dict) else None,
563
+ failure_reason=None,
564
+ created_at=now,
565
+ updated_at=now,
566
+ )
567
+
568
+
569
+ def list_installs(*, scope: Optional[Dict[str, Any]] = None, source_id: Optional[str] = None) -> List[InstallRecord]:
570
+ ensure_install_schema()
571
+ clauses: List[str] = []
572
+ params: List[Any] = []
573
+ if source_id:
574
+ clauses.append("source_id = %s")
575
+ params.append(source_id)
576
+ normalized_scope = _normalize_scope(scope) if scope is not None else None
577
+ if scope is not None and _is_concrete_scope(scope):
578
+ clauses.append("scope_key = %s")
579
+ params.append(_scope_key(scope))
580
+ where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
581
+ with _db_conn() as conn:
582
+ rows = fetch_all(
583
+ conn,
584
+ f"""
585
+ SELECT {", ".join(_SELECT_COLUMNS)}
586
+ FROM {INSTALL_TABLE}
587
+ {where_sql}
588
+ ORDER BY updated_at DESC
589
+ """,
590
+ tuple(params),
591
+ )
592
+ records = [_row_to_record(row) for row in rows]
593
+ if normalized_scope is None or _is_concrete_scope(scope):
594
+ return records
595
+ return [record for record in records if _scope_matches(normalized_scope, record.scope or {})]
596
+
597
+
598
+ def _safe_sql_identifier(name: str) -> bool:
599
+ return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", str(name or "")))
600
+
601
+
602
+ def _extract_row_value(row: Any, index: int = 0, key: Optional[str] = None) -> Any:
603
+ if isinstance(row, dict):
604
+ if key is not None and key in row:
605
+ return row[key]
606
+ values = list(row.values())
607
+ return values[index] if index < len(values) else None
608
+ try:
609
+ if key is not None:
610
+ return row[key]
611
+ except Exception:
612
+ pass
613
+ try:
614
+ return row[index]
615
+ except Exception:
616
+ return None
617
+
618
+
619
+ def _list_user_tables(conn: Any) -> List[str]:
620
+ if settings.topos_database_mode == "postgres":
621
+ rows = fetch_all(
622
+ conn,
623
+ """
624
+ SELECT table_name
625
+ FROM information_schema.tables
626
+ WHERE table_schema = 'public'
627
+ ORDER BY table_name
628
+ """,
629
+ )
630
+ return [str(_extract_row_value(row, 0, "table_name") or "").strip() for row in rows]
631
+
632
+ rows = fetch_all(
633
+ conn,
634
+ """
635
+ SELECT name
636
+ FROM sqlite_master
637
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
638
+ ORDER BY name
639
+ """,
640
+ )
641
+ return [str(_extract_row_value(row, 0, "name") or "").strip() for row in rows]
642
+
643
+
644
+ def _list_table_columns(conn: Any, table_name: str) -> List[str]:
645
+ if not _safe_sql_identifier(table_name):
646
+ return []
647
+ if settings.topos_database_mode == "postgres":
648
+ rows = fetch_all(
649
+ conn,
650
+ """
651
+ SELECT column_name
652
+ FROM information_schema.columns
653
+ WHERE table_schema = 'public' AND table_name = %s
654
+ ORDER BY ordinal_position
655
+ """,
656
+ (table_name,),
657
+ )
658
+ return [str(_extract_row_value(row, 0, "column_name") or "").strip() for row in rows]
659
+
660
+ rows = fetch_all(conn, f'PRAGMA table_info("{table_name}")')
661
+ columns: List[str] = []
662
+ for row in rows:
663
+ col_name = _extract_row_value(row, 1)
664
+ if col_name is not None:
665
+ columns.append(str(col_name))
666
+ return columns
667
+
668
+
669
+ def _drop_table(conn: Any, table_name: str) -> None:
670
+ if not _safe_sql_identifier(table_name):
671
+ return
672
+ if settings.topos_database_mode == "postgres":
673
+ execute_query(conn, f'DROP TABLE IF EXISTS "{table_name}" CASCADE')
674
+ return
675
+ execute_query(conn, f'DROP TABLE IF EXISTS "{table_name}"')
676
+
677
+
678
+ def _count_table_rows_for_source(conn: Any, table_name: str, source_column: str, source_id: str) -> int:
679
+ if not (_safe_sql_identifier(table_name) and _safe_sql_identifier(source_column)):
680
+ return 0
681
+ row = fetch_one(conn, f'SELECT COUNT(*) AS count FROM "{table_name}" WHERE "{source_column}" = %s', (source_id,))
682
+ count_val = _extract_row_value(row, 0, "count") if row is not None else 0
683
+ try:
684
+ return int(count_val or 0)
685
+ except Exception:
686
+ return 0
687
+
688
+
689
+ def _count_all_rows(conn: Any, table_name: str) -> int:
690
+ if not _safe_sql_identifier(table_name):
691
+ return 0
692
+ row = fetch_one(conn, f'SELECT COUNT(*) AS count FROM "{table_name}"')
693
+ count_val = _extract_row_value(row, 0, "count") if row is not None else 0
694
+ try:
695
+ return int(count_val or 0)
696
+ except Exception:
697
+ return 0
698
+
699
+
700
+ def _purge_source_associated_data(conn: Any, source_id: str) -> Dict[str, Any]:
701
+ sid = str(source_id or "").strip()
702
+ if not sid:
703
+ return {"tables_dropped": [], "rows_deleted": 0}
704
+
705
+ rows_deleted = 0
706
+ tables_dropped: List[str] = []
707
+ normalized_sid = re.sub(r"[^a-z0-9]+", "", sid.lower())
708
+ table_names = [name for name in _list_user_tables(conn) if name and _safe_sql_identifier(name)]
709
+
710
+ for table_name in table_names:
711
+ if table_name in {INSTALL_TABLE, "sqlite_sequence"}:
712
+ continue
713
+ columns = set(_list_table_columns(conn, table_name))
714
+ deleted_from_table = False
715
+ for source_column in ("source_id", "source_system"):
716
+ if source_column not in columns:
717
+ continue
718
+ match_count = _count_table_rows_for_source(conn, table_name, source_column, sid)
719
+ if match_count <= 0:
720
+ continue
721
+ execute_query(conn, f'DELETE FROM "{table_name}" WHERE "{source_column}" = %s', (sid,))
722
+ rows_deleted += match_count
723
+ deleted_from_table = True
724
+
725
+ # Per-source raw tables are safe to drop when emptied by this purge.
726
+ if deleted_from_table and table_name.startswith("raw_") and _count_all_rows(conn, table_name) == 0:
727
+ _drop_table(conn, table_name)
728
+ tables_dropped.append(table_name)
729
+ continue
730
+
731
+ # Some legacy source tables may not carry source_id/source_system columns.
732
+ # Drop obvious per-source raw tables by normalized source id suffix.
733
+ normalized_table = re.sub(r"[^a-z0-9]+", "", table_name.lower())
734
+ if table_name.startswith("raw_") and normalized_sid and normalized_sid in normalized_table:
735
+ _drop_table(conn, table_name)
736
+ tables_dropped.append(table_name)
737
+
738
+ return {"tables_dropped": sorted(set(tables_dropped)), "rows_deleted": rows_deleted}
739
+
740
+
741
+ def uninstall_source(
742
+ *,
743
+ source_id: str,
744
+ scope: Optional[Dict[str, Any]] = None,
745
+ delete_source_tables: bool = False,
746
+ ) -> Dict[str, Any]:
747
+ ensure_install_schema()
748
+ sid = str(source_id or "").strip()
749
+ if not sid:
750
+ raise ValueError("source_id is required")
751
+
752
+ scope_key = _scope_key(scope)
753
+ now = _utc_now_iso()
754
+ with _db_conn() as conn:
755
+ with _LOCK:
756
+ active_before = _get_active_record(conn, scope_key, sid)
757
+ handle = _ACTIVE_HANDLES.pop((scope_key, sid), None)
758
+ if handle is not None:
759
+ handle.uninstall()
760
+ execute_query(
761
+ conn,
762
+ f"""
763
+ UPDATE {INSTALL_TABLE}
764
+ SET is_active = 0, status = 'rolled_back', updated_at = %s
765
+ WHERE scope_key = %s AND source_id = %s AND is_active = 1
766
+ """,
767
+ (now, scope_key, sid),
768
+ )
769
+ purge_summary: Dict[str, Any] = {"tables_dropped": [], "rows_deleted": 0}
770
+ if delete_source_tables:
771
+ purge_summary = _purge_source_associated_data(conn, sid)
772
+ if settings.topos_database_mode != "postgres":
773
+ conn.commit()
774
+ return {
775
+ "status": "ok",
776
+ "source_id": sid,
777
+ "scope": _scope_dict(scope_key),
778
+ "uninstalled": active_before is not None,
779
+ "delete_source_tables": bool(delete_source_tables),
780
+ "rows_deleted": int(purge_summary.get("rows_deleted") or 0),
781
+ "tables_dropped": purge_summary.get("tables_dropped") or [],
782
+ }
783
+
784
+
785
+ def get_active_source_definition(*, source_id: str, scope: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
786
+ try:
787
+ with _db_conn() as conn:
788
+ rec = _get_active_record(conn, _scope_key(scope), str(source_id or "").strip())
789
+ if rec and rec.is_active:
790
+ return rec.source_definition_json
791
+ except Exception:
792
+ return None
793
+ return None
794
+
795
+
796
+ def rehydrate_active_installs_runtime(*, source_id: Optional[str] = None) -> Dict[str, int]:
797
+ """Ensure active installs are present in runtime registries after restarts.
798
+
799
+ This repopulates in-memory REGISTRY/PARSER_REGISTRY/MAPPER_REGISTRY from
800
+ persisted active install rows without creating new install history rows.
801
+ """
802
+ ensure_install_schema()
803
+ records = [rec for rec in list_installs(source_id=source_id) if rec.is_active]
804
+ rehydrated = 0
805
+ failed = 0
806
+ with _LOCK:
807
+ for rec in records:
808
+ source_def = rec.source_definition_json if isinstance(rec.source_definition_json, dict) else {}
809
+ if not source_def:
810
+ continue
811
+ try:
812
+ scope_key = _scope_key(rec.scope)
813
+ runtime_key = (scope_key, rec.source_id)
814
+ parser_ids = {
815
+ item
816
+ for item in [
817
+ str(source_def.get("schema_id") or "").strip(),
818
+ str(source_def.get("parser_id") or "").strip(),
819
+ ]
820
+ if item
821
+ }
822
+ runtime_missing = runtime_key not in _ACTIVE_HANDLES or any(parser_id not in PARSER_REGISTRY for parser_id in parser_ids)
823
+ if not runtime_missing:
824
+ continue
825
+ _ACTIVE_HANDLES[runtime_key] = install_source_definition(source_def)
826
+ rehydrated += 1
827
+ except Exception as exc: # noqa: BLE001
828
+ failed += 1
829
+ logger.warning(
830
+ "Failed to rehydrate active install runtime: source_id=%s scope=%s error=%s",
831
+ rec.source_id,
832
+ rec.scope,
833
+ exc,
834
+ )
835
+ return {"active_installs": len(records), "rehydrated": rehydrated, "failed": failed}
836
+