topicgate 1.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 (148) hide show
  1. topicgate/__init__.py +0 -0
  2. topicgate/__main__.py +5 -0
  3. topicgate/app/__init__.py +0 -0
  4. topicgate/app/app_dependencies.py +148 -0
  5. topicgate/app/broker_runtime_state.py +43 -0
  6. topicgate/app/models/__init__.py +1 -0
  7. topicgate/app/models/broker_snapshot.py +107 -0
  8. topicgate/app/models/mcp_setup.py +20 -0
  9. topicgate/app/services/__init__.py +0 -0
  10. topicgate/app/services/broker_profile_service.py +245 -0
  11. topicgate/app/services/broker_resolver.py +58 -0
  12. topicgate/app/services/broker_snapshot_service.py +403 -0
  13. topicgate/app/services/control_operation_service.py +158 -0
  14. topicgate/app/services/mcp_setup_service.py +192 -0
  15. topicgate/app/services/observation_cache_service.py +256 -0
  16. topicgate/app/services/observation_retention_policy_service.py +33 -0
  17. topicgate/app/services/persistence_lifecycle.py +25 -0
  18. topicgate/app/services/service_container.py +20 -0
  19. topicgate/app/services/service_item.py +13 -0
  20. topicgate/app/topicgate_runtime.py +496 -0
  21. topicgate/assets/icon.png +0 -0
  22. topicgate/assets/icon.svg +37 -0
  23. topicgate/core/__init__.py +0 -0
  24. topicgate/core/config/__init__.py +0 -0
  25. topicgate/core/config/app_config.py +7 -0
  26. topicgate/core/config/mqtt_config.py +10 -0
  27. topicgate/core/interfaces/__init__.py +0 -0
  28. topicgate/core/interfaces/broker_profile_store.py +26 -0
  29. topicgate/core/interfaces/mqtt_message_processor.py +12 -0
  30. topicgate/core/interfaces/observer_repository.py +82 -0
  31. topicgate/core/interfaces/topic_message_store.py +13 -0
  32. topicgate/core/models/__init__.py +0 -0
  33. topicgate/core/models/broker_profile.py +15 -0
  34. topicgate/core/models/broker_profile_identity.py +14 -0
  35. topicgate/core/models/broker_summary.py +14 -0
  36. topicgate/core/models/connection_status.py +9 -0
  37. topicgate/core/models/message_filter.py +9 -0
  38. topicgate/core/models/mqtt_message.py +13 -0
  39. topicgate/core/models/mqtt_observation.py +36 -0
  40. topicgate/core/models/mqtt_state.py +5 -0
  41. topicgate/core/models/observation_cache_administration.py +135 -0
  42. topicgate/core/models/observation_deletion_preview.py +39 -0
  43. topicgate/core/models/observation_retention_policy.py +65 -0
  44. topicgate/core/models/observer_model.py +22 -0
  45. topicgate/core/models/observer_workspace.py +13 -0
  46. topicgate/core/models/subscription.py +29 -0
  47. topicgate/core/models/topic_message.py +16 -0
  48. topicgate/core/mqtt_topics.py +46 -0
  49. topicgate/core/observer_limits.py +8 -0
  50. topicgate/core/payload_limits.py +6 -0
  51. topicgate/gui/__init__.py +0 -0
  52. topicgate/gui/app.py +97 -0
  53. topicgate/gui/broker_state_reader.py +29 -0
  54. topicgate/gui/components/about_dialog.py +100 -0
  55. topicgate/gui/components/add_subscription_dialog.py +59 -0
  56. topicgate/gui/components/application_header.py +137 -0
  57. topicgate/gui/components/broker_settings_dialog.py +223 -0
  58. topicgate/gui/components/connection_controls.py +123 -0
  59. topicgate/gui/components/connection_status.py +7 -0
  60. topicgate/gui/components/log_console.py +26 -0
  61. topicgate/gui/components/mcp_setup_dialog.py +198 -0
  62. topicgate/gui/components/observer_tree.py +434 -0
  63. topicgate/gui/components/onboarding_panel.py +74 -0
  64. topicgate/gui/components/publish_pane.py +60 -0
  65. topicgate/gui/components/snapshot_panel.py +343 -0
  66. topicgate/gui/components/stored_observations_dialog.py +611 -0
  67. topicgate/gui/components/subscription_settings.py +151 -0
  68. topicgate/gui/components/topic_details.py +51 -0
  69. topicgate/gui/components/topic_metadata.py +79 -0
  70. topicgate/gui/components/workspace_pane.py +30 -0
  71. topicgate/gui/gui.py +5 -0
  72. topicgate/gui/main_view_model.py +814 -0
  73. topicgate/gui/main_window.py +986 -0
  74. topicgate/gui/observer_state_reader.py +47 -0
  75. topicgate/gui/settings_migration.py +32 -0
  76. topicgate/gui/theme.py +61 -0
  77. topicgate/infrastructure/__init__.py +0 -0
  78. topicgate/infrastructure/credentials/__init__.py +1 -0
  79. topicgate/infrastructure/credentials/credential_store.py +10 -0
  80. topicgate/infrastructure/credentials/os_credential_store.py +20 -0
  81. topicgate/infrastructure/database/__init__.py +0 -0
  82. topicgate/infrastructure/database/base.py +5 -0
  83. topicgate/infrastructure/database/database_context.py +56 -0
  84. topicgate/infrastructure/database/mappers/broker_profile_mapper.py +42 -0
  85. topicgate/infrastructure/database/mappers/config_mapper.py +48 -0
  86. topicgate/infrastructure/database/mappers/mapper_helper.py +24 -0
  87. topicgate/infrastructure/database/mappers/observation_retention_policy_mapper.py +53 -0
  88. topicgate/infrastructure/database/mappers/observer_workspace_mapper.py +42 -0
  89. topicgate/infrastructure/database/mappers/subscription_mapper.py +38 -0
  90. topicgate/infrastructure/database/mappers/topic_message_mapper.py +37 -0
  91. topicgate/infrastructure/database/migrations.py +57 -0
  92. topicgate/infrastructure/database/models/__init__.py +25 -0
  93. topicgate/infrastructure/database/models/app_config_row.py +18 -0
  94. topicgate/infrastructure/database/models/broker_profile_row.py +35 -0
  95. topicgate/infrastructure/database/models/mqtt_config_row.py +18 -0
  96. topicgate/infrastructure/database/models/mqtt_message_row.py +33 -0
  97. topicgate/infrastructure/database/models/observation_retention_policy_row.py +59 -0
  98. topicgate/infrastructure/database/models/observer_workspace_row.py +39 -0
  99. topicgate/infrastructure/database/models/subscription_row.py +18 -0
  100. topicgate/infrastructure/mqtt/__init__.py +0 -0
  101. topicgate/infrastructure/mqtt/async_callback_bridge.py +105 -0
  102. topicgate/infrastructure/mqtt/callbacks/__init__.py +0 -0
  103. topicgate/infrastructure/mqtt/callbacks/basic_callbacks.py +61 -0
  104. topicgate/infrastructure/mqtt/callbacks/observer_repository_callbacks.py +79 -0
  105. topicgate/infrastructure/mqtt/mqtt_callbacks.py +53 -0
  106. topicgate/infrastructure/mqtt/mqtt_client.py +356 -0
  107. topicgate/infrastructure/mqtt/mqtt_gate.py +122 -0
  108. topicgate/infrastructure/repository/__init__.py +0 -0
  109. topicgate/infrastructure/repository/broker_config_repository.py +68 -0
  110. topicgate/infrastructure/repository/broker_repository.py +170 -0
  111. topicgate/infrastructure/repository/observation_retention_policy_repository.py +42 -0
  112. topicgate/infrastructure/repository/observer_mqtt_repository.py +335 -0
  113. topicgate/infrastructure/repository/subscription_repository.py +117 -0
  114. topicgate/infrastructure/repository/topic_message_repository.py +438 -0
  115. topicgate/mcp/__init__.py +0 -0
  116. topicgate/mcp/api/broker_api.py +48 -0
  117. topicgate/mcp/api/connection_api.py +83 -0
  118. topicgate/mcp/api/dashboard_api.py +790 -0
  119. topicgate/mcp/api/dashboard_snapshot.py +278 -0
  120. topicgate/mcp/api/mcp_api.py +18 -0
  121. topicgate/mcp/api/preview/dashboard_preview.py +6 -0
  122. topicgate/mcp/api/publish_api.py +62 -0
  123. topicgate/mcp/api/snapshot_api.py +78 -0
  124. topicgate/mcp/api/subscription_api.py +134 -0
  125. topicgate/mcp/api/topic_api.py +77 -0
  126. topicgate/mcp/capabilities.py +12 -0
  127. topicgate/mcp/instructions.py +3 -0
  128. topicgate/mcp/middleware/__init__.py +6 -0
  129. topicgate/mcp/middleware/error_handling_middleware.py +49 -0
  130. topicgate/mcp/middleware/logging_middleware.py +43 -0
  131. topicgate/mcp/models.py +28 -0
  132. topicgate/mcp/server.py +147 -0
  133. topicgate/paths.py +36 -0
  134. topicgate/presentation/__init__.py +39 -0
  135. topicgate/presentation/retention_presentation.py +187 -0
  136. topicgate/presentation/snapshot_presentation.py +130 -0
  137. topicgate/presentation/topic_presentation.py +482 -0
  138. topicgate/processors/__init__.py +0 -0
  139. topicgate/processors/observation_retention_processor.py +35 -0
  140. topicgate/processors/observer_model_mqtt_message_processor.py +79 -0
  141. topicgate/processors/observer_model_processor.py +223 -0
  142. topicgate/processors/subscription_manager.py +90 -0
  143. topicgate-1.1.0.dist-info/METADATA +222 -0
  144. topicgate-1.1.0.dist-info/RECORD +148 -0
  145. topicgate-1.1.0.dist-info/WHEEL +5 -0
  146. topicgate-1.1.0.dist-info/entry_points.txt +3 -0
  147. topicgate-1.1.0.dist-info/licenses/LICENCE +21 -0
  148. topicgate-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,403 @@
