py-data-engine 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 (200) hide show
  1. data_engine/__init__.py +37 -0
  2. data_engine/application/__init__.py +39 -0
  3. data_engine/application/actions.py +42 -0
  4. data_engine/application/catalog.py +151 -0
  5. data_engine/application/control.py +213 -0
  6. data_engine/application/details.py +73 -0
  7. data_engine/application/runtime.py +449 -0
  8. data_engine/application/workspace.py +62 -0
  9. data_engine/authoring/__init__.py +14 -0
  10. data_engine/authoring/builder.py +31 -0
  11. data_engine/authoring/execution/__init__.py +6 -0
  12. data_engine/authoring/execution/app.py +6 -0
  13. data_engine/authoring/execution/context.py +82 -0
  14. data_engine/authoring/execution/continuous.py +176 -0
  15. data_engine/authoring/execution/grouped.py +106 -0
  16. data_engine/authoring/execution/logging.py +83 -0
  17. data_engine/authoring/execution/polling.py +135 -0
  18. data_engine/authoring/execution/runner.py +210 -0
  19. data_engine/authoring/execution/single.py +171 -0
  20. data_engine/authoring/flow.py +361 -0
  21. data_engine/authoring/helpers.py +160 -0
  22. data_engine/authoring/model.py +59 -0
  23. data_engine/authoring/primitives.py +430 -0
  24. data_engine/authoring/services.py +42 -0
  25. data_engine/devtools/__init__.py +3 -0
  26. data_engine/devtools/project_ast_map.py +503 -0
  27. data_engine/docs/__init__.py +1 -0
  28. data_engine/docs/sphinx_source/_static/custom.css +13 -0
  29. data_engine/docs/sphinx_source/api.rst +42 -0
  30. data_engine/docs/sphinx_source/conf.py +37 -0
  31. data_engine/docs/sphinx_source/guides/app-runtime-and-workspaces.md +397 -0
  32. data_engine/docs/sphinx_source/guides/authoring-flow-modules.md +215 -0
  33. data_engine/docs/sphinx_source/guides/configuring-flows.md +185 -0
  34. data_engine/docs/sphinx_source/guides/core-concepts.md +208 -0
  35. data_engine/docs/sphinx_source/guides/database-methods.md +107 -0
  36. data_engine/docs/sphinx_source/guides/duckdb-helpers.md +462 -0
  37. data_engine/docs/sphinx_source/guides/flow-context.md +538 -0
  38. data_engine/docs/sphinx_source/guides/flow-methods.md +206 -0
  39. data_engine/docs/sphinx_source/guides/getting-started.md +271 -0
  40. data_engine/docs/sphinx_source/guides/project-inventory.md +5683 -0
  41. data_engine/docs/sphinx_source/guides/project-map.md +118 -0
  42. data_engine/docs/sphinx_source/guides/recipes.md +268 -0
  43. data_engine/docs/sphinx_source/index.rst +22 -0
  44. data_engine/domain/__init__.py +92 -0
  45. data_engine/domain/actions.py +69 -0
  46. data_engine/domain/catalog.py +128 -0
  47. data_engine/domain/details.py +214 -0
  48. data_engine/domain/diagnostics.py +56 -0
  49. data_engine/domain/errors.py +104 -0
  50. data_engine/domain/inspection.py +99 -0
  51. data_engine/domain/logs.py +118 -0
  52. data_engine/domain/operations.py +172 -0
  53. data_engine/domain/operator.py +72 -0
  54. data_engine/domain/runs.py +155 -0
  55. data_engine/domain/runtime.py +279 -0
  56. data_engine/domain/source_state.py +17 -0
  57. data_engine/domain/support.py +54 -0
  58. data_engine/domain/time.py +23 -0
  59. data_engine/domain/workspace.py +159 -0
  60. data_engine/flow_modules/__init__.py +1 -0
  61. data_engine/flow_modules/flow_module_compiler.py +179 -0
  62. data_engine/flow_modules/flow_module_loader.py +201 -0
  63. data_engine/helpers/__init__.py +25 -0
  64. data_engine/helpers/duckdb.py +705 -0
  65. data_engine/hosts/__init__.py +1 -0
  66. data_engine/hosts/daemon/__init__.py +23 -0
  67. data_engine/hosts/daemon/app.py +221 -0
  68. data_engine/hosts/daemon/bootstrap.py +69 -0
  69. data_engine/hosts/daemon/client.py +465 -0
  70. data_engine/hosts/daemon/commands.py +64 -0
  71. data_engine/hosts/daemon/composition.py +310 -0
  72. data_engine/hosts/daemon/constants.py +15 -0
  73. data_engine/hosts/daemon/entrypoints.py +97 -0
  74. data_engine/hosts/daemon/lifecycle.py +191 -0
  75. data_engine/hosts/daemon/manager.py +272 -0
  76. data_engine/hosts/daemon/ownership.py +126 -0
  77. data_engine/hosts/daemon/runtime_commands.py +188 -0
  78. data_engine/hosts/daemon/runtime_control.py +31 -0
  79. data_engine/hosts/daemon/server.py +84 -0
  80. data_engine/hosts/daemon/shared_state.py +147 -0
  81. data_engine/hosts/daemon/state_sync.py +101 -0
  82. data_engine/platform/__init__.py +1 -0
  83. data_engine/platform/identity.py +35 -0
  84. data_engine/platform/local_settings.py +146 -0
  85. data_engine/platform/theme.py +259 -0
  86. data_engine/platform/workspace_models.py +190 -0
  87. data_engine/platform/workspace_policy.py +333 -0
  88. data_engine/runtime/__init__.py +1 -0
  89. data_engine/runtime/file_watch.py +185 -0
  90. data_engine/runtime/ledger_models.py +116 -0
  91. data_engine/runtime/runtime_db.py +938 -0
  92. data_engine/runtime/shared_state.py +523 -0
  93. data_engine/services/__init__.py +49 -0
  94. data_engine/services/daemon.py +64 -0
  95. data_engine/services/daemon_state.py +40 -0
  96. data_engine/services/flow_catalog.py +102 -0
  97. data_engine/services/flow_execution.py +48 -0
  98. data_engine/services/ledger.py +85 -0
  99. data_engine/services/logs.py +65 -0
  100. data_engine/services/runtime_binding.py +105 -0
  101. data_engine/services/runtime_execution.py +126 -0
  102. data_engine/services/runtime_history.py +62 -0
  103. data_engine/services/settings.py +58 -0
  104. data_engine/services/shared_state.py +28 -0
  105. data_engine/services/theme.py +59 -0
  106. data_engine/services/workspace_provisioning.py +224 -0
  107. data_engine/services/workspaces.py +74 -0
  108. data_engine/ui/__init__.py +3 -0
  109. data_engine/ui/cli/__init__.py +19 -0
  110. data_engine/ui/cli/app.py +161 -0
  111. data_engine/ui/cli/commands_doctor.py +178 -0
  112. data_engine/ui/cli/commands_run.py +80 -0
  113. data_engine/ui/cli/commands_start.py +100 -0
  114. data_engine/ui/cli/commands_workspace.py +97 -0
  115. data_engine/ui/cli/dependencies.py +44 -0
  116. data_engine/ui/cli/parser.py +56 -0
  117. data_engine/ui/gui/__init__.py +25 -0
  118. data_engine/ui/gui/app.py +116 -0
  119. data_engine/ui/gui/bootstrap.py +487 -0
  120. data_engine/ui/gui/bootstrapper.py +140 -0
  121. data_engine/ui/gui/cache_models.py +23 -0
  122. data_engine/ui/gui/control_support.py +185 -0
  123. data_engine/ui/gui/controllers/__init__.py +6 -0
  124. data_engine/ui/gui/controllers/flows.py +439 -0
  125. data_engine/ui/gui/controllers/runtime.py +245 -0
  126. data_engine/ui/gui/dialogs/__init__.py +12 -0
  127. data_engine/ui/gui/dialogs/messages.py +88 -0
  128. data_engine/ui/gui/dialogs/previews.py +222 -0
  129. data_engine/ui/gui/helpers/__init__.py +62 -0
  130. data_engine/ui/gui/helpers/inspection.py +81 -0
  131. data_engine/ui/gui/helpers/lifecycle.py +112 -0
  132. data_engine/ui/gui/helpers/scroll.py +28 -0
  133. data_engine/ui/gui/helpers/theming.py +87 -0
  134. data_engine/ui/gui/icons/dark_light.svg +12 -0
  135. data_engine/ui/gui/icons/documentation.svg +1 -0
  136. data_engine/ui/gui/icons/failed.svg +3 -0
  137. data_engine/ui/gui/icons/group.svg +4 -0
  138. data_engine/ui/gui/icons/home.svg +2 -0
  139. data_engine/ui/gui/icons/manual.svg +2 -0
  140. data_engine/ui/gui/icons/poll.svg +2 -0
  141. data_engine/ui/gui/icons/schedule.svg +4 -0
  142. data_engine/ui/gui/icons/settings.svg +2 -0
  143. data_engine/ui/gui/icons/started.svg +3 -0
  144. data_engine/ui/gui/icons/success.svg +3 -0
  145. data_engine/ui/gui/icons/view-log.svg +3 -0
  146. data_engine/ui/gui/icons.py +50 -0
  147. data_engine/ui/gui/launcher.py +48 -0
  148. data_engine/ui/gui/presenters/__init__.py +72 -0
  149. data_engine/ui/gui/presenters/docs.py +140 -0
  150. data_engine/ui/gui/presenters/logs.py +58 -0
  151. data_engine/ui/gui/presenters/runtime_projection.py +29 -0
  152. data_engine/ui/gui/presenters/sidebar.py +88 -0
  153. data_engine/ui/gui/presenters/steps.py +148 -0
  154. data_engine/ui/gui/presenters/workspace.py +39 -0
  155. data_engine/ui/gui/presenters/workspace_binding.py +75 -0
  156. data_engine/ui/gui/presenters/workspace_settings.py +182 -0
  157. data_engine/ui/gui/preview_models.py +37 -0
  158. data_engine/ui/gui/render_support.py +241 -0
  159. data_engine/ui/gui/rendering/__init__.py +12 -0
  160. data_engine/ui/gui/rendering/artifacts.py +95 -0
  161. data_engine/ui/gui/rendering/icons.py +50 -0
  162. data_engine/ui/gui/runtime.py +47 -0
  163. data_engine/ui/gui/state_support.py +193 -0
  164. data_engine/ui/gui/support.py +214 -0
  165. data_engine/ui/gui/surface.py +209 -0
  166. data_engine/ui/gui/theme.py +720 -0
  167. data_engine/ui/gui/widgets/__init__.py +34 -0
  168. data_engine/ui/gui/widgets/config.py +41 -0
  169. data_engine/ui/gui/widgets/logs.py +62 -0
  170. data_engine/ui/gui/widgets/panels.py +507 -0
  171. data_engine/ui/gui/widgets/sidebar.py +130 -0
  172. data_engine/ui/gui/widgets/steps.py +84 -0
  173. data_engine/ui/tui/__init__.py +5 -0
  174. data_engine/ui/tui/app.py +222 -0
  175. data_engine/ui/tui/bootstrap.py +475 -0
  176. data_engine/ui/tui/bootstrapper.py +117 -0
  177. data_engine/ui/tui/controllers/__init__.py +6 -0
  178. data_engine/ui/tui/controllers/flows.py +349 -0
  179. data_engine/ui/tui/controllers/runtime.py +167 -0
  180. data_engine/ui/tui/runtime.py +34 -0
  181. data_engine/ui/tui/state_support.py +141 -0
  182. data_engine/ui/tui/support.py +63 -0
  183. data_engine/ui/tui/theme.py +204 -0
  184. data_engine/ui/tui/widgets.py +123 -0
  185. data_engine/views/__init__.py +109 -0
  186. data_engine/views/actions.py +80 -0
  187. data_engine/views/artifacts.py +58 -0
  188. data_engine/views/flow_display.py +69 -0
  189. data_engine/views/logs.py +54 -0
  190. data_engine/views/models.py +96 -0
  191. data_engine/views/presentation.py +133 -0
  192. data_engine/views/runs.py +62 -0
  193. data_engine/views/state.py +39 -0
  194. data_engine/views/status.py +13 -0
  195. data_engine/views/text.py +109 -0
  196. py_data_engine-0.1.0.dist-info/METADATA +330 -0
  197. py_data_engine-0.1.0.dist-info/RECORD +200 -0
  198. py_data_engine-0.1.0.dist-info/WHEEL +5 -0
  199. py_data_engine-0.1.0.dist-info/entry_points.txt +2 -0
  200. py_data_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,272 @@
