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,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"]
|