1
+ import asyncio
2
+ import base64
3
+ import math
4
+ import time
5
+ from collections.abc import Awaitable, Callable
6
+ from datetime import datetime, timezone
7
+ from uuid import UUID
8
+
9
+ from topicgate.app.models.broker_snapshot import (
10
+ BrokerSnapshot,
11
+ SnapshotBrokerIdentity,
12
+ SnapshotCompleteness,
13
+ SnapshotFreshness,
14
+ SnapshotLimitation,
15
+ SnapshotPayload,
16
+ SnapshotPayloadEncoding,
17
+ SnapshotResultLimit,
18
+ SnapshotSettling,
19
+ SnapshotTopicState,
20
+ SnapshotTopicStatus,
21
+ )
22
+ from topicgate.app.services.broker_resolver import BrokerResolver
23
+ from topicgate.app.topicgate_runtime import TopicGateRuntime
24
+ from topicgate.core.models.broker_summary import BrokerSummary
25
+ from topicgate.core.models.connection_status import ConnectionStatus
26
+ from topicgate.core.models.mqtt_observation import MqttObservation, ObservationSource
27
+ from topicgate.core.models.subscription import Subscription
28
+ from topicgate.core.mqtt_topics import mqtt_filter_matches
29
+ from topicgate.core.observer_limits import MAX_OBSERVED_TOPICS
30
+ from topicgate.core.payload_limits import MAX_RENDERED_PAYLOAD_BYTES
31
+
32
+ DEFAULT_SNAPSHOT_RESULT_LIMIT = 100
33
+ MAX_SNAPSHOT_RESULT_LIMIT = MAX_OBSERVED_TOPICS
34
+ DEFAULT_SNAPSHOT_WAIT_SECONDS = 1.0
35
+ MAX_SNAPSHOT_WAIT_SECONDS = 5.0
36
+
37
+ Clock = Callable[[], datetime]
38
+ MonotonicClock = Callable[[], float]
39
+ Sleep = Callable[[float], Awaitable[None]]
40
+
41
+
42
+ class BrokerSnapshotService:
43
+ """Build bounded broker snapshots for any application presentation layer."""
44
+
45
+ def __init__(
46
+ self,
47
+ runtime: TopicGateRuntime,
48
+ *,
49
+ resolver: BrokerResolver | None = None,
50
+ clock: Clock | None = None,
51
+ monotonic_clock: MonotonicClock | None = None,
52
+ sleep: Sleep | None = None,
53
+ ) -> None:
54
+ self._runtime = runtime
55
+ self._resolver = resolver or BrokerResolver(runtime)
56
+ self._clock = clock or (lambda: datetime.now(timezone.utc))
57
+ self._monotonic_clock = monotonic_clock or time.monotonic
58
+ self._sleep = sleep or asyncio.sleep
59
+
60
+ async def build(
61
+ self,
62
+ broker: UUID | str,
63
+ *,
64
+ topic_filter: str = "#",
65
+ max_age_seconds: float | None = None,
66
+ result_limit: int = DEFAULT_SNAPSHOT_RESULT_LIMIT,
67
+ payload_limit_bytes: int = MAX_RENDERED_PAYLOAD_BYTES,
68
+ ) -> BrokerSnapshot:
69
+ return self.build_current(
70
+ broker,
71
+ topic_filter=topic_filter,
72
+ max_age_seconds=max_age_seconds,
73
+ result_limit=result_limit,
74
+ payload_limit_bytes=payload_limit_bytes,
75
+ )
76
+
77
+ def build_current(
78
+ self,
79
+ broker: UUID | str,
80
+ *,
81
+ topic_filter: str = "#",
82
+ max_age_seconds: float | None = None,
83
+ result_limit: int = DEFAULT_SNAPSHOT_RESULT_LIMIT,
84
+ payload_limit_bytes: int = MAX_RENDERED_PAYLOAD_BYTES,
85
+ ) -> BrokerSnapshot:
86
+ """Build current broker state without activation, waiting, or I/O."""
87
+ resolved = self._resolver.resolve(broker)
88
+ validated_filter = self._validate_topic_filter(topic_filter)
89
+ max_age_seconds = self._validate_max_age(max_age_seconds)
90
+ result_limit = self._validate_result_limit(result_limit)
91
+ payload_limit_bytes = self._validate_payload_limit(payload_limit_bytes)
92
+
93
+ return self._capture(
94
+ resolved,
95
+ topic_filter=validated_filter,
96
+ max_age_seconds=max_age_seconds,
97
+ result_limit=result_limit,
98
+ payload_limit_bytes=payload_limit_bytes,
99
+ requested_wait_seconds=0.0,
100
+ actual_wait_seconds=0.0,
101
+ )
102
+
103
+ async def observe(
104
+ self,
105
+ broker: UUID | str,
106
+ *,
107
+ topic_filter: str = "#",
108
+ max_age_seconds: float | None = None,
109
+ result_limit: int = DEFAULT_SNAPSHOT_RESULT_LIMIT,
110
+ payload_limit_bytes: int = MAX_RENDERED_PAYLOAD_BYTES,
111
+ wait_seconds: float = DEFAULT_SNAPSHOT_WAIT_SECONDS,
112
+ ) -> BrokerSnapshot:
113
+ resolved = self._resolver.resolve(broker)
114
+ validated_filter = self._validate_topic_filter(topic_filter)
115
+ max_age_seconds = self._validate_max_age(max_age_seconds)
116
+ result_limit = self._validate_result_limit(result_limit)
117
+ payload_limit_bytes = self._validate_payload_limit(payload_limit_bytes)
118
+ wait_seconds = self._validate_wait_seconds(wait_seconds)
119
+
120
+ with self._runtime.control_operation("reconnect and observe"):
121
+ await self._runtime.activate_broker(resolved.id)
122
+ actual_wait_seconds = await self._wait(wait_seconds)
123
+ return self._capture(
124
+ resolved,
125
+ topic_filter=validated_filter,
126
+ max_age_seconds=max_age_seconds,
127
+ result_limit=result_limit,
128
+ payload_limit_bytes=payload_limit_bytes,
129
+ requested_wait_seconds=wait_seconds,
130
+ actual_wait_seconds=actual_wait_seconds,
131
+ )
132
+
133
+ def _capture(
134
+ self,
135
+ resolved: BrokerSummary,
136
+ *,
137
+ topic_filter: str,
138
+ max_age_seconds: float | None,
139
+ result_limit: int,
140
+ payload_limit_bytes: int,
141
+ requested_wait_seconds: float,
142
+ actual_wait_seconds: float,
143
+ ) -> BrokerSnapshot:
144
+ captured_at = self._as_utc(self._clock())
145
+ model = self._runtime.get_observer_model(resolved.id)
146
+ matching = sorted(
147
+ (
148
+ state
149
+ for state in model.topic_states.values()
150
+ if mqtt_filter_matches(topic_filter, state.topic)
151
+ ),
152
+ key=lambda state: state.topic,
153
+ )
154
+ aged = tuple(
155
+ (state, self._age_seconds(captured_at, state.received_at))
156
+ for state in matching
157
+ )
158
+ fresh = tuple(
159
+ (state, age)
160
+ for state, age in aged
161
+ if max_age_seconds is None or age <= max_age_seconds
162
+ )
163
+ stale_count = len(aged) - len(fresh)
164
+ selected = fresh[:result_limit]
165
+ omitted_by_limit = len(fresh) - len(selected)
166
+ status = self._runtime.get_connection_status(resolved.id)
167
+ dropped_message_count = self._runtime.get_dropped_message_count(
168
+ resolved.id
169
+ )
170
+ connected_at = self._runtime.get_connected_at(resolved.id)
171
+ observation_started_at = self._runtime.get_observation_started_at(
172
+ resolved.id
173
+ )
174
+ topics = tuple(
175
+ self._topic_snapshot(
176
+ state,
177
+ age,
178
+ payload_limit_bytes,
179
+ observation_started_at=observation_started_at,
180
+ )
181
+ for state, age in selected
182
+ )
183
+
184
+ observed_for_seconds = (
185
+ None
186
+ if observation_started_at is None
187
+ else self._age_seconds(captured_at, observation_started_at)
188
+ )
189
+ limitations = self._limitations(
190
+ status=status,
191
+ matching=matching,
192
+ topics=topics,
193
+ observation_started_at=observation_started_at,
194
+ dropped_message_count=dropped_message_count,
195
+ stale_count=stale_count,
196
+ omitted_by_limit=omitted_by_limit,
197
+ )
198
+
199
+ return BrokerSnapshot(
200
+ broker=SnapshotBrokerIdentity(resolved.id, resolved.name),
201
+ connection_status=self._status_value(status),
202
+ captured_at=captured_at,
203
+ connected_at=(
204
+ None if connected_at is None else self._as_utc(connected_at)
205
+ ),
206
+ observation_started_at=(
207
+ None
208
+ if observation_started_at is None
209
+ else self._as_utc(observation_started_at)
210
+ ),
211
+ observed_for_seconds=observed_for_seconds,
212
+ topic_filter=topic_filter,
213
+ topics=topics,
214
+ dropped_message_count=dropped_message_count,
215
+ freshness=SnapshotFreshness(max_age_seconds, stale_count),
216
+ results=SnapshotResultLimit(
217
+ limit=result_limit,
218
+ total=len(aged),
219
+ returned=len(topics),
220
+ omitted=len(aged) - len(topics),
221
+ omitted_as_stale=stale_count,
222
+ omitted_by_limit=omitted_by_limit,
223
+ truncated=omitted_by_limit > 0,
224
+ ),
225
+ settling=SnapshotSettling(
226
+ requested_seconds=requested_wait_seconds,
227
+ maximum_seconds=MAX_SNAPSHOT_WAIT_SECONDS,
228
+ actual_seconds=actual_wait_seconds,
229
+ ),
230
+ completeness=SnapshotCompleteness(False, limitations),
231
+ )
232
+
233
+ async def _wait(self, wait_seconds: float) -> float:
234
+ if wait_seconds == 0:
235
+ return 0.0
236
+ started_at = self._monotonic_clock()
237
+ await self._sleep(wait_seconds)
238
+ return max(0.0, self._monotonic_clock() - started_at)
239
+
240
+ @classmethod
241
+ def _topic_snapshot(
242
+ cls,
243
+ state: MqttObservation,
244
+ age_seconds: float,
245
+ payload_limit_bytes: int,
246
+ *,
247
+ observation_started_at: datetime | None,
248
+ ) -> SnapshotTopicState:
249
+ return SnapshotTopicState(
250
+ topic=state.topic,
251
+ payload=cls._render_payload(state, payload_limit_bytes),
252
+ qos=state.qos,
253
+ retain=state.retain,
254
+ received_at=cls._as_utc(state.received_at),
255
+ age_seconds=age_seconds,
256
+ message_count=state.message_count,
257
+ source=state.source,
258
+ status=cls._topic_status(state, observation_started_at),
259
+ )
260
+
261
+ @classmethod
262
+ def _topic_status(
263
+ cls,
264
+ state: MqttObservation,
265
+ observation_started_at: datetime | None,
266
+ ) -> SnapshotTopicStatus:
267
+ if (
268
+ observation_started_at is not None
269
+ and cls._as_utc(state.received_at)
270
+ < cls._as_utc(observation_started_at)
271
+ ):
272
+ return SnapshotTopicStatus.STALE
273
+ if state.source == ObservationSource.STORED:
274
+ return SnapshotTopicStatus.CACHED
275
+ return SnapshotTopicStatus.LIVE
276
+
277
+ @staticmethod
278
+ def _render_payload(
279
+ state: MqttObservation,
280
+ payload_limit_bytes: int,
281
+ ) -> SnapshotPayload:
282
+ available = bytes(state.payload)
283
+ original_size = max(state.payload_size or 0, len(available))
284
+ rendered = available[:payload_limit_bytes]
285
+ try:
286
+ available.decode("utf-8")
287
+ except UnicodeDecodeError:
288
+ encoding = SnapshotPayloadEncoding.BASE64
289
+ value = base64.b64encode(rendered).decode("ascii")
290
+ rendered_size = len(rendered)
291
+ else:
292
+ encoding = SnapshotPayloadEncoding.UTF8
293
+ value = rendered.decode("utf-8", errors="ignore")
294
+ rendered_size = len(value.encode("utf-8"))
295
+
296
+ ingestion_truncated = original_size > len(available)
297
+ rendering_truncated = rendered_size < len(available)
298
+ return SnapshotPayload(
299
+ encoding=encoding,
300
+ value=value,
301
+ original_size=original_size,
302
+ available_size=len(available),
303
+ rendered_size=rendered_size,
304
+ ingestion_truncated=ingestion_truncated,
305
+ rendering_truncated=rendering_truncated,
306
+ truncated=ingestion_truncated or rendering_truncated,
307
+ )
308
+
309
+ @staticmethod
310
+ def _limitations(
311
+ *,
312
+ status: object,
313
+ matching: list[MqttObservation],
314
+ topics: tuple[SnapshotTopicState, ...],
315
+ observation_started_at: datetime | None,
316
+ dropped_message_count: int,
317
+ stale_count: int,
318
+ omitted_by_limit: int,
319
+ ) -> tuple[SnapshotLimitation, ...]:
320
+ limitations = [
321
+ SnapshotLimitation.CURRENT_STATE_ONLY,
322
+ SnapshotLimitation.RETAINED_DELIVERY_UNCONFIRMED,
323
+ ]
324
+ if BrokerSnapshotService._status_value(status) != ConnectionStatus.CONNECTED:
325
+ limitations.append(SnapshotLimitation.BROKER_DISCONNECTED)
326
+ if observation_started_at is None:
327
+ limitations.append(SnapshotLimitation.OBSERVATION_NOT_STARTED)
328
+ if any(state.source == ObservationSource.STORED for state in matching):
329
+ limitations.append(
330
+ SnapshotLimitation.STORED_STATE_PREDATES_OBSERVATION
331
+ )
332
+ if dropped_message_count:
333
+ limitations.append(SnapshotLimitation.DROPPED_MESSAGES)
334
+ if stale_count:
335
+ limitations.append(SnapshotLimitation.STALE_STATES_OMITTED)
336
+ if omitted_by_limit:
337
+ limitations.append(SnapshotLimitation.RESULT_LIMIT_REACHED)
338
+ if any(topic.payload.truncated for topic in topics):
339
+ limitations.append(SnapshotLimitation.PAYLOAD_TRUNCATED)
340
+ return tuple(limitations)
341
+
342
+ @staticmethod
343
+ def _validate_topic_filter(topic_filter: str) -> str:
344
+ return Subscription(topic_filter).topic_filter
345
+
346
+ @staticmethod
347
+ def _validate_max_age(max_age_seconds: float | None) -> float | None:
348
+ if max_age_seconds is None:
349
+ return None
350
+ value = float(max_age_seconds)
351
+ if not math.isfinite(value) or value < 0:
352
+ raise ValueError("max_age_seconds must be a finite non-negative value.")
353
+ return value
354
+
355
+ @staticmethod
356
+ def _validate_result_limit(result_limit: int) -> int:
357
+ if isinstance(result_limit, bool) or not isinstance(result_limit, int):
358
+ raise ValueError("result_limit must be an integer.")
359
+ if not 1 <= result_limit <= MAX_SNAPSHOT_RESULT_LIMIT:
360
+ raise ValueError(
361
+ f"result_limit must be between 1 and {MAX_SNAPSHOT_RESULT_LIMIT}."
362
+ )
363
+ return result_limit
364
+
365
+ @staticmethod
366
+ def _validate_payload_limit(payload_limit_bytes: int) -> int:
367
+ if isinstance(payload_limit_bytes, bool) or not isinstance(
368
+ payload_limit_bytes, int
369
+ ):
370
+ raise ValueError("payload_limit_bytes must be an integer.")
371
+ if not 0 <= payload_limit_bytes <= MAX_RENDERED_PAYLOAD_BYTES:
372
+ raise ValueError(
373
+ "payload_limit_bytes must be between 0 and "
374
+ f"{MAX_RENDERED_PAYLOAD_BYTES}."
375
+ )
376
+ return payload_limit_bytes
377
+
378
+ @staticmethod
379
+ def _validate_wait_seconds(wait_seconds: float) -> float:
380
+ value = float(wait_seconds)
381
+ if not math.isfinite(value) or not 0 <= value <= MAX_SNAPSHOT_WAIT_SECONDS:
382
+ raise ValueError(
383
+ "wait_seconds must be between 0 and "
384
+ f"{MAX_SNAPSHOT_WAIT_SECONDS}."
385
+ )
386
+ return value
387
+
388
+ @staticmethod
389
+ def _age_seconds(captured_at: datetime, observed_at: datetime) -> float:
390
+ return max(
391
+ 0.0,
392
+ (captured_at - BrokerSnapshotService._as_utc(observed_at)).total_seconds(),
393
+ )
394
+
395
+ @staticmethod
396
+ def _as_utc(value: datetime) -> datetime:
397
+ if value.tzinfo is None:
398
+ return value.replace(tzinfo=timezone.utc)
399
+ return value.astimezone(timezone.utc)
400
+
401
+ @staticmethod
402
+ def _status_value(status: object) -> str:
403
+ return str(getattr(status, "value", status)).lower()
@@ -0,0 +1,158 @@
1
+ from collections.abc import Iterator
2
+ from contextlib import contextmanager
3
+ from contextvars import ContextVar
4
+ import threading
5
+ import time
6
+ from uuid import uuid4
7
+
8
+ from sqlalchemy import text
9
+
10
+ from topicgate.infrastructure.database.database_context import DatabaseContext
11
+
12
+
13
+ class ControlOperationConflict(RuntimeError):
14
+ """Raised when another TopicGate process owns the control lease."""
15
+
16
+
17
+ class ControlOperationService:
18
+ """Coordinate state-changing desktop and MCP work across processes."""
19
+
20
+ def __init__(
21
+ self,
22
+ database: DatabaseContext,
23
+ owner: str,
24
+ *,
25
+ lease_seconds: float = 30.0,
26
+ ) -> None:
27
+ self._database = database
28
+ self._owner = owner
29
+ self._lease_seconds = lease_seconds
30
+ self._token: ContextVar[str | None] = ContextVar(
31
+ "topicgate_control_lease_token",
32
+ default=None,
33
+ )
34
+ with self._database.session() as session:
35
+ self._seen_generation = int(
36
+ session.execute(
37
+ text(
38
+ "SELECT generation FROM control_operation_state WHERE id = 1"
39
+ )
40
+ ).scalar_one()
41
+ )
42
+
43
+ @contextmanager
44
+ def operation(self, name: str) -> Iterator[None]:
45
+ inherited_token = self._token.get()
46
+ if inherited_token is not None:
47
+ yield
48
+ return
49
+
50
+ token = str(uuid4())
51
+ self._acquire(name, token)
52
+ context_token = self._token.set(token)
53
+ stopped = threading.Event()
54
+ renewer = threading.Thread(
55
+ target=self._renew_until_stopped,
56
+ args=(name, token, stopped),
57
+ daemon=True,
58
+ name="topicgate-control-lease",
59
+ )
60
+ renewer.start()
61
+ try:
62
+ yield
63
+ finally:
64
+ stopped.set()
65
+ renewer.join(timeout=1.0)
66
+ self._release(token)
67
+ self._token.reset(context_token)
68
+
69
+ def _acquire(self, name: str, token: str) -> None:
70
+ now = time.time()
71
+ expires_at = now + self._lease_seconds
72
+ statement = text(
73
+ "INSERT INTO control_operation_lease "
74
+ "(id, owner, operation, token, expires_at) "
75
+ "VALUES (1, :owner, :operation, :token, :expires_at) "
76
+ "ON CONFLICT(id) DO UPDATE SET "
77
+ "owner = excluded.owner, operation = excluded.operation, "
78
+ "token = excluded.token, expires_at = excluded.expires_at "
79
+ "WHERE control_operation_lease.expires_at <= :now"
80
+ )
81
+ with self._database.transaction() as session:
82
+ generation = int(
83
+ session.execute(
84
+ text(
85
+ "SELECT generation FROM control_operation_state WHERE id = 1"
86
+ )
87
+ ).scalar_one()
88
+ )
89
+ if generation != self._seen_generation:
90
+ raise ControlOperationConflict(
91
+ "TopicGate configuration changed in another desktop or MCP "
92
+ "process. Restart this process to reload the latest broker, "
93
+ "subscription, retention, and credential state before retrying."
94
+ )
95
+ result = session.execute(
96
+ statement,
97
+ {
98
+ "owner": self._owner,
99
+ "operation": name,
100
+ "token": token,
101
+ "expires_at": expires_at,
102
+ "now": now,
103
+ },
104
+ )
105
+ if result.rowcount == 1:
106
+ return
107
+ conflict = session.execute(
108
+ text(
109
+ "SELECT owner, operation, expires_at "
110
+ "FROM control_operation_lease WHERE id = 1"
111
+ )
112
+ ).one()
113
+ remaining = max(0.0, float(conflict.expires_at) - now)
114
+ raise ControlOperationConflict(
115
+ f"TopicGate {conflict.owner} is already running "
116
+ f"'{conflict.operation}'. Retry after it finishes "
117
+ f"(lease expires in at most {remaining:.0f} seconds)."
118
+ )
119
+
120
+ def _renew_until_stopped(
121
+ self,
122
+ name: str,
123
+ token: str,
124
+ stopped: threading.Event,
125
+ ) -> None:
126
+ interval = max(0.01, self._lease_seconds / 3)
127
+ while not stopped.wait(interval):
128
+ with self._database.transaction() as session:
129
+ session.execute(
130
+ text(
131
+ "UPDATE control_operation_lease "
132
+ "SET operation = :operation, expires_at = :expires_at "
133
+ "WHERE id = 1 AND token = :token"
134
+ ),
135
+ {
136
+ "operation": name,
137
+ "expires_at": time.time() + self._lease_seconds,
138
+ "token": token,
139
+ },
140
+ )
141
+
142
+ def _release(self, token: str) -> None:
143
+ with self._database.transaction() as session:
144
+ deleted = session.execute(
145
+ text(
146
+ "DELETE FROM control_operation_lease "
147
+ "WHERE id = 1 AND token = :token"
148
+ ),
149
+ {"token": token},
150
+ )
151
+ if deleted.rowcount == 1:
152
+ session.execute(
153
+ text(
154
+ "UPDATE control_operation_state "
155
+ "SET generation = generation + 1 WHERE id = 1"
156
+ )
157
+ )
158
+ self._seen_generation += 1