1
+ """Client-side daemon state management for Data Engine workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ import os
8
+ import subprocess
9
+
10
+ from data_engine.domain.time import parse_utc_text
11
+ from data_engine.hosts.daemon.app import DaemonClientError, daemon_request, is_daemon_live
12
+ from data_engine.hosts.daemon.shared_state import DaemonSharedStateAdapter
13
+ from data_engine.domain import WorkspaceControlState
14
+ from data_engine.platform.workspace_models import WorkspacePaths, machine_id_text
15
+
16
+ _DEFAULT_SHARED_STATE_ADAPTER = DaemonSharedStateAdapter()
17
+
18
+
19
+ def read_lease_metadata(paths: WorkspacePaths) -> dict[str, object] | None:
20
+ """Compatibility seam for reading lease metadata in daemon-manager tests."""
21
+ return _DEFAULT_SHARED_STATE_ADAPTER.read_lease_metadata(paths)
22
+
23
+
24
+ def recover_stale_workspace(*, paths: WorkspacePaths, machine_id: str, stale_after_seconds: float) -> bool:
25
+ """Compatibility seam for stale-lease recovery in daemon-manager tests."""
26
+ return _DEFAULT_SHARED_STATE_ADAPTER.recover_stale_workspace(
27
+ paths,
28
+ machine_id=machine_id,
29
+ stale_after_seconds=stale_after_seconds,
30
+ )
31
+
32
+
33
+ def write_control_request(paths: WorkspacePaths, **kwargs: object) -> None:
34
+ """Compatibility seam for control-request writes in daemon-manager tests."""
35
+ _DEFAULT_SHARED_STATE_ADAPTER.write_control_request(paths, **kwargs)
36
+
37
+
38
+ def _lease_pid_is_live(metadata: dict[str, object] | None) -> bool:
39
+ """Return whether the recorded lease owner pid is still alive."""
40
+ if not isinstance(metadata, dict):
41
+ return False
42
+ pid_value = metadata.get("pid")
43
+ try:
44
+ pid = int(pid_value)
45
+ except (TypeError, ValueError):
46
+ return False
47
+ if pid <= 0:
48
+ return False
49
+ try:
50
+ os.kill(pid, 0)
51
+ except OSError:
52
+ return False
53
+ try:
54
+ # Refine the basic PID-exists check by rejecting zombies when process
55
+ # status inspection is available. If the host blocks `ps` (for example
56
+ # in a restricted sandbox), keep the conservative answer from os.kill:
57
+ # treat the PID as live so we don't incorrectly reclaim a genuinely
58
+ # running local owner.
59
+ result = subprocess.run(
60
+ ["ps", "-o", "stat=", "-p", str(pid)],
61
+ capture_output=True,
62
+ text=True,
63
+ check=False,
64
+ )
65
+ except OSError:
66
+ return True
67
+ if result.returncode != 0:
68
+ return False
69
+ status_text = result.stdout.strip()
70
+ if not status_text:
71
+ return False
72
+ if status_text.split()[0].startswith("Z"):
73
+ return False
74
+ return True
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class WorkspaceDaemonSnapshot:
79
+ """Normalized client view of one workspace daemon state."""
80
+
81
+ live: bool
82
+ workspace_owned: bool
83
+ leased_by_machine_id: str | None
84
+ runtime_active: bool
85
+ runtime_stopping: bool
86
+ manual_runs: tuple[str, ...]
87
+ last_checkpoint_at_utc: str | None
88
+ source: str
89
+
90
+
91
+ class WorkspaceDaemonManager:
92
+ """Track daemon liveness and normalize fallback state for one workspace."""
93
+
94
+ def __init__(
95
+ self,
96
+ paths: WorkspacePaths,
97
+ *,
98
+ max_sync_misses: int = 3,
99
+ shared_state_adapter: DaemonSharedStateAdapter | None = None,
100
+ ) -> None:
101
+ self.paths = paths
102
+ self.max_sync_misses = max(max_sync_misses, 1)
103
+ self.shared_state_adapter = shared_state_adapter or DaemonSharedStateAdapter()
104
+ self._daemon_live = False
105
+ self._sync_misses = 0
106
+ self._last_snapshot: WorkspaceDaemonSnapshot | None = None
107
+
108
+ @property
109
+ def daemon_live(self) -> bool:
110
+ """Return the most recent daemon liveness result."""
111
+ return self._daemon_live
112
+
113
+ def sync(self) -> WorkspaceDaemonSnapshot:
114
+ """Return the latest normalized daemon snapshot for one workspace."""
115
+ try:
116
+ live = is_daemon_live(self.paths)
117
+ except Exception:
118
+ live = False
119
+ self._daemon_live = live
120
+ if not live:
121
+ self._sync_misses += 1
122
+ if self._sync_misses < self.max_sync_misses and self._last_snapshot is not None:
123
+ return WorkspaceDaemonSnapshot(
124
+ live=False,
125
+ workspace_owned=self._last_snapshot.workspace_owned,
126
+ leased_by_machine_id=self._last_snapshot.leased_by_machine_id,
127
+ runtime_active=self._last_snapshot.runtime_active,
128
+ runtime_stopping=self._last_snapshot.runtime_stopping,
129
+ manual_runs=self._last_snapshot.manual_runs,
130
+ last_checkpoint_at_utc=self._last_snapshot.last_checkpoint_at_utc,
131
+ source="cached",
132
+ )
133
+ snapshot = self._lease_snapshot()
134
+ self._last_snapshot = snapshot
135
+ return snapshot
136
+ try:
137
+ response = daemon_request(self.paths, {"command": "daemon_status"}, timeout=0.5)
138
+ except DaemonClientError:
139
+ self._sync_misses += 1
140
+ if self._last_snapshot is not None:
141
+ return WorkspaceDaemonSnapshot(
142
+ live=False,
143
+ workspace_owned=self._last_snapshot.workspace_owned,
144
+ leased_by_machine_id=self._last_snapshot.leased_by_machine_id,
145
+ runtime_active=self._last_snapshot.runtime_active,
146
+ runtime_stopping=self._last_snapshot.runtime_stopping,
147
+ manual_runs=self._last_snapshot.manual_runs,
148
+ last_checkpoint_at_utc=self._last_snapshot.last_checkpoint_at_utc,
149
+ source="cached",
150
+ )
151
+ snapshot = self._lease_snapshot()
152
+ self._last_snapshot = snapshot
153
+ return snapshot
154
+ status = response.get("status") if response.get("ok") else None
155
+ if not isinstance(status, dict):
156
+ snapshot = self._lease_snapshot()
157
+ self._last_snapshot = snapshot
158
+ return snapshot
159
+ self._sync_misses = 0
160
+ manual_runs = tuple(name for name in status.get("manual_runs", []) if isinstance(name, str))
161
+ leased_by = status.get("leased_by_machine_id")
162
+ checkpoint = status.get("last_checkpoint_at_utc")
163
+ snapshot = WorkspaceDaemonSnapshot(
164
+ live=True,
165
+ workspace_owned=bool(status.get("workspace_owned", True)),
166
+ leased_by_machine_id=str(leased_by) if isinstance(leased_by, str) and leased_by.strip() else None,
167
+ runtime_active=bool(status.get("engine_active")),
168
+ runtime_stopping=bool(status.get("engine_stopping")),
169
+ manual_runs=manual_runs,
170
+ last_checkpoint_at_utc=str(checkpoint) if isinstance(checkpoint, str) and checkpoint.strip() else None,
171
+ source="daemon",
172
+ )
173
+ self._last_snapshot = snapshot
174
+ return snapshot
175
+
176
+ def _lease_snapshot(self) -> WorkspaceDaemonSnapshot:
177
+ metadata = read_lease_metadata(self.paths)
178
+ local_machine_id = machine_id_text()
179
+ if (
180
+ isinstance(metadata, dict)
181
+ and str(metadata.get("machine_id", "")).strip() == local_machine_id
182
+ and not _lease_pid_is_live(metadata)
183
+ ):
184
+ recovered = recover_stale_workspace(
185
+ paths=self.paths,
186
+ machine_id=local_machine_id,
187
+ stale_after_seconds=0.0,
188
+ )
189
+ if recovered:
190
+ metadata = read_lease_metadata(self.paths)
191
+ owner = metadata.get("machine_id") if isinstance(metadata, dict) else None
192
+ checkpoint = metadata.get("last_checkpoint_at_utc") if isinstance(metadata, dict) else None
193
+ checkpoint_text = str(checkpoint) if isinstance(checkpoint, str) and checkpoint.strip() else None
194
+ if checkpoint_text is not None and parse_utc_text(checkpoint_text) is None:
195
+ checkpoint_text = None
196
+ return WorkspaceDaemonSnapshot(
197
+ live=False,
198
+ workspace_owned=metadata is None,
199
+ leased_by_machine_id=str(owner) if isinstance(owner, str) and owner.strip() else None,
200
+ runtime_active=False,
201
+ runtime_stopping=False,
202
+ manual_runs=(),
203
+ last_checkpoint_at_utc=checkpoint_text,
204
+ source="lease" if metadata is not None else "none",
205
+ )
206
+
207
+ def control_status_text(
208
+ self,
209
+ snapshot: WorkspaceDaemonSnapshot,
210
+ *,
211
+ daemon_startup_in_progress: bool = False,
212
+ ) -> str | None:
213
+ """Return plain-language control status text for UI/TUI display."""
214
+ return self.control_state(
215
+ snapshot,
216
+ daemon_startup_in_progress=daemon_startup_in_progress,
217
+ ).control_status_text
218
+
219
+ def leased_elsewhere_status_text(self, snapshot: WorkspaceDaemonSnapshot) -> str:
220
+ """Return plain-language action-blocked status for another owner."""
221
+ return self.control_state(snapshot).blocked_status_text
222
+
223
+ def control_state(
224
+ self,
225
+ snapshot: WorkspaceDaemonSnapshot,
226
+ *,
227
+ daemon_startup_in_progress: bool = False,
228
+ ) -> WorkspaceControlState:
229
+ """Return the structured workspace control state for one snapshot."""
230
+ return WorkspaceControlState.from_snapshot(
231
+ snapshot,
232
+ daemon_live=self.daemon_live,
233
+ local_machine_id=machine_id_text(),
234
+ control_request=self.shared_state_adapter.read_control_request(self.paths),
235
+ daemon_startup_in_progress=daemon_startup_in_progress,
236
+ )
237
+
238
+ def request_control(self) -> str:
239
+ """Record one control-transfer request for the current workstation."""
240
+ if self._daemon_live:
241
+ snapshot = self.sync()
242
+ if snapshot.workspace_owned:
243
+ return "This workstation already has control."
244
+ metadata = read_lease_metadata(self.paths)
245
+ owner = (
246
+ str(metadata.get("machine_id")).strip()
247
+ if isinstance(metadata, dict) and isinstance(metadata.get("machine_id"), str)
248
+ else ""
249
+ )
250
+ local_machine_id = machine_id_text()
251
+ if owner == local_machine_id and not self._daemon_live:
252
+ if not _lease_pid_is_live(metadata):
253
+ recovered = recover_stale_workspace(
254
+ paths=self.paths,
255
+ machine_id=local_machine_id,
256
+ stale_after_seconds=0.0,
257
+ )
258
+ if recovered:
259
+ return "Recovered local control."
260
+ write_control_request(
261
+ self.paths,
262
+ workspace_id=self.paths.workspace_id,
263
+ requester_machine_id=local_machine_id,
264
+ requester_host_name=local_machine_id,
265
+ requester_pid=os.getpid(),
266
+ requester_client_kind="ui",
267
+ requested_at_utc=datetime.now(UTC).isoformat(),
268
+ )
269
+ return "Control request sent."
270
+
271
+
272
+ __all__ = ["WorkspaceDaemonManager", "WorkspaceDaemonSnapshot"]
@@ -0,0 +1,126 @@
1
+ """Workspace ownership helpers for the daemon host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from data_engine.hosts.daemon.app import DataEngineDaemonService
9
+
10
+
11
+ def control_request_metadata(service: "DataEngineDaemonService") -> dict[str, object] | None:
12
+ metadata = service.shared_state_adapter.read_control_request(service.paths)
13
+ return metadata if isinstance(metadata, dict) else None
14
+
15
+
16
+ def honor_control_request_if_needed(service: "DataEngineDaemonService") -> bool:
17
+ """Relinquish ownership when another workstation requests control."""
18
+ with service._state_lock:
19
+ if not service.host.workspace_owned:
20
+ return False
21
+ metadata = control_request_metadata(service)
22
+ if metadata is None:
23
+ return False
24
+ requester = str(metadata.get("requester_machine_id", "")).strip()
25
+ if not requester or requester == service.machine_id:
26
+ return False
27
+ service._debug_log(f"control request received requester={requester}")
28
+ service._relinquish_workspace_for_control_request(requester)
29
+ return True
30
+
31
+
32
+ def try_claim_requested_control(service: "DataEngineDaemonService") -> bool:
33
+ """Claim released ownership when this workstation requested control."""
34
+ with service._state_lock:
35
+ if service.host.workspace_owned:
36
+ return True
37
+ metadata = control_request_metadata(service)
38
+ if metadata is None:
39
+ return False
40
+ requester = str(metadata.get("requester_machine_id", "")).strip()
41
+ if requester != service.machine_id:
42
+ return False
43
+ claimed = try_claim_released_workspace(service)
44
+ if not claimed:
45
+ return False
46
+ service.shared_state_adapter.remove_control_request(service.paths)
47
+ service._debug_log("control request fulfilled workspace claimed")
48
+ return True
49
+
50
+
51
+ def lease_error_text(service: "DataEngineDaemonService") -> str:
52
+ with service._state_lock:
53
+ owner = service.host.leased_by_machine_id or "another machine"
54
+ return f"Workspace {service.paths.workspace_id!r} is leased by {owner}."
55
+
56
+
57
+ def try_claim_released_workspace(service: "DataEngineDaemonService") -> bool:
58
+ """Try to reclaim an available workspace for this daemon."""
59
+ with service._state_lock:
60
+ if service.host.workspace_owned:
61
+ return True
62
+ shared_state = service.shared_state_adapter
63
+ metadata = shared_state.read_lease_metadata(service.paths)
64
+ if metadata is not None:
65
+ owner = metadata.get("machine_id")
66
+ if isinstance(owner, str) and owner.strip():
67
+ with service._state_lock:
68
+ service.host.leased_by_machine_id = owner
69
+ return False
70
+ try:
71
+ claimed = shared_state.claim_workspace(service.paths)
72
+ except Exception:
73
+ return False
74
+ if not claimed:
75
+ metadata = shared_state.read_lease_metadata(service.paths)
76
+ owner = metadata.get("machine_id") if isinstance(metadata, dict) else None
77
+ with service._state_lock:
78
+ service.host.leased_by_machine_id = str(owner) if isinstance(owner, str) and owner.strip() else None
79
+ return False
80
+ with service._state_lock:
81
+ service.state.claim_workspace()
82
+ try:
83
+ service._checkpoint_once(status="idle")
84
+ with service._state_lock:
85
+ service.state.reset_checkpoint_failures()
86
+ except Exception:
87
+ with service._state_lock:
88
+ service.state.release_workspace()
89
+ release_workspace_claim(service)
90
+ return False
91
+ return True
92
+
93
+
94
+ def release_workspace_claim(
95
+ service: "DataEngineDaemonService",
96
+ *,
97
+ leased_by_machine_id: str | None = None,
98
+ status: str | None = None,
99
+ update_state: bool = False,
100
+ ) -> None:
101
+ """Release shared ownership and mark the daemon as no longer owning the workspace."""
102
+ with service._state_lock:
103
+ workspace_owned = service.host.workspace_owned
104
+ if workspace_owned:
105
+ try:
106
+ service.shared_state_adapter.remove_lease_metadata(service.paths)
107
+ except Exception:
108
+ pass
109
+ try:
110
+ service.shared_state_adapter.release_workspace(service.paths)
111
+ except Exception:
112
+ pass
113
+ with service._state_lock:
114
+ service.state.release_workspace(leased_by_machine_id=leased_by_machine_id, status=status)
115
+ if update_state and status is not None:
116
+ service._update_daemon_state(status=status)
117
+
118
+
119
+ __all__ = [
120
+ "control_request_metadata",
121
+ "honor_control_request_if_needed",
122
+ "lease_error_text",
123
+ "release_workspace_claim",
124
+ "try_claim_released_workspace",
125
+ "try_claim_requested_control",
126
+ ]
@@ -0,0 +1,188 @@
1
+ """Runtime-control actions for the daemon host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import traceback
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from data_engine.domain.time import utcnow_text
10
+ from data_engine.hosts.daemon.ownership import lease_error_text, try_claim_released_workspace
11
+
12
+ if TYPE_CHECKING:
13
+ from data_engine.hosts.daemon.app import DataEngineDaemonService
14
+
15
+
16
+ class DaemonRuntimeCommandHandler:
17
+ """Own daemon runtime-control actions."""
18
+
19
+ def __init__(self, service: "DataEngineDaemonService") -> None:
20
+ self.service = service
21
+
22
+ def automated_flow_names(self, *, force: bool = False) -> tuple[str, ...]:
23
+ return tuple(
24
+ card.name
25
+ for card in self.service._load_flow_cards(force=force)
26
+ if card.valid and card.mode in {"poll", "schedule"}
27
+ )
28
+
29
+ def run_flow(self, *, name: str, wait: bool) -> dict[str, Any]:
30
+ service = self.service
31
+ if not service.state.workspace_owned and not try_claim_released_workspace(service):
32
+ return {"ok": False, "error": lease_error_text(service)}
33
+ cards_by_name = {card.name: card for card in service._load_flow_cards(force=True)}
34
+ card = cards_by_name.get(name)
35
+ if card is None:
36
+ return {"ok": False, "error": f"Unknown flow: {name}"}
37
+ if not card.valid:
38
+ return {"ok": False, "error": card.error or f"Flow {name} is invalid."}
39
+ with service._state_lock:
40
+ existing_thread = service.state.manual_run_threads.get(name)
41
+ if (existing_thread is not None and existing_thread.is_alive()) or name in service.state.pending_manual_run_names:
42
+ return {"ok": False, "error": f"Flow {name} is already running."}
43
+ active_same_group = next(
44
+ (
45
+ flow_name
46
+ for flow_name, thread in service.state.manual_run_threads.items()
47
+ if flow_name != name
48
+ and thread.is_alive()
49
+ and cards_by_name.get(flow_name) is not None
50
+ and cards_by_name[flow_name].group == card.group
51
+ ),
52
+ None,
53
+ )
54
+ if active_same_group is None:
55
+ active_same_group = next(
56
+ (
57
+ flow_name
58
+ for flow_name in service.state.pending_manual_run_names
59
+ if flow_name != name
60
+ and cards_by_name.get(flow_name) is not None
61
+ and cards_by_name[flow_name].group == card.group
62
+ ),
63
+ None,
64
+ )
65
+ if active_same_group is not None:
66
+ return {"ok": False, "error": f"Group {card.group} already has {active_same_group} running."}
67
+ service.state.reserve_manual_run(name)
68
+ try:
69
+ flow = service.flow_execution_service.load_flow(name, workspace_root=service.paths.workspace_root)
70
+ except Exception as exc:
71
+ with service._state_lock:
72
+ service.state.clear_manual_run_reservation(name)
73
+ return {"ok": False, "error": str(exc)}
74
+
75
+ stop_event = threading.Event()
76
+
77
+ def _target() -> None:
78
+ try:
79
+ service.runtime_execution_service.run_manual(
80
+ flow,
81
+ runtime_ledger=service.runtime_ledger,
82
+ flow_stop_event=stop_event,
83
+ )
84
+ service._debug_log(f"manual flow completed name={name}")
85
+ except Exception as exc:
86
+ service._debug_log(f"manual flow crashed name={name} error={exc!r}")
87
+ service._debug_log(traceback.format_exc().rstrip())
88
+ service.runtime_ledger.append_log(
89
+ level="ERROR",
90
+ message=str(exc),
91
+ created_at_utc=utcnow_text(),
92
+ flow_name=name,
93
+ )
94
+ raise
95
+ finally:
96
+ with service._state_lock:
97
+ service.state.unregister_manual_run(name)
98
+
99
+ thread = threading.Thread(target=_target, daemon=True)
100
+ with service._state_lock:
101
+ service.state.register_manual_run(name, thread=thread, stop_event=stop_event)
102
+ thread.start()
103
+ if wait:
104
+ thread.join()
105
+ return {"ok": True}
106
+
107
+ def start_engine(self) -> dict[str, Any]:
108
+ service = self.service
109
+ if not service.state.workspace_owned and not try_claim_released_workspace(service):
110
+ return {"ok": False, "error": lease_error_text(service)}
111
+ with service._state_lock:
112
+ if not service.state.reserve_engine_start():
113
+ return {"ok": True}
114
+ flow_names = self.automated_flow_names(force=True)
115
+ if not flow_names:
116
+ flow_names = self.automated_flow_names(force=True)
117
+ if not flow_names:
118
+ with service._state_lock:
119
+ service.state.clear_engine_start_reservation()
120
+ return {"ok": False, "error": "No automated flows are available."}
121
+ try:
122
+ flows = service.flow_execution_service.load_flows(flow_names, workspace_root=service.paths.workspace_root)
123
+ except Exception as exc:
124
+ with service._state_lock:
125
+ service.state.clear_engine_start_reservation()
126
+ return {"ok": False, "error": str(exc)}
127
+ with service._state_lock:
128
+ runtime_stop_event = threading.Event()
129
+ flow_stop_event = threading.Event()
130
+ service.state.set_engine_threads(runtime_stop_event=runtime_stop_event, flow_stop_event=flow_stop_event)
131
+ service.state.begin_runtime(status="running")
132
+
133
+ def _target() -> None:
134
+ try:
135
+ service.runtime_execution_service.run_grouped(
136
+ flows,
137
+ runtime_ledger=service.runtime_ledger,
138
+ runtime_stop_event=runtime_stop_event,
139
+ flow_stop_event=flow_stop_event,
140
+ )
141
+ service._debug_log("engine runtime exited normally")
142
+ except Exception as exc:
143
+ service._debug_log(f"engine runtime crashed error={exc!r}")
144
+ service._debug_log(traceback.format_exc().rstrip())
145
+ raise
146
+ finally:
147
+ with service._state_lock:
148
+ service.state.end_runtime(status="idle")
149
+ service._debug_log(f"engine thread finished status={service.state.status}")
150
+
151
+ engine_thread = threading.Thread(target=_target, daemon=True)
152
+ with service._state_lock:
153
+ service.state.engine_thread = engine_thread
154
+ try:
155
+ engine_thread.start()
156
+ except Exception:
157
+ with service._state_lock:
158
+ service.state.end_runtime(status="idle")
159
+ raise
160
+ return {"ok": True}
161
+
162
+ def stop_engine(self) -> dict[str, Any]:
163
+ service = self.service
164
+ if not service.state.workspace_owned:
165
+ return {"ok": False, "error": lease_error_text(service)}
166
+ with service._state_lock:
167
+ if not service.state.runtime_active:
168
+ return {"ok": True}
169
+ service.state.stop_runtime(status="stopping")
170
+ runtime_stop_event = service.state.engine_runtime_stop_event
171
+ flow_stop_event = service.state.engine_flow_stop_event
172
+ runtime_stop_event.set()
173
+ flow_stop_event.set()
174
+ return {"ok": True}
175
+
176
+ def stop_flow(self, name: str) -> dict[str, Any]:
177
+ service = self.service
178
+ if not service.state.workspace_owned:
179
+ return {"ok": False, "error": lease_error_text(service)}
180
+ with service._state_lock:
181
+ stop_event = service.state.manual_stop_events.get(name)
182
+ if stop_event is None:
183
+ return {"ok": False, "error": f"Flow {name} is not running."}
184
+ stop_event.set()
185
+ return {"ok": True}
186
+
187
+
188
+ __all__ = ["DaemonRuntimeCommandHandler"]
@@ -0,0 +1,31 @@
1
+ """Runtime stop/join helpers for the daemon host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from data_engine.hosts.daemon.app import DataEngineDaemonService
9
+
10
+
11
+ def stop_active_work(service: "DataEngineDaemonService") -> None:
12
+ """Signal all active runtime work to stop and wait briefly for it to exit."""
13
+ with service._state_lock:
14
+ engine_runtime_stop_event = service.state.engine_runtime_stop_event
15
+ engine_flow_stop_event = service.state.engine_flow_stop_event
16
+ engine_thread = service.state.engine_thread
17
+ manual_stop_events = list(service.state.manual_stop_events.values())
18
+ manual_threads = list(service.state.manual_run_threads.values())
19
+ engine_runtime_stop_event.set()
20
+ engine_flow_stop_event.set()
21
+ for stop_event in manual_stop_events:
22
+ stop_event.set()
23
+ if engine_thread is not None:
24
+ engine_thread.join(timeout=1.5)
25
+ for thread in manual_threads:
26
+ thread.join(timeout=1.5)
27
+ with service._state_lock:
28
+ service.state.end_runtime()
29
+
30
+
31
+ __all__ = ["stop_active_work"]