dtSpark 1.0.4__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.
- dtSpark/__init__.py +0 -0
- dtSpark/_description.txt +1 -0
- dtSpark/_full_name.txt +1 -0
- dtSpark/_licence.txt +21 -0
- dtSpark/_metadata.yaml +6 -0
- dtSpark/_name.txt +1 -0
- dtSpark/_version.txt +1 -0
- dtSpark/aws/__init__.py +7 -0
- dtSpark/aws/authentication.py +296 -0
- dtSpark/aws/bedrock.py +578 -0
- dtSpark/aws/costs.py +318 -0
- dtSpark/aws/pricing.py +580 -0
- dtSpark/cli_interface.py +2645 -0
- dtSpark/conversation_manager.py +3050 -0
- dtSpark/core/__init__.py +12 -0
- dtSpark/core/application.py +3355 -0
- dtSpark/core/context_compaction.py +735 -0
- dtSpark/daemon/__init__.py +104 -0
- dtSpark/daemon/__main__.py +10 -0
- dtSpark/daemon/action_monitor.py +213 -0
- dtSpark/daemon/daemon_app.py +730 -0
- dtSpark/daemon/daemon_manager.py +289 -0
- dtSpark/daemon/execution_coordinator.py +194 -0
- dtSpark/daemon/pid_file.py +169 -0
- dtSpark/database/__init__.py +482 -0
- dtSpark/database/autonomous_actions.py +1191 -0
- dtSpark/database/backends.py +329 -0
- dtSpark/database/connection.py +122 -0
- dtSpark/database/conversations.py +520 -0
- dtSpark/database/credential_prompt.py +218 -0
- dtSpark/database/files.py +205 -0
- dtSpark/database/mcp_ops.py +355 -0
- dtSpark/database/messages.py +161 -0
- dtSpark/database/schema.py +673 -0
- dtSpark/database/tool_permissions.py +186 -0
- dtSpark/database/usage.py +167 -0
- dtSpark/files/__init__.py +4 -0
- dtSpark/files/manager.py +322 -0
- dtSpark/launch.py +39 -0
- dtSpark/limits/__init__.py +10 -0
- dtSpark/limits/costs.py +296 -0
- dtSpark/limits/tokens.py +342 -0
- dtSpark/llm/__init__.py +17 -0
- dtSpark/llm/anthropic_direct.py +446 -0
- dtSpark/llm/base.py +146 -0
- dtSpark/llm/context_limits.py +438 -0
- dtSpark/llm/manager.py +177 -0
- dtSpark/llm/ollama.py +578 -0
- dtSpark/mcp_integration/__init__.py +5 -0
- dtSpark/mcp_integration/manager.py +653 -0
- dtSpark/mcp_integration/tool_selector.py +225 -0
- dtSpark/resources/config.yaml.template +631 -0
- dtSpark/safety/__init__.py +22 -0
- dtSpark/safety/llm_service.py +111 -0
- dtSpark/safety/patterns.py +229 -0
- dtSpark/safety/prompt_inspector.py +442 -0
- dtSpark/safety/violation_logger.py +346 -0
- dtSpark/scheduler/__init__.py +20 -0
- dtSpark/scheduler/creation_tools.py +599 -0
- dtSpark/scheduler/execution_queue.py +159 -0
- dtSpark/scheduler/executor.py +1152 -0
- dtSpark/scheduler/manager.py +395 -0
- dtSpark/tools/__init__.py +4 -0
- dtSpark/tools/builtin.py +833 -0
- dtSpark/web/__init__.py +20 -0
- dtSpark/web/auth.py +152 -0
- dtSpark/web/dependencies.py +37 -0
- dtSpark/web/endpoints/__init__.py +17 -0
- dtSpark/web/endpoints/autonomous_actions.py +1125 -0
- dtSpark/web/endpoints/chat.py +621 -0
- dtSpark/web/endpoints/conversations.py +353 -0
- dtSpark/web/endpoints/main_menu.py +547 -0
- dtSpark/web/endpoints/streaming.py +421 -0
- dtSpark/web/server.py +578 -0
- dtSpark/web/session.py +167 -0
- dtSpark/web/ssl_utils.py +195 -0
- dtSpark/web/static/css/dark-theme.css +427 -0
- dtSpark/web/static/js/actions.js +1101 -0
- dtSpark/web/static/js/chat.js +614 -0
- dtSpark/web/static/js/main.js +496 -0
- dtSpark/web/static/js/sse-client.js +242 -0
- dtSpark/web/templates/actions.html +408 -0
- dtSpark/web/templates/base.html +93 -0
- dtSpark/web/templates/chat.html +814 -0
- dtSpark/web/templates/conversations.html +350 -0
- dtSpark/web/templates/goodbye.html +81 -0
- dtSpark/web/templates/login.html +90 -0
- dtSpark/web/templates/main_menu.html +983 -0
- dtSpark/web/templates/new_conversation.html +191 -0
- dtSpark/web/web_interface.py +137 -0
- dtspark-1.0.4.dist-info/METADATA +187 -0
- dtspark-1.0.4.dist-info/RECORD +96 -0
- dtspark-1.0.4.dist-info/WHEEL +5 -0
- dtspark-1.0.4.dist-info/entry_points.txt +3 -0
- dtspark-1.0.4.dist-info/licenses/LICENSE +21 -0
- dtspark-1.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Daemon lifecycle manager.
|
|
3
|
+
|
|
4
|
+
Provides CLI commands for starting, stopping, and checking daemon status.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import signal
|
|
13
|
+
import logging
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from .pid_file import PIDFile
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DaemonManager:
|
|
23
|
+
"""
|
|
24
|
+
Manages daemon lifecycle via CLI commands.
|
|
25
|
+
|
|
26
|
+
Supports: start, stop, status, restart
|
|
27
|
+
Works across Windows and Unix platforms.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, pid_file_path: str):
|
|
31
|
+
"""
|
|
32
|
+
Initialise the daemon manager.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
pid_file_path: Path to the PID file
|
|
36
|
+
"""
|
|
37
|
+
self.pid_file = PIDFile(pid_file_path)
|
|
38
|
+
|
|
39
|
+
def start(self, args: Optional[List[str]] = None) -> int:
|
|
40
|
+
"""
|
|
41
|
+
Start the daemon process.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
args: Additional arguments to pass to daemon
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
0 on success, non-zero on failure
|
|
48
|
+
"""
|
|
49
|
+
# Check if already running
|
|
50
|
+
if self.pid_file.is_running():
|
|
51
|
+
pid = self.pid_file.read_pid()
|
|
52
|
+
print(f"Daemon already running (PID: {pid})")
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
# Check for --foreground flag
|
|
56
|
+
foreground = False
|
|
57
|
+
if args and '--foreground' in args:
|
|
58
|
+
foreground = True
|
|
59
|
+
args = [a for a in args if a != '--foreground']
|
|
60
|
+
|
|
61
|
+
if foreground:
|
|
62
|
+
# Run in foreground (for debugging)
|
|
63
|
+
return self._run_foreground(args)
|
|
64
|
+
else:
|
|
65
|
+
# Start in background
|
|
66
|
+
return self._start_background(args)
|
|
67
|
+
|
|
68
|
+
def _run_foreground(self, args: Optional[List[str]] = None) -> int:
|
|
69
|
+
"""Run daemon in foreground mode."""
|
|
70
|
+
print("Starting daemon in foreground mode...")
|
|
71
|
+
print("Press Ctrl+C to stop")
|
|
72
|
+
print("")
|
|
73
|
+
|
|
74
|
+
# Import and run directly
|
|
75
|
+
from .daemon_app import DaemonApplication
|
|
76
|
+
app = DaemonApplication()
|
|
77
|
+
|
|
78
|
+
# Clean up sys.argv - AbstractApp expects only arguments it recognises
|
|
79
|
+
# Remove 'daemon', 'start', '--foreground' and keep only daemon-specific args
|
|
80
|
+
clean_args = ['dtSpark-daemon']
|
|
81
|
+
if args:
|
|
82
|
+
clean_args.extend(args)
|
|
83
|
+
sys.argv = clean_args
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
app.run()
|
|
87
|
+
return 0
|
|
88
|
+
except KeyboardInterrupt:
|
|
89
|
+
print("\nDaemon stopped by user")
|
|
90
|
+
return 0
|
|
91
|
+
except Exception as e:
|
|
92
|
+
print(f"Daemon error: {e}")
|
|
93
|
+
import traceback
|
|
94
|
+
traceback.print_exc()
|
|
95
|
+
return 1
|
|
96
|
+
|
|
97
|
+
def _start_background(self, args: Optional[List[str]] = None) -> int:
|
|
98
|
+
"""Start daemon as a background process."""
|
|
99
|
+
# Build command to run daemon
|
|
100
|
+
daemon_cmd = [
|
|
101
|
+
sys.executable,
|
|
102
|
+
'-m', 'dtSpark.daemon',
|
|
103
|
+
'--run'
|
|
104
|
+
]
|
|
105
|
+
if args:
|
|
106
|
+
daemon_cmd.extend(args)
|
|
107
|
+
|
|
108
|
+
# Log file for daemon output (helps with debugging startup issues)
|
|
109
|
+
daemon_log_path = str(self.pid_file.path) + '.log'
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
# Open log file for daemon output
|
|
113
|
+
log_file = open(daemon_log_path, 'w')
|
|
114
|
+
|
|
115
|
+
if sys.platform == 'win32':
|
|
116
|
+
# Windows: use CREATE_NEW_PROCESS_GROUP and DETACHED_PROCESS
|
|
117
|
+
process = subprocess.Popen(
|
|
118
|
+
daemon_cmd,
|
|
119
|
+
creationflags=(
|
|
120
|
+
subprocess.CREATE_NEW_PROCESS_GROUP |
|
|
121
|
+
subprocess.DETACHED_PROCESS
|
|
122
|
+
),
|
|
123
|
+
stdout=log_file,
|
|
124
|
+
stderr=log_file,
|
|
125
|
+
stdin=subprocess.DEVNULL,
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
# Unix: start new session
|
|
129
|
+
process = subprocess.Popen(
|
|
130
|
+
daemon_cmd,
|
|
131
|
+
start_new_session=True,
|
|
132
|
+
stdout=log_file,
|
|
133
|
+
stderr=log_file,
|
|
134
|
+
stdin=subprocess.DEVNULL,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Wait for daemon to start and write PID file
|
|
138
|
+
# Poll with increasing intervals up to a maximum wait time
|
|
139
|
+
max_wait = 10 # seconds
|
|
140
|
+
waited = 0
|
|
141
|
+
check_interval = 0.5
|
|
142
|
+
|
|
143
|
+
while waited < max_wait:
|
|
144
|
+
time.sleep(check_interval)
|
|
145
|
+
waited += check_interval
|
|
146
|
+
|
|
147
|
+
if self.pid_file.is_running():
|
|
148
|
+
pid = self.pid_file.read_pid()
|
|
149
|
+
print(f"Daemon started (PID: {pid})")
|
|
150
|
+
print(f"Daemon log: {daemon_log_path}")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
# Daemon didn't start within timeout
|
|
154
|
+
print("Daemon failed to start. Check daemon log for details:")
|
|
155
|
+
print(f" {daemon_log_path}")
|
|
156
|
+
# Try to show recent log content
|
|
157
|
+
try:
|
|
158
|
+
log_file.close()
|
|
159
|
+
with open(daemon_log_path, 'r') as f:
|
|
160
|
+
content = f.read()
|
|
161
|
+
if content:
|
|
162
|
+
print("\nDaemon output:")
|
|
163
|
+
print(content[-2000:] if len(content) > 2000 else content)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
return 1
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
print(f"Failed to start daemon: {e}")
|
|
170
|
+
logger.error(f"Failed to start daemon: {e}", exc_info=True)
|
|
171
|
+
return 1
|
|
172
|
+
|
|
173
|
+
def stop(self, timeout: int = 30) -> int:
|
|
174
|
+
"""
|
|
175
|
+
Stop the daemon gracefully.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
timeout: Seconds to wait for graceful shutdown
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
0 on success, non-zero on failure
|
|
182
|
+
"""
|
|
183
|
+
pid = self.pid_file.read_pid()
|
|
184
|
+
if not pid or not self.pid_file.is_running():
|
|
185
|
+
print("Daemon is not running")
|
|
186
|
+
# Clean up stale PID file if exists
|
|
187
|
+
self.pid_file.remove()
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
print(f"Stopping daemon (PID: {pid})...")
|
|
191
|
+
|
|
192
|
+
# Send termination signal
|
|
193
|
+
try:
|
|
194
|
+
if sys.platform == 'win32':
|
|
195
|
+
# Windows: Create a stop signal file that the daemon checks
|
|
196
|
+
# This is more reliable than signals across console sessions
|
|
197
|
+
stop_file = str(self.pid_file.path) + '.stop'
|
|
198
|
+
try:
|
|
199
|
+
with open(stop_file, 'w') as f:
|
|
200
|
+
f.write(str(pid))
|
|
201
|
+
print("Stop signal sent")
|
|
202
|
+
except Exception as e:
|
|
203
|
+
print(f"Failed to create stop signal: {e}")
|
|
204
|
+
# Fall back to taskkill /F if signal file fails
|
|
205
|
+
subprocess.run(['taskkill', '/F', '/PID', str(pid)], capture_output=True)
|
|
206
|
+
else:
|
|
207
|
+
# Unix: SIGTERM for graceful shutdown
|
|
208
|
+
os.kill(pid, signal.SIGTERM)
|
|
209
|
+
except ProcessLookupError:
|
|
210
|
+
print("Daemon process not found")
|
|
211
|
+
self.pid_file.remove()
|
|
212
|
+
return 0
|
|
213
|
+
except PermissionError:
|
|
214
|
+
print("Permission denied to stop daemon")
|
|
215
|
+
return 1
|
|
216
|
+
except Exception as e:
|
|
217
|
+
print(f"Error stopping daemon: {e}")
|
|
218
|
+
return 1
|
|
219
|
+
|
|
220
|
+
# Wait for graceful shutdown
|
|
221
|
+
for i in range(timeout):
|
|
222
|
+
if not self.pid_file.is_running():
|
|
223
|
+
print("Daemon stopped")
|
|
224
|
+
self._cleanup_stop_file()
|
|
225
|
+
return 0
|
|
226
|
+
time.sleep(1)
|
|
227
|
+
if (i + 1) % 5 == 0:
|
|
228
|
+
print(f"Waiting for shutdown... ({i + 1}/{timeout}s)")
|
|
229
|
+
|
|
230
|
+
# Process didn't stop gracefully - clean up and report
|
|
231
|
+
self._cleanup_stop_file()
|
|
232
|
+
print(f"Daemon did not stop within {timeout} seconds")
|
|
233
|
+
if sys.platform == 'win32':
|
|
234
|
+
print("Forcing termination...")
|
|
235
|
+
subprocess.run(['taskkill', '/F', '/PID', str(pid)], capture_output=True)
|
|
236
|
+
time.sleep(1)
|
|
237
|
+
if not self.pid_file.is_running():
|
|
238
|
+
print("Daemon terminated")
|
|
239
|
+
return 0
|
|
240
|
+
print("Consider using 'kill -9' manually if needed")
|
|
241
|
+
return 1
|
|
242
|
+
|
|
243
|
+
def _cleanup_stop_file(self):
|
|
244
|
+
"""Remove the stop signal file if it exists."""
|
|
245
|
+
stop_file = str(self.pid_file.path) + '.stop'
|
|
246
|
+
try:
|
|
247
|
+
if os.path.exists(stop_file):
|
|
248
|
+
os.remove(stop_file)
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
def status(self) -> int:
|
|
253
|
+
"""
|
|
254
|
+
Check daemon status.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
0 if running, 1 if not running
|
|
258
|
+
"""
|
|
259
|
+
pid = self.pid_file.read_pid()
|
|
260
|
+
|
|
261
|
+
if pid and self.pid_file.is_running():
|
|
262
|
+
print(f"Daemon is running (PID: {pid})")
|
|
263
|
+
return 0
|
|
264
|
+
else:
|
|
265
|
+
print("Daemon is not running")
|
|
266
|
+
# Clean up stale PID file if exists
|
|
267
|
+
if pid:
|
|
268
|
+
self.pid_file.remove()
|
|
269
|
+
return 1
|
|
270
|
+
|
|
271
|
+
def restart(self, args: Optional[List[str]] = None) -> int:
|
|
272
|
+
"""
|
|
273
|
+
Restart the daemon.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
args: Additional arguments to pass to daemon
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
0 on success, non-zero on failure
|
|
280
|
+
"""
|
|
281
|
+
# Stop if running
|
|
282
|
+
if self.pid_file.is_running():
|
|
283
|
+
stop_result = self.stop()
|
|
284
|
+
if stop_result != 0:
|
|
285
|
+
return stop_result
|
|
286
|
+
time.sleep(2)
|
|
287
|
+
|
|
288
|
+
# Start daemon
|
|
289
|
+
return self.start(args)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Execution coordinator for preventing duplicate action runs.
|
|
3
|
+
|
|
4
|
+
Provides functionality for:
|
|
5
|
+
- Acquiring execution locks on actions
|
|
6
|
+
- Preventing concurrent execution by daemon and UI
|
|
7
|
+
- Cleaning up stale locks from crashed processes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ActionLockedError(Exception):
|
|
20
|
+
"""Raised when an action is locked by another process."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, action_id: int, locked_by: str, message: str = None):
|
|
23
|
+
self.action_id = action_id
|
|
24
|
+
self.locked_by = locked_by
|
|
25
|
+
self.message = message or f"Action {action_id} is locked by {locked_by}"
|
|
26
|
+
super().__init__(self.message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExecutionCoordinator:
|
|
30
|
+
"""
|
|
31
|
+
Coordinates action execution to prevent conflicts.
|
|
32
|
+
|
|
33
|
+
Uses database locking mechanism to prevent duplicate execution
|
|
34
|
+
when both daemon and UI try to run the same action.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
database,
|
|
40
|
+
process_id: str,
|
|
41
|
+
user_guid: str,
|
|
42
|
+
lock_timeout_seconds: int = 300
|
|
43
|
+
):
|
|
44
|
+
"""
|
|
45
|
+
Initialise the execution coordinator.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
database: Database instance with autonomous_actions methods
|
|
49
|
+
process_id: Unique identifier for this process (daemon_id or session_id)
|
|
50
|
+
user_guid: User GUID for database operations
|
|
51
|
+
lock_timeout_seconds: Seconds after which a lock is considered stale
|
|
52
|
+
"""
|
|
53
|
+
self.database = database
|
|
54
|
+
self.process_id = process_id
|
|
55
|
+
self.user_guid = user_guid
|
|
56
|
+
self.lock_timeout_seconds = lock_timeout_seconds
|
|
57
|
+
|
|
58
|
+
def try_acquire_lock(self, action_id: int) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Attempt to acquire an execution lock for an action.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
action_id: ID of the action to lock
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if lock acquired, False if another process holds it
|
|
67
|
+
"""
|
|
68
|
+
# First, clear any stale locks
|
|
69
|
+
self._clear_stale_locks()
|
|
70
|
+
|
|
71
|
+
# Try to acquire lock
|
|
72
|
+
from dtSpark.database.autonomous_actions import try_lock_action
|
|
73
|
+
success = try_lock_action(
|
|
74
|
+
conn=self.database.conn,
|
|
75
|
+
action_id=action_id,
|
|
76
|
+
locked_by=self.process_id,
|
|
77
|
+
user_guid=self.user_guid
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if success:
|
|
81
|
+
logger.debug(f"Acquired lock on action {action_id}")
|
|
82
|
+
else:
|
|
83
|
+
logger.debug(f"Failed to acquire lock on action {action_id}")
|
|
84
|
+
|
|
85
|
+
return success
|
|
86
|
+
|
|
87
|
+
def release_lock(self, action_id: int) -> bool:
|
|
88
|
+
"""
|
|
89
|
+
Release an execution lock for an action.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
action_id: ID of the action to unlock
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True if unlocked successfully, False otherwise
|
|
96
|
+
"""
|
|
97
|
+
from dtSpark.database.autonomous_actions import unlock_action
|
|
98
|
+
success = unlock_action(
|
|
99
|
+
conn=self.database.conn,
|
|
100
|
+
action_id=action_id,
|
|
101
|
+
locked_by=self.process_id,
|
|
102
|
+
user_guid=self.user_guid
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if success:
|
|
106
|
+
logger.debug(f"Released lock on action {action_id}")
|
|
107
|
+
else:
|
|
108
|
+
logger.warning(f"Failed to release lock on action {action_id}")
|
|
109
|
+
|
|
110
|
+
return success
|
|
111
|
+
|
|
112
|
+
def is_action_locked(self, action_id: int) -> bool:
|
|
113
|
+
"""
|
|
114
|
+
Check if an action is currently locked by another process.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
action_id: ID of the action to check
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if locked by another process, False otherwise
|
|
121
|
+
"""
|
|
122
|
+
from dtSpark.database.autonomous_actions import get_action_lock_info
|
|
123
|
+
lock_info = get_action_lock_info(
|
|
124
|
+
conn=self.database.conn,
|
|
125
|
+
action_id=action_id,
|
|
126
|
+
user_guid=self.user_guid
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if not lock_info or not lock_info.get('locked_by'):
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
# Not locked by another process if we hold the lock
|
|
133
|
+
if lock_info['locked_by'] == self.process_id:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
def get_lock_holder(self, action_id: int) -> Optional[str]:
|
|
139
|
+
"""
|
|
140
|
+
Get the identifier of the process holding the lock.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
action_id: ID of the action
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Process identifier if locked, None otherwise
|
|
147
|
+
"""
|
|
148
|
+
from dtSpark.database.autonomous_actions import get_action_lock_info
|
|
149
|
+
lock_info = get_action_lock_info(
|
|
150
|
+
conn=self.database.conn,
|
|
151
|
+
action_id=action_id,
|
|
152
|
+
user_guid=self.user_guid
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if lock_info:
|
|
156
|
+
return lock_info.get('locked_by')
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def _clear_stale_locks(self):
|
|
160
|
+
"""Clear locks that are older than the timeout."""
|
|
161
|
+
from dtSpark.database.autonomous_actions import clear_stale_locks
|
|
162
|
+
clear_stale_locks(
|
|
163
|
+
conn=self.database.conn,
|
|
164
|
+
lock_timeout_seconds=self.lock_timeout_seconds,
|
|
165
|
+
user_guid=self.user_guid
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def execute_with_lock(self, action_id: int, execute_func, *args, **kwargs):
|
|
169
|
+
"""
|
|
170
|
+
Execute a function while holding an action lock.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
action_id: ID of the action to lock
|
|
174
|
+
execute_func: Function to execute
|
|
175
|
+
*args, **kwargs: Arguments to pass to the function
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Result of execute_func
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
ActionLockedError: If action is locked by another process
|
|
182
|
+
"""
|
|
183
|
+
if not self.try_acquire_lock(action_id):
|
|
184
|
+
lock_holder = self.get_lock_holder(action_id)
|
|
185
|
+
raise ActionLockedError(
|
|
186
|
+
action_id=action_id,
|
|
187
|
+
locked_by=lock_holder or "unknown",
|
|
188
|
+
message=f"Action {action_id} is currently being executed by another process"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
return execute_func(*args, **kwargs)
|
|
193
|
+
finally:
|
|
194
|
+
self.release_lock(action_id)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PID file management for daemon process.
|
|
3
|
+
|
|
4
|
+
Provides functionality for:
|
|
5
|
+
- Writing and reading PID files
|
|
6
|
+
- Checking if a process is running
|
|
7
|
+
- Preventing multiple daemon instances
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PIDFile:
|
|
22
|
+
"""
|
|
23
|
+
Manages PID file for daemon process.
|
|
24
|
+
|
|
25
|
+
Prevents multiple daemon instances and enables status checking.
|
|
26
|
+
Works across Windows and Unix platforms.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path: str):
|
|
30
|
+
"""
|
|
31
|
+
Initialise the PID file manager.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
path: Path to the PID file
|
|
35
|
+
"""
|
|
36
|
+
self.path = Path(path)
|
|
37
|
+
|
|
38
|
+
def write_pid(self, pid: Optional[int] = None):
|
|
39
|
+
"""
|
|
40
|
+
Write current process PID to file.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
pid: Process ID to write (default: current process)
|
|
44
|
+
"""
|
|
45
|
+
if pid is None:
|
|
46
|
+
pid = os.getpid()
|
|
47
|
+
|
|
48
|
+
# Ensure parent directory exists
|
|
49
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
self.path.write_text(str(pid))
|
|
52
|
+
logger.info(f"PID file written: {self.path} (PID: {pid})")
|
|
53
|
+
|
|
54
|
+
def read_pid(self) -> Optional[int]:
|
|
55
|
+
"""
|
|
56
|
+
Read PID from file.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Process ID if file exists and is valid, None otherwise
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
return int(self.path.read_text().strip())
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return None
|
|
65
|
+
except ValueError:
|
|
66
|
+
logger.warning(f"Invalid PID file contents: {self.path}")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def remove(self):
|
|
70
|
+
"""Remove PID file."""
|
|
71
|
+
try:
|
|
72
|
+
self.path.unlink()
|
|
73
|
+
logger.info(f"PID file removed: {self.path}")
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def is_running(self) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Check if process with stored PID is running.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if process is running, False otherwise
|
|
83
|
+
"""
|
|
84
|
+
pid = self.read_pid()
|
|
85
|
+
if not pid:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
return self._is_process_running(pid)
|
|
89
|
+
|
|
90
|
+
def _is_process_running(self, pid: int) -> bool:
|
|
91
|
+
"""
|
|
92
|
+
Check if a process with the given PID is running.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
pid: Process ID to check
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
True if process is running, False otherwise
|
|
99
|
+
"""
|
|
100
|
+
if sys.platform == 'win32':
|
|
101
|
+
return self._is_process_running_windows(pid)
|
|
102
|
+
else:
|
|
103
|
+
return self._is_process_running_unix(pid)
|
|
104
|
+
|
|
105
|
+
def _is_process_running_windows(self, pid: int) -> bool:
|
|
106
|
+
"""Check if process is running on Windows."""
|
|
107
|
+
try:
|
|
108
|
+
import ctypes
|
|
109
|
+
kernel32 = ctypes.windll.kernel32
|
|
110
|
+
|
|
111
|
+
# PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
112
|
+
handle = kernel32.OpenProcess(0x1000, False, pid)
|
|
113
|
+
if handle:
|
|
114
|
+
kernel32.CloseHandle(handle)
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.debug(f"Error checking process on Windows: {e}")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def _is_process_running_unix(self, pid: int) -> bool:
|
|
122
|
+
"""Check if process is running on Unix."""
|
|
123
|
+
try:
|
|
124
|
+
# Signal 0 doesn't actually send a signal, just checks if process exists
|
|
125
|
+
os.kill(pid, 0)
|
|
126
|
+
return True
|
|
127
|
+
except ProcessLookupError:
|
|
128
|
+
return False
|
|
129
|
+
except PermissionError:
|
|
130
|
+
# Process exists but we don't have permission to signal it
|
|
131
|
+
return True
|
|
132
|
+
except OSError:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def acquire(self) -> bool:
|
|
136
|
+
"""
|
|
137
|
+
Attempt to acquire the PID file (atomic operation).
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
True if acquired successfully, False if daemon already running
|
|
141
|
+
"""
|
|
142
|
+
if self.is_running():
|
|
143
|
+
existing_pid = self.read_pid()
|
|
144
|
+
logger.warning(f"Daemon already running with PID {existing_pid}")
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
# Clean up stale PID file if process is not running
|
|
148
|
+
if self.path.exists():
|
|
149
|
+
logger.info("Removing stale PID file")
|
|
150
|
+
self.remove()
|
|
151
|
+
|
|
152
|
+
self.write_pid()
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
def release(self):
|
|
156
|
+
"""
|
|
157
|
+
Release the PID file.
|
|
158
|
+
|
|
159
|
+
Only removes if the current process holds it.
|
|
160
|
+
"""
|
|
161
|
+
current_pid = os.getpid()
|
|
162
|
+
stored_pid = self.read_pid()
|
|
163
|
+
|
|
164
|
+
if stored_pid == current_pid:
|
|
165
|
+
self.remove()
|
|
166
|
+
else:
|
|
167
|
+
logger.warning(
|
|
168
|
+
f"PID file contains different PID (stored: {stored_pid}, current: {current_pid})"
|
|
169
|
+
)
|