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.
- data_engine/__init__.py +37 -0
- data_engine/application/__init__.py +39 -0
- data_engine/application/actions.py +42 -0
- data_engine/application/catalog.py +151 -0
- data_engine/application/control.py +213 -0
- data_engine/application/details.py +73 -0
- data_engine/application/runtime.py +449 -0
- data_engine/application/workspace.py +62 -0
- data_engine/authoring/__init__.py +14 -0
- data_engine/authoring/builder.py +31 -0
- data_engine/authoring/execution/__init__.py +6 -0
- data_engine/authoring/execution/app.py +6 -0
- data_engine/authoring/execution/context.py +82 -0
- data_engine/authoring/execution/continuous.py +176 -0
- data_engine/authoring/execution/grouped.py +106 -0
- data_engine/authoring/execution/logging.py +83 -0
- data_engine/authoring/execution/polling.py +135 -0
- data_engine/authoring/execution/runner.py +210 -0
- data_engine/authoring/execution/single.py +171 -0
- data_engine/authoring/flow.py +361 -0
- data_engine/authoring/helpers.py +160 -0
- data_engine/authoring/model.py +59 -0
- data_engine/authoring/primitives.py +430 -0
- data_engine/authoring/services.py +42 -0
- data_engine/devtools/__init__.py +3 -0
- data_engine/devtools/project_ast_map.py +503 -0
- data_engine/docs/__init__.py +1 -0
- data_engine/docs/sphinx_source/_static/custom.css +13 -0
- data_engine/docs/sphinx_source/api.rst +42 -0
- data_engine/docs/sphinx_source/conf.py +37 -0
- data_engine/docs/sphinx_source/guides/app-runtime-and-workspaces.md +397 -0
- data_engine/docs/sphinx_source/guides/authoring-flow-modules.md +215 -0
- data_engine/docs/sphinx_source/guides/configuring-flows.md +185 -0
- data_engine/docs/sphinx_source/guides/core-concepts.md +208 -0
- data_engine/docs/sphinx_source/guides/database-methods.md +107 -0
- data_engine/docs/sphinx_source/guides/duckdb-helpers.md +462 -0
- data_engine/docs/sphinx_source/guides/flow-context.md +538 -0
- data_engine/docs/sphinx_source/guides/flow-methods.md +206 -0
- data_engine/docs/sphinx_source/guides/getting-started.md +271 -0
- data_engine/docs/sphinx_source/guides/project-inventory.md +5683 -0
- data_engine/docs/sphinx_source/guides/project-map.md +118 -0
- data_engine/docs/sphinx_source/guides/recipes.md +268 -0
- data_engine/docs/sphinx_source/index.rst +22 -0
- data_engine/domain/__init__.py +92 -0
- data_engine/domain/actions.py +69 -0
- data_engine/domain/catalog.py +128 -0
- data_engine/domain/details.py +214 -0
- data_engine/domain/diagnostics.py +56 -0
- data_engine/domain/errors.py +104 -0
- data_engine/domain/inspection.py +99 -0
- data_engine/domain/logs.py +118 -0
- data_engine/domain/operations.py +172 -0
- data_engine/domain/operator.py +72 -0
- data_engine/domain/runs.py +155 -0
- data_engine/domain/runtime.py +279 -0
- data_engine/domain/source_state.py +17 -0
- data_engine/domain/support.py +54 -0
- data_engine/domain/time.py +23 -0
- data_engine/domain/workspace.py +159 -0
- data_engine/flow_modules/__init__.py +1 -0
- data_engine/flow_modules/flow_module_compiler.py +179 -0
- data_engine/flow_modules/flow_module_loader.py +201 -0
- data_engine/helpers/__init__.py +25 -0
- data_engine/helpers/duckdb.py +705 -0
- data_engine/hosts/__init__.py +1 -0
- data_engine/hosts/daemon/__init__.py +23 -0
- data_engine/hosts/daemon/app.py +221 -0
- data_engine/hosts/daemon/bootstrap.py +69 -0
- data_engine/hosts/daemon/client.py +465 -0
- data_engine/hosts/daemon/commands.py +64 -0
- data_engine/hosts/daemon/composition.py +310 -0
- data_engine/hosts/daemon/constants.py +15 -0
- data_engine/hosts/daemon/entrypoints.py +97 -0
- data_engine/hosts/daemon/lifecycle.py +191 -0
- data_engine/hosts/daemon/manager.py +272 -0
- data_engine/hosts/daemon/ownership.py +126 -0
- data_engine/hosts/daemon/runtime_commands.py +188 -0
- data_engine/hosts/daemon/runtime_control.py +31 -0
- data_engine/hosts/daemon/server.py +84 -0
- data_engine/hosts/daemon/shared_state.py +147 -0
- data_engine/hosts/daemon/state_sync.py +101 -0
- data_engine/platform/__init__.py +1 -0
- data_engine/platform/identity.py +35 -0
- data_engine/platform/local_settings.py +146 -0
- data_engine/platform/theme.py +259 -0
- data_engine/platform/workspace_models.py +190 -0
- data_engine/platform/workspace_policy.py +333 -0
- data_engine/runtime/__init__.py +1 -0
- data_engine/runtime/file_watch.py +185 -0
- data_engine/runtime/ledger_models.py +116 -0
- data_engine/runtime/runtime_db.py +938 -0
- data_engine/runtime/shared_state.py +523 -0
- data_engine/services/__init__.py +49 -0
- data_engine/services/daemon.py +64 -0
- data_engine/services/daemon_state.py +40 -0
- data_engine/services/flow_catalog.py +102 -0
- data_engine/services/flow_execution.py +48 -0
- data_engine/services/ledger.py +85 -0
- data_engine/services/logs.py +65 -0
- data_engine/services/runtime_binding.py +105 -0
- data_engine/services/runtime_execution.py +126 -0
- data_engine/services/runtime_history.py +62 -0
- data_engine/services/settings.py +58 -0
- data_engine/services/shared_state.py +28 -0
- data_engine/services/theme.py +59 -0
- data_engine/services/workspace_provisioning.py +224 -0
- data_engine/services/workspaces.py +74 -0
- data_engine/ui/__init__.py +3 -0
- data_engine/ui/cli/__init__.py +19 -0
- data_engine/ui/cli/app.py +161 -0
- data_engine/ui/cli/commands_doctor.py +178 -0
- data_engine/ui/cli/commands_run.py +80 -0
- data_engine/ui/cli/commands_start.py +100 -0
- data_engine/ui/cli/commands_workspace.py +97 -0
- data_engine/ui/cli/dependencies.py +44 -0
- data_engine/ui/cli/parser.py +56 -0
- data_engine/ui/gui/__init__.py +25 -0
- data_engine/ui/gui/app.py +116 -0
- data_engine/ui/gui/bootstrap.py +487 -0
- data_engine/ui/gui/bootstrapper.py +140 -0
- data_engine/ui/gui/cache_models.py +23 -0
- data_engine/ui/gui/control_support.py +185 -0
- data_engine/ui/gui/controllers/__init__.py +6 -0
- data_engine/ui/gui/controllers/flows.py +439 -0
- data_engine/ui/gui/controllers/runtime.py +245 -0
- data_engine/ui/gui/dialogs/__init__.py +12 -0
- data_engine/ui/gui/dialogs/messages.py +88 -0
- data_engine/ui/gui/dialogs/previews.py +222 -0
- data_engine/ui/gui/helpers/__init__.py +62 -0
- data_engine/ui/gui/helpers/inspection.py +81 -0
- data_engine/ui/gui/helpers/lifecycle.py +112 -0
- data_engine/ui/gui/helpers/scroll.py +28 -0
- data_engine/ui/gui/helpers/theming.py +87 -0
- data_engine/ui/gui/icons/dark_light.svg +12 -0
- data_engine/ui/gui/icons/documentation.svg +1 -0
- data_engine/ui/gui/icons/failed.svg +3 -0
- data_engine/ui/gui/icons/group.svg +4 -0
- data_engine/ui/gui/icons/home.svg +2 -0
- data_engine/ui/gui/icons/manual.svg +2 -0
- data_engine/ui/gui/icons/poll.svg +2 -0
- data_engine/ui/gui/icons/schedule.svg +4 -0
- data_engine/ui/gui/icons/settings.svg +2 -0
- data_engine/ui/gui/icons/started.svg +3 -0
- data_engine/ui/gui/icons/success.svg +3 -0
- data_engine/ui/gui/icons/view-log.svg +3 -0
- data_engine/ui/gui/icons.py +50 -0
- data_engine/ui/gui/launcher.py +48 -0
- data_engine/ui/gui/presenters/__init__.py +72 -0
- data_engine/ui/gui/presenters/docs.py +140 -0
- data_engine/ui/gui/presenters/logs.py +58 -0
- data_engine/ui/gui/presenters/runtime_projection.py +29 -0
- data_engine/ui/gui/presenters/sidebar.py +88 -0
- data_engine/ui/gui/presenters/steps.py +148 -0
- data_engine/ui/gui/presenters/workspace.py +39 -0
- data_engine/ui/gui/presenters/workspace_binding.py +75 -0
- data_engine/ui/gui/presenters/workspace_settings.py +182 -0
- data_engine/ui/gui/preview_models.py +37 -0
- data_engine/ui/gui/render_support.py +241 -0
- data_engine/ui/gui/rendering/__init__.py +12 -0
- data_engine/ui/gui/rendering/artifacts.py +95 -0
- data_engine/ui/gui/rendering/icons.py +50 -0
- data_engine/ui/gui/runtime.py +47 -0
- data_engine/ui/gui/state_support.py +193 -0
- data_engine/ui/gui/support.py +214 -0
- data_engine/ui/gui/surface.py +209 -0
- data_engine/ui/gui/theme.py +720 -0
- data_engine/ui/gui/widgets/__init__.py +34 -0
- data_engine/ui/gui/widgets/config.py +41 -0
- data_engine/ui/gui/widgets/logs.py +62 -0
- data_engine/ui/gui/widgets/panels.py +507 -0
- data_engine/ui/gui/widgets/sidebar.py +130 -0
- data_engine/ui/gui/widgets/steps.py +84 -0
- data_engine/ui/tui/__init__.py +5 -0
- data_engine/ui/tui/app.py +222 -0
- data_engine/ui/tui/bootstrap.py +475 -0
- data_engine/ui/tui/bootstrapper.py +117 -0
- data_engine/ui/tui/controllers/__init__.py +6 -0
- data_engine/ui/tui/controllers/flows.py +349 -0
- data_engine/ui/tui/controllers/runtime.py +167 -0
- data_engine/ui/tui/runtime.py +34 -0
- data_engine/ui/tui/state_support.py +141 -0
- data_engine/ui/tui/support.py +63 -0
- data_engine/ui/tui/theme.py +204 -0
- data_engine/ui/tui/widgets.py +123 -0
- data_engine/views/__init__.py +109 -0
- data_engine/views/actions.py +80 -0
- data_engine/views/artifacts.py +58 -0
- data_engine/views/flow_display.py +69 -0
- data_engine/views/logs.py +54 -0
- data_engine/views/models.py +96 -0
- data_engine/views/presentation.py +133 -0
- data_engine/views/runs.py +62 -0
- data_engine/views/state.py +39 -0
- data_engine/views/status.py +13 -0
- data_engine/views/text.py +109 -0
- py_data_engine-0.1.0.dist-info/METADATA +330 -0
- py_data_engine-0.1.0.dist-info/RECORD +200 -0
- py_data_engine-0.1.0.dist-info/WHEEL +5 -0
- py_data_engine-0.1.0.dist-info/entry_points.txt +2 -0
- 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"]
|