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,465 @@
1
+ """Daemon transport, liveness, and startup helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ import json
7
+ import os
8
+ from multiprocessing import AuthenticationError
9
+ from multiprocessing.connection import Client
10
+ from pathlib import Path
11
+ import secrets
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from typing import Any
17
+
18
+ from data_engine.domain import DaemonLifecyclePolicy
19
+ from data_engine.domain.time import parse_utc_text
20
+ from data_engine.hosts.daemon.constants import (
21
+ CHECKPOINT_INTERVAL_SECONDS,
22
+ DAEMON_STARTUP_LOCK_STALE_SECONDS,
23
+ STALE_AFTER_SECONDS,
24
+ )
25
+ from data_engine.hosts.daemon.shared_state import DaemonSharedStateAdapter
26
+ from data_engine.platform.workspace_models import WorkspacePaths, machine_id_text
27
+
28
+
29
+ class DaemonClientError(RuntimeError):
30
+ """Raised when local daemon communication fails."""
31
+
32
+
33
+ class WorkspaceLeaseError(RuntimeError):
34
+ """Raised when a workspace cannot be claimed."""
35
+
36
+
37
+ DAEMON_AUTHKEY_FILE_NAME = ".daemon-authkey"
38
+ _SHARED_STATE_ADAPTER = DaemonSharedStateAdapter()
39
+
40
+
41
+ def endpoint_address(paths: WorkspacePaths) -> str:
42
+ """Return the Listener/Client address for one workspace."""
43
+ return paths.daemon_endpoint_path
44
+
45
+
46
+ def endpoint_family(paths: WorkspacePaths) -> str:
47
+ """Return the multiprocessing.connection family for one workspace."""
48
+ return "AF_PIPE" if paths.daemon_endpoint_kind == "pipe" else "AF_UNIX"
49
+
50
+
51
+ def _daemon_authkey_path(paths: WorkspacePaths) -> Path:
52
+ """Return the per-workspace local daemon authkey path."""
53
+ return paths.runtime_state_dir / DAEMON_AUTHKEY_FILE_NAME
54
+
55
+
56
+ def daemon_authkey(paths: WorkspacePaths) -> bytes:
57
+ """Load or create the per-workspace daemon authkey."""
58
+ authkey_path = _daemon_authkey_path(paths)
59
+ authkey_path.parent.mkdir(parents=True, exist_ok=True)
60
+ while True:
61
+ try:
62
+ token = authkey_path.read_text(encoding="ascii").strip()
63
+ except FileNotFoundError:
64
+ authkey = secrets.token_bytes(32)
65
+ try:
66
+ fd = os.open(authkey_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
67
+ except FileExistsError:
68
+ continue
69
+ with os.fdopen(fd, "w", encoding="ascii") as handle:
70
+ handle.write(authkey.hex())
71
+ return authkey
72
+ if not token:
73
+ try:
74
+ authkey_path.unlink()
75
+ except FileNotFoundError:
76
+ pass
77
+ continue
78
+ return bytes.fromhex(token)
79
+
80
+
81
+ def _encode_message(payload: dict[str, Any]) -> bytes:
82
+ """Encode one daemon message as UTF-8 JSON bytes."""
83
+ if not isinstance(payload, dict):
84
+ raise DaemonClientError("Daemon payload must be a JSON object.")
85
+ try:
86
+ return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
87
+ except (TypeError, ValueError) as exc:
88
+ raise DaemonClientError("Daemon payload is not JSON serializable.") from exc
89
+
90
+
91
+ def _decode_message(raw: bytes) -> dict[str, Any]:
92
+ """Decode one UTF-8 JSON daemon message."""
93
+ try:
94
+ payload = json.loads(raw.decode("utf-8"))
95
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
96
+ raise DaemonClientError("Daemon returned an invalid message.") from exc
97
+ if not isinstance(payload, dict):
98
+ raise DaemonClientError("Daemon returned an invalid response.")
99
+ return payload
100
+
101
+
102
+ def daemon_request(paths: WorkspacePaths, payload: dict[str, Any], *, timeout: float = 5.0) -> dict[str, Any]:
103
+ """Send one request to the local workspace daemon and return its response."""
104
+ try:
105
+ with Client(
106
+ endpoint_address(paths),
107
+ family=endpoint_family(paths),
108
+ authkey=daemon_authkey(paths),
109
+ ) as connection:
110
+ connection.send_bytes(_encode_message(payload))
111
+ if timeout > 0:
112
+ deadline = time.monotonic() + timeout
113
+ while not connection.poll(0.05):
114
+ if time.monotonic() >= deadline:
115
+ raise DaemonClientError("Timed out waiting for daemon response.")
116
+ response = _decode_message(connection.recv_bytes())
117
+ except (AuthenticationError, EOFError, FileNotFoundError, ConnectionRefusedError, OSError) as exc:
118
+ raise DaemonClientError("Daemon is not reachable.") from exc
119
+ return response
120
+
121
+
122
+ def is_daemon_live(paths: WorkspacePaths) -> bool:
123
+ """Return whether a local daemon is reachable for one workspace."""
124
+ try:
125
+ response = daemon_request(paths, {"command": "daemon_ping"}, timeout=1.0)
126
+ except DaemonClientError:
127
+ return False
128
+ return bool(response.get("ok"))
129
+
130
+
131
+ def _kill_pid(pid: int) -> None:
132
+ """Forcefully terminate one local process id."""
133
+ if os.name == "nt":
134
+ result = subprocess.run(
135
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
136
+ capture_output=True,
137
+ text=True,
138
+ check=False,
139
+ )
140
+ if result.returncode != 0 and _pid_is_live(pid):
141
+ detail = result.stderr.strip() or result.stdout.strip() or f"taskkill returned {result.returncode}"
142
+ raise DaemonClientError(f"Failed to terminate local daemon process {pid}: {detail}")
143
+ return
144
+ os.kill(pid, signal.SIGKILL)
145
+
146
+
147
+ def _pid_is_live(pid: int | None) -> bool:
148
+ """Return whether one OS process id currently exists."""
149
+ if pid is None or pid <= 0:
150
+ return False
151
+ try:
152
+ os.kill(pid, 0)
153
+ except OSError:
154
+ return False
155
+ result = subprocess.run(
156
+ ["ps", "-o", "stat=", "-p", str(pid)],
157
+ capture_output=True,
158
+ text=True,
159
+ check=False,
160
+ )
161
+ if result.returncode != 0:
162
+ return False
163
+ status_text = result.stdout.strip()
164
+ if not status_text:
165
+ return False
166
+ if status_text.split()[0].startswith("Z"):
167
+ return False
168
+ return True
169
+
170
+
171
+ def _same_machine_unreachable_lease_metadata(paths: WorkspacePaths) -> dict[str, Any] | None:
172
+ """Return lease metadata when the workspace is leased locally but IPC is unavailable."""
173
+ metadata = _SHARED_STATE_ADAPTER.read_lease_metadata(paths)
174
+ if metadata is None:
175
+ return None
176
+ owner = metadata.get("machine_id")
177
+ if not isinstance(owner, str) or owner.strip() != machine_id_text():
178
+ return None
179
+ if is_daemon_live(paths):
180
+ return None
181
+ return metadata
182
+
183
+
184
+ def _same_machine_live_lease_process(paths: WorkspacePaths) -> int | None:
185
+ """Return the owning local lease pid when one is still alive."""
186
+ metadata = _SHARED_STATE_ADAPTER.read_lease_metadata(paths)
187
+ if metadata is None:
188
+ return None
189
+ owner = metadata.get("machine_id")
190
+ if not isinstance(owner, str) or owner.strip() != machine_id_text():
191
+ return None
192
+ pid_value = metadata.get("pid")
193
+ try:
194
+ pid = int(pid_value)
195
+ except (TypeError, ValueError):
196
+ return None
197
+ if pid <= 0 or not _pid_is_live(pid):
198
+ return None
199
+ return pid
200
+
201
+
202
+ def _same_machine_lease_pid(paths: WorkspacePaths) -> int | None:
203
+ """Return the owning local lease pid when one is recorded."""
204
+ metadata = _SHARED_STATE_ADAPTER.read_lease_metadata(paths)
205
+ if metadata is None:
206
+ return None
207
+ owner = metadata.get("machine_id")
208
+ if not isinstance(owner, str) or owner.strip() != machine_id_text():
209
+ return None
210
+ try:
211
+ pid = int(metadata.get("pid"))
212
+ except (TypeError, ValueError):
213
+ return None
214
+ return pid if pid > 0 else None
215
+
216
+
217
+ def _reachable_daemon_pid(paths: WorkspacePaths) -> int | None:
218
+ """Return the daemon pid when the local daemon answers status requests."""
219
+ try:
220
+ response = daemon_request(paths, {"command": "daemon_status"}, timeout=0.5)
221
+ except DaemonClientError:
222
+ return None
223
+ status = response.get("status")
224
+ if not isinstance(status, dict):
225
+ return None
226
+ try:
227
+ pid = int(status.get("pid"))
228
+ except (TypeError, ValueError):
229
+ return None
230
+ return pid if pid > 0 else None
231
+
232
+
233
+ def _cleanup_forced_shutdown(paths: WorkspacePaths) -> None:
234
+ """Best-effort cleanup after a forced daemon termination."""
235
+ try:
236
+ _SHARED_STATE_ADAPTER.remove_lease_metadata(paths)
237
+ except Exception:
238
+ pass
239
+ try:
240
+ _remove_stale_unix_endpoint(paths)
241
+ except Exception:
242
+ pass
243
+
244
+
245
+ def _lease_checkpoint_age_seconds(metadata: dict[str, Any]) -> float | None:
246
+ """Return the age in seconds of one lease checkpoint timestamp when available."""
247
+ checkpoint = parse_utc_text(str(metadata.get("last_checkpoint_at_utc")))
248
+ if checkpoint is None:
249
+ return None
250
+ return max((datetime.now(UTC) - checkpoint).total_seconds(), 0.0)
251
+
252
+
253
+ def _wait_for_fresh_local_daemon(paths: WorkspacePaths) -> bool:
254
+ """Give one recently checked-in same-machine daemon a brief chance to answer."""
255
+ metadata = _same_machine_unreachable_lease_metadata(paths)
256
+ if metadata is None:
257
+ return False
258
+ age_seconds = _lease_checkpoint_age_seconds(metadata)
259
+ if age_seconds is None or age_seconds >= CHECKPOINT_INTERVAL_SECONDS:
260
+ return False
261
+ deadline = time.monotonic() + min(2.0, max(CHECKPOINT_INTERVAL_SECONDS - age_seconds, 0.0))
262
+ while time.monotonic() < deadline:
263
+ if is_daemon_live(paths):
264
+ return True
265
+ time.sleep(0.1)
266
+ return is_daemon_live(paths)
267
+
268
+
269
+ def _should_force_recover_local_lease(paths: WorkspacePaths) -> bool:
270
+ """Return whether an unreachable same-machine lease is stale enough to reclaim."""
271
+ metadata = _same_machine_unreachable_lease_metadata(paths)
272
+ if metadata is None:
273
+ return False
274
+ return _SHARED_STATE_ADAPTER.lease_is_stale(paths, stale_after_seconds=STALE_AFTER_SECONDS)
275
+
276
+
277
+ def _recover_broken_local_lease(paths: WorkspacePaths) -> bool:
278
+ """Recover one unreachable same-machine lease after it becomes stale."""
279
+ return _SHARED_STATE_ADAPTER.recover_stale_workspace(
280
+ paths,
281
+ machine_id=machine_id_text(),
282
+ stale_after_seconds=STALE_AFTER_SECONDS,
283
+ reclaim=False,
284
+ )
285
+
286
+
287
+ def _remove_stale_unix_endpoint(paths: WorkspacePaths) -> None:
288
+ """Delete one dead Unix socket file before binding a new daemon listener."""
289
+ if paths.daemon_endpoint_kind != "unix":
290
+ return
291
+ endpoint_path = Path(paths.daemon_endpoint_path)
292
+ if not endpoint_path.exists():
293
+ return
294
+ if is_daemon_live(paths):
295
+ return
296
+ try:
297
+ endpoint_path.unlink()
298
+ except FileNotFoundError:
299
+ pass
300
+
301
+
302
+ def _startup_lock_path(paths: WorkspacePaths) -> Path:
303
+ """Return the per-workspace local startup lock path."""
304
+ return paths.runtime_state_dir / ".daemon-start.lock"
305
+
306
+
307
+ def _acquire_startup_lock(paths: WorkspacePaths) -> bool:
308
+ """Try to acquire the per-workspace daemon startup lock."""
309
+ lock_path = _startup_lock_path(paths)
310
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
311
+ while True:
312
+ try:
313
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
314
+ except FileExistsError:
315
+ try:
316
+ age_seconds = time.time() - lock_path.stat().st_mtime
317
+ except FileNotFoundError:
318
+ continue
319
+ if age_seconds > DAEMON_STARTUP_LOCK_STALE_SECONDS:
320
+ try:
321
+ lock_path.unlink()
322
+ except FileNotFoundError:
323
+ pass
324
+ continue
325
+ return False
326
+ else:
327
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
328
+ handle.write(str(os.getpid()))
329
+ return True
330
+
331
+
332
+ def _release_startup_lock(paths: WorkspacePaths) -> None:
333
+ """Release the per-workspace daemon startup lock when held."""
334
+ try:
335
+ _startup_lock_path(paths).unlink()
336
+ except FileNotFoundError:
337
+ pass
338
+
339
+
340
+ def _wait_for_daemon_live(paths: WorkspacePaths, *, timeout_seconds: float) -> bool:
341
+ """Wait for one workspace daemon to become reachable."""
342
+ deadline = time.monotonic() + timeout_seconds
343
+ while time.monotonic() < deadline:
344
+ if is_daemon_live(paths):
345
+ return True
346
+ time.sleep(0.1)
347
+ return is_daemon_live(paths)
348
+
349
+
350
+ def spawn_daemon_process(
351
+ paths: WorkspacePaths,
352
+ *,
353
+ lifecycle_policy: DaemonLifecyclePolicy = DaemonLifecyclePolicy.PERSISTENT,
354
+ ) -> int:
355
+ """Start the daemon process in the background for one workspace."""
356
+ lifecycle_policy = DaemonLifecyclePolicy.coerce(lifecycle_policy)
357
+ if is_daemon_live(paths):
358
+ return 0
359
+ if _wait_for_fresh_local_daemon(paths):
360
+ return 0
361
+ local_pid = _same_machine_live_lease_process(paths)
362
+ if local_pid is not None:
363
+ if _wait_for_daemon_live(paths, timeout_seconds=2.0):
364
+ return 0
365
+ raise DaemonClientError(f"Local daemon process {local_pid} already owns this workspace.")
366
+ if _should_force_recover_local_lease(paths):
367
+ _recover_broken_local_lease(paths)
368
+ if is_daemon_live(paths):
369
+ return 0
370
+ elif _same_machine_unreachable_lease_metadata(paths) is not None:
371
+ raise DaemonClientError("This workstation already has control, but the local daemon is not responding yet.")
372
+ acquired = _acquire_startup_lock(paths)
373
+ if not acquired:
374
+ if _wait_for_daemon_live(paths, timeout_seconds=10.0):
375
+ return 0
376
+ raise DaemonClientError("Timed out waiting for daemon startup.")
377
+ command = [
378
+ sys.executable,
379
+ "-m",
380
+ "data_engine.hosts.daemon.app",
381
+ "--app-root",
382
+ str(paths.app_root),
383
+ "--workspace",
384
+ str(paths.workspace_root),
385
+ "--lifecycle-policy",
386
+ lifecycle_policy.value,
387
+ ]
388
+ kwargs: dict[str, Any] = {
389
+ "stdout": subprocess.DEVNULL,
390
+ "stderr": subprocess.DEVNULL,
391
+ "stdin": subprocess.DEVNULL,
392
+ "close_fds": True,
393
+ }
394
+ if os.name != "nt":
395
+ kwargs["start_new_session"] = True
396
+ try:
397
+ subprocess.Popen(command, **kwargs)
398
+ if _wait_for_daemon_live(paths, timeout_seconds=10.0):
399
+ return 0
400
+ raise DaemonClientError("Timed out waiting for daemon startup.")
401
+ finally:
402
+ _release_startup_lock(paths)
403
+
404
+
405
+ def force_shutdown_daemon_process(paths: WorkspacePaths, *, timeout: float = 0.5) -> None:
406
+ """Stop the local workspace daemon, escalating to an OS kill when needed."""
407
+ pid = _reachable_daemon_pid(paths) or _same_machine_lease_pid(paths)
408
+ if pid is None:
409
+ if not is_daemon_live(paths):
410
+ _cleanup_forced_shutdown(paths)
411
+ return
412
+ raise DaemonClientError("Local daemon is reachable, but its process id is unavailable.")
413
+ try:
414
+ daemon_request(paths, {"command": "shutdown_daemon"}, timeout=timeout)
415
+ except DaemonClientError:
416
+ pass
417
+ graceful_deadline = time.monotonic() + max(timeout, 0.0)
418
+ while time.monotonic() < graceful_deadline:
419
+ if not _pid_is_live(pid):
420
+ _cleanup_forced_shutdown(paths)
421
+ return
422
+ time.sleep(0.05)
423
+ if _pid_is_live(pid):
424
+ try:
425
+ _kill_pid(pid)
426
+ except OSError as exc:
427
+ if _pid_is_live(pid):
428
+ raise DaemonClientError(f"Failed to terminate local daemon process {pid}.") from exc
429
+ kill_deadline = time.monotonic() + 2.0
430
+ while time.monotonic() < kill_deadline:
431
+ if not _pid_is_live(pid):
432
+ _cleanup_forced_shutdown(paths)
433
+ return
434
+ time.sleep(0.05)
435
+ raise DaemonClientError(f"Failed to stop local daemon process {pid}.")
436
+
437
+
438
+ __all__ = [
439
+ "DAEMON_AUTHKEY_FILE_NAME",
440
+ "DaemonClientError",
441
+ "WorkspaceLeaseError",
442
+ "_acquire_startup_lock",
443
+ "_decode_message",
444
+ "_encode_message",
445
+ "_lease_checkpoint_age_seconds",
446
+ "_pid_is_live",
447
+ "_reachable_daemon_pid",
448
+ "_recover_broken_local_lease",
449
+ "_release_startup_lock",
450
+ "_remove_stale_unix_endpoint",
451
+ "_same_machine_lease_pid",
452
+ "_same_machine_live_lease_process",
453
+ "_same_machine_unreachable_lease_metadata",
454
+ "_should_force_recover_local_lease",
455
+ "_startup_lock_path",
456
+ "_wait_for_daemon_live",
457
+ "_wait_for_fresh_local_daemon",
458
+ "daemon_authkey",
459
+ "daemon_request",
460
+ "endpoint_address",
461
+ "endpoint_family",
462
+ "force_shutdown_daemon_process",
463
+ "is_daemon_live",
464
+ "spawn_daemon_process",
465
+ ]
@@ -0,0 +1,64 @@
1
+ """Daemon IPC command routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from data_engine.hosts.daemon.runtime_commands import DaemonRuntimeCommandHandler
9
+ from data_engine.hosts.daemon.state_sync import DaemonStateSyncHandler
10
+
11
+ if TYPE_CHECKING:
12
+ from data_engine.hosts.daemon.app import DataEngineDaemonService
13
+
14
+
15
+ class DaemonCommandHandler:
16
+ """Route daemon IPC commands onto narrower host collaborators."""
17
+
18
+ def __init__(self, service: "DataEngineDaemonService") -> None:
19
+ self.service = service
20
+ self.runtime_commands = DaemonRuntimeCommandHandler(service)
21
+ self.state_sync = DaemonStateSyncHandler(service)
22
+
23
+ def handle(self, payload: Any) -> dict[str, Any]:
24
+ if not isinstance(payload, dict):
25
+ return {"ok": False, "error": "Invalid command payload."}
26
+ command = str(payload.get("command", ""))
27
+ if command == "daemon_ping":
28
+ return {"ok": True, "workspace_id": self.service.paths.workspace_id}
29
+ if command == "daemon_status":
30
+ return {"ok": True, "status": self.state_sync.status_payload()}
31
+ if command == "list_flows":
32
+ return {"ok": True, "flows": [asdict(card) for card in self.state_sync.load_flow_cards()]}
33
+ if command == "get_flow":
34
+ name = str(payload.get("name", ""))
35
+ flow = next((card for card in self.state_sync.load_flow_cards() if card.name == name), None)
36
+ if flow is None:
37
+ return {"ok": False, "error": f"Unknown flow: {name}"}
38
+ return {"ok": True, "flow": asdict(flow)}
39
+ if command == "refresh_flows":
40
+ return {"ok": True, "flows": [asdict(card) for card in self.state_sync.load_flow_cards(force=True)]}
41
+ if command == "run_flow":
42
+ return self.runtime_commands.run_flow(name=str(payload.get("name", "")), wait=bool(payload.get("wait", False)))
43
+ if command == "start_engine":
44
+ return self.runtime_commands.start_engine()
45
+ if command == "stop_engine":
46
+ return self.runtime_commands.stop_engine()
47
+ if command == "stop_flow":
48
+ return self.runtime_commands.stop_flow(str(payload.get("name", "")))
49
+ if command == "shutdown_daemon":
50
+ self.service.host.shutdown_event.set()
51
+ return {"ok": True}
52
+ return {"ok": False, "error": f"Unknown command: {command}"}
53
+
54
+ def checkpoint_once(self, *, status: str) -> None:
55
+ self.state_sync.checkpoint_once(status=status)
56
+
57
+ def refresh_observer_snapshot(self) -> None:
58
+ self.state_sync.refresh_observer_snapshot()
59
+
60
+ def update_daemon_state(self, *, status: str) -> None:
61
+ self.state_sync.update_daemon_state(status=status)
62
+
63
+
64
+ __all__ = ["DaemonCommandHandler"]