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,1191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Autonomous Actions CRUD operations module.
|
|
3
|
+
|
|
4
|
+
This module handles:
|
|
5
|
+
- Creating and managing scheduled action definitions
|
|
6
|
+
- Recording and retrieving action execution history
|
|
7
|
+
- Managing tool permissions for actions
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sqlite3
|
|
13
|
+
import logging
|
|
14
|
+
import json
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import List, Dict, Optional, Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_action(conn: sqlite3.Connection, name: str, description: str,
|
|
20
|
+
action_prompt: str, model_id: str, schedule_type: str,
|
|
21
|
+
schedule_config: Dict[str, Any], context_mode: str = 'fresh',
|
|
22
|
+
max_failures: int = 3, max_tokens: int = 8192,
|
|
23
|
+
user_guid: str = None) -> int:
|
|
24
|
+
"""
|
|
25
|
+
Create a new autonomous action.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
conn: Database connection
|
|
29
|
+
name: Unique name for the action
|
|
30
|
+
description: Human-readable description
|
|
31
|
+
action_prompt: The prompt to execute
|
|
32
|
+
model_id: Model ID to use for execution
|
|
33
|
+
schedule_type: 'one_off' or 'recurring'
|
|
34
|
+
schedule_config: JSON-serialisable schedule configuration
|
|
35
|
+
context_mode: 'fresh' (new each run) or 'cumulative' (uses prior context)
|
|
36
|
+
max_failures: Number of failures before auto-disable
|
|
37
|
+
max_tokens: Maximum tokens for LLM response (default 8192)
|
|
38
|
+
user_guid: User GUID for multi-user support
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
ID of the newly created action
|
|
42
|
+
"""
|
|
43
|
+
cursor = conn.cursor()
|
|
44
|
+
now = datetime.now()
|
|
45
|
+
config_json = json.dumps(schedule_config)
|
|
46
|
+
|
|
47
|
+
cursor.execute('''
|
|
48
|
+
INSERT INTO autonomous_actions
|
|
49
|
+
(name, description, action_prompt, model_id, schedule_type,
|
|
50
|
+
schedule_config, context_mode, max_failures, max_tokens, created_at, user_guid)
|
|
51
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
52
|
+
''', (name, description, action_prompt, model_id, schedule_type,
|
|
53
|
+
config_json, context_mode, max_failures, max_tokens, now, user_guid))
|
|
54
|
+
|
|
55
|
+
conn.commit()
|
|
56
|
+
action_id = cursor.lastrowid
|
|
57
|
+
logging.info(f"Created autonomous action '{name}' with ID {action_id} for user {user_guid}")
|
|
58
|
+
return action_id
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_action(conn: sqlite3.Connection, action_id: int,
|
|
62
|
+
user_guid: str = None) -> Optional[Dict]:
|
|
63
|
+
"""
|
|
64
|
+
Retrieve a specific action.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
conn: Database connection
|
|
68
|
+
action_id: ID of the action
|
|
69
|
+
user_guid: User GUID for multi-user support
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Action dictionary or None if not found
|
|
73
|
+
"""
|
|
74
|
+
cursor = conn.cursor()
|
|
75
|
+
cursor.execute('''
|
|
76
|
+
SELECT id, name, description, action_prompt, model_id, schedule_type,
|
|
77
|
+
schedule_config, context_mode, max_failures, failure_count,
|
|
78
|
+
is_enabled, max_tokens, created_at, last_run_at, next_run_at
|
|
79
|
+
FROM autonomous_actions
|
|
80
|
+
WHERE id = ? AND user_guid = ?
|
|
81
|
+
''', (action_id, user_guid))
|
|
82
|
+
|
|
83
|
+
row = cursor.fetchone()
|
|
84
|
+
if row:
|
|
85
|
+
return _row_to_action_dict(row)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_action_by_name(conn: sqlite3.Connection, name: str,
|
|
90
|
+
user_guid: str = None) -> Optional[Dict]:
|
|
91
|
+
"""
|
|
92
|
+
Retrieve an action by name.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
conn: Database connection
|
|
96
|
+
name: Name of the action
|
|
97
|
+
user_guid: User GUID for multi-user support
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Action dictionary or None if not found
|
|
101
|
+
"""
|
|
102
|
+
cursor = conn.cursor()
|
|
103
|
+
cursor.execute('''
|
|
104
|
+
SELECT id, name, description, action_prompt, model_id, schedule_type,
|
|
105
|
+
schedule_config, context_mode, max_failures, failure_count,
|
|
106
|
+
is_enabled, max_tokens, created_at, last_run_at, next_run_at
|
|
107
|
+
FROM autonomous_actions
|
|
108
|
+
WHERE name = ? AND user_guid = ?
|
|
109
|
+
''', (name, user_guid))
|
|
110
|
+
|
|
111
|
+
row = cursor.fetchone()
|
|
112
|
+
if row:
|
|
113
|
+
return _row_to_action_dict(row)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_all_actions(conn: sqlite3.Connection, user_guid: str = None,
|
|
118
|
+
include_disabled: bool = True) -> List[Dict]:
|
|
119
|
+
"""
|
|
120
|
+
Retrieve all actions for a user.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
conn: Database connection
|
|
124
|
+
user_guid: User GUID for multi-user support
|
|
125
|
+
include_disabled: Whether to include disabled actions
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of action dictionaries
|
|
129
|
+
"""
|
|
130
|
+
cursor = conn.cursor()
|
|
131
|
+
|
|
132
|
+
if include_disabled:
|
|
133
|
+
cursor.execute('''
|
|
134
|
+
SELECT id, name, description, action_prompt, model_id, schedule_type,
|
|
135
|
+
schedule_config, context_mode, max_failures, failure_count,
|
|
136
|
+
is_enabled, max_tokens, created_at, last_run_at, next_run_at
|
|
137
|
+
FROM autonomous_actions
|
|
138
|
+
WHERE user_guid = ?
|
|
139
|
+
ORDER BY created_at DESC
|
|
140
|
+
''', (user_guid,))
|
|
141
|
+
else:
|
|
142
|
+
cursor.execute('''
|
|
143
|
+
SELECT id, name, description, action_prompt, model_id, schedule_type,
|
|
144
|
+
schedule_config, context_mode, max_failures, failure_count,
|
|
145
|
+
is_enabled, max_tokens, created_at, last_run_at, next_run_at
|
|
146
|
+
FROM autonomous_actions
|
|
147
|
+
WHERE user_guid = ? AND is_enabled = 1
|
|
148
|
+
ORDER BY created_at DESC
|
|
149
|
+
''', (user_guid,))
|
|
150
|
+
|
|
151
|
+
return [_row_to_action_dict(row) for row in cursor.fetchall()]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def update_action(conn: sqlite3.Connection, action_id: int,
|
|
155
|
+
updates: Dict[str, Any], user_guid: str = None) -> bool:
|
|
156
|
+
"""
|
|
157
|
+
Update an action's configuration.
|
|
158
|
+
|
|
159
|
+
Automatically increments the version column for daemon change detection.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
conn: Database connection
|
|
163
|
+
action_id: ID of the action
|
|
164
|
+
updates: Dictionary of fields to update
|
|
165
|
+
user_guid: User GUID for multi-user support
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
True if successful, False otherwise
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
cursor = conn.cursor()
|
|
172
|
+
|
|
173
|
+
# Build dynamic UPDATE statement
|
|
174
|
+
allowed_fields = ['name', 'description', 'action_prompt', 'model_id',
|
|
175
|
+
'schedule_type', 'schedule_config', 'context_mode',
|
|
176
|
+
'max_failures', 'max_tokens', 'next_run_at']
|
|
177
|
+
set_clauses = []
|
|
178
|
+
values = []
|
|
179
|
+
|
|
180
|
+
for field, value in updates.items():
|
|
181
|
+
if field in allowed_fields:
|
|
182
|
+
if field == 'schedule_config' and isinstance(value, dict):
|
|
183
|
+
value = json.dumps(value)
|
|
184
|
+
set_clauses.append(f"{field} = ?")
|
|
185
|
+
values.append(value)
|
|
186
|
+
|
|
187
|
+
if not set_clauses:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
# Always increment version and update timestamp for daemon change detection
|
|
191
|
+
set_clauses.append("version = COALESCE(version, 0) + 1")
|
|
192
|
+
set_clauses.append("updated_at = ?")
|
|
193
|
+
values.append(datetime.now().isoformat())
|
|
194
|
+
|
|
195
|
+
values.extend([action_id, user_guid])
|
|
196
|
+
query = f'''
|
|
197
|
+
UPDATE autonomous_actions
|
|
198
|
+
SET {', '.join(set_clauses)}
|
|
199
|
+
WHERE id = ? AND user_guid = ?
|
|
200
|
+
'''
|
|
201
|
+
|
|
202
|
+
cursor.execute(query, values)
|
|
203
|
+
conn.commit()
|
|
204
|
+
logging.info(f"Updated action {action_id}: {list(updates.keys())}")
|
|
205
|
+
return cursor.rowcount > 0
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logging.error(f"Failed to update action {action_id}: {e}")
|
|
209
|
+
conn.rollback()
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def delete_action(conn: sqlite3.Connection, action_id: int,
|
|
214
|
+
user_guid: str = None) -> bool:
|
|
215
|
+
"""
|
|
216
|
+
Delete an action and all its related data.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
conn: Database connection
|
|
220
|
+
action_id: ID of the action to delete
|
|
221
|
+
user_guid: User GUID for multi-user support
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if successful, False otherwise
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
cursor = conn.cursor()
|
|
228
|
+
|
|
229
|
+
# Delete tool permissions
|
|
230
|
+
cursor.execute('''
|
|
231
|
+
DELETE FROM action_tool_permissions
|
|
232
|
+
WHERE action_id = ? AND user_guid = ?
|
|
233
|
+
''', (action_id, user_guid))
|
|
234
|
+
|
|
235
|
+
# Delete run history
|
|
236
|
+
cursor.execute('''
|
|
237
|
+
DELETE FROM action_runs
|
|
238
|
+
WHERE action_id = ? AND user_guid = ?
|
|
239
|
+
''', (action_id, user_guid))
|
|
240
|
+
|
|
241
|
+
# Delete the action
|
|
242
|
+
cursor.execute('''
|
|
243
|
+
DELETE FROM autonomous_actions
|
|
244
|
+
WHERE id = ? AND user_guid = ?
|
|
245
|
+
''', (action_id, user_guid))
|
|
246
|
+
|
|
247
|
+
conn.commit()
|
|
248
|
+
logging.info(f"Deleted action {action_id} for user {user_guid}")
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
logging.error(f"Failed to delete action {action_id}: {e}")
|
|
253
|
+
conn.rollback()
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def enable_action(conn: sqlite3.Connection, action_id: int,
|
|
258
|
+
user_guid: str = None) -> bool:
|
|
259
|
+
"""
|
|
260
|
+
Enable a disabled action.
|
|
261
|
+
|
|
262
|
+
Automatically increments the version column for daemon change detection.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
conn: Database connection
|
|
266
|
+
action_id: ID of the action
|
|
267
|
+
user_guid: User GUID for multi-user support
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
True if successful, False otherwise
|
|
271
|
+
"""
|
|
272
|
+
try:
|
|
273
|
+
cursor = conn.cursor()
|
|
274
|
+
now = datetime.now().isoformat()
|
|
275
|
+
cursor.execute('''
|
|
276
|
+
UPDATE autonomous_actions
|
|
277
|
+
SET is_enabled = 1, failure_count = 0,
|
|
278
|
+
version = COALESCE(version, 0) + 1,
|
|
279
|
+
updated_at = ?
|
|
280
|
+
WHERE id = ? AND user_guid = ?
|
|
281
|
+
''', (now, action_id, user_guid))
|
|
282
|
+
conn.commit()
|
|
283
|
+
logging.info(f"Enabled action {action_id}")
|
|
284
|
+
return cursor.rowcount > 0
|
|
285
|
+
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logging.error(f"Failed to enable action {action_id}: {e}")
|
|
288
|
+
conn.rollback()
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def disable_action(conn: sqlite3.Connection, action_id: int,
|
|
293
|
+
user_guid: str = None) -> bool:
|
|
294
|
+
"""
|
|
295
|
+
Disable an action.
|
|
296
|
+
|
|
297
|
+
Automatically increments the version column for daemon change detection.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
conn: Database connection
|
|
301
|
+
action_id: ID of the action
|
|
302
|
+
user_guid: User GUID for multi-user support
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
True if successful, False otherwise
|
|
306
|
+
"""
|
|
307
|
+
try:
|
|
308
|
+
cursor = conn.cursor()
|
|
309
|
+
now = datetime.now().isoformat()
|
|
310
|
+
cursor.execute('''
|
|
311
|
+
UPDATE autonomous_actions
|
|
312
|
+
SET is_enabled = 0,
|
|
313
|
+
version = COALESCE(version, 0) + 1,
|
|
314
|
+
updated_at = ?
|
|
315
|
+
WHERE id = ? AND user_guid = ?
|
|
316
|
+
''', (now, action_id, user_guid))
|
|
317
|
+
conn.commit()
|
|
318
|
+
logging.info(f"Disabled action {action_id}")
|
|
319
|
+
return cursor.rowcount > 0
|
|
320
|
+
|
|
321
|
+
except Exception as e:
|
|
322
|
+
logging.error(f"Failed to disable action {action_id}: {e}")
|
|
323
|
+
conn.rollback()
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def increment_failure_count(conn: sqlite3.Connection, action_id: int,
|
|
328
|
+
user_guid: str = None) -> Dict[str, Any]:
|
|
329
|
+
"""
|
|
330
|
+
Increment failure count and auto-disable if threshold reached.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
conn: Database connection
|
|
334
|
+
action_id: ID of the action
|
|
335
|
+
user_guid: User GUID for multi-user support
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Dict with 'failure_count', 'max_failures', 'auto_disabled' keys
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
cursor = conn.cursor()
|
|
342
|
+
|
|
343
|
+
# Get current counts
|
|
344
|
+
cursor.execute('''
|
|
345
|
+
SELECT failure_count, max_failures
|
|
346
|
+
FROM autonomous_actions
|
|
347
|
+
WHERE id = ? AND user_guid = ?
|
|
348
|
+
''', (action_id, user_guid))
|
|
349
|
+
|
|
350
|
+
row = cursor.fetchone()
|
|
351
|
+
if not row:
|
|
352
|
+
return {'failure_count': 0, 'max_failures': 3, 'auto_disabled': False}
|
|
353
|
+
|
|
354
|
+
new_count = row['failure_count'] + 1
|
|
355
|
+
max_failures = row['max_failures']
|
|
356
|
+
auto_disabled = new_count >= max_failures
|
|
357
|
+
|
|
358
|
+
if auto_disabled:
|
|
359
|
+
cursor.execute('''
|
|
360
|
+
UPDATE autonomous_actions
|
|
361
|
+
SET failure_count = ?, is_enabled = 0
|
|
362
|
+
WHERE id = ? AND user_guid = ?
|
|
363
|
+
''', (new_count, action_id, user_guid))
|
|
364
|
+
logging.error(f"Action {action_id} auto-disabled after {new_count} failures")
|
|
365
|
+
else:
|
|
366
|
+
cursor.execute('''
|
|
367
|
+
UPDATE autonomous_actions
|
|
368
|
+
SET failure_count = ?
|
|
369
|
+
WHERE id = ? AND user_guid = ?
|
|
370
|
+
''', (new_count, action_id, user_guid))
|
|
371
|
+
logging.warning(f"Action {action_id} failure count: {new_count}/{max_failures}")
|
|
372
|
+
|
|
373
|
+
conn.commit()
|
|
374
|
+
return {
|
|
375
|
+
'failure_count': new_count,
|
|
376
|
+
'max_failures': max_failures,
|
|
377
|
+
'auto_disabled': auto_disabled
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
except Exception as e:
|
|
381
|
+
logging.error(f"Failed to increment failure count for action {action_id}: {e}")
|
|
382
|
+
conn.rollback()
|
|
383
|
+
return {'failure_count': 0, 'max_failures': 3, 'auto_disabled': False}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def update_last_run(conn: sqlite3.Connection, action_id: int,
|
|
387
|
+
next_run_at: Optional[datetime] = None,
|
|
388
|
+
user_guid: str = None) -> bool:
|
|
389
|
+
"""
|
|
390
|
+
Update last_run_at and optionally next_run_at.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
conn: Database connection
|
|
394
|
+
action_id: ID of the action
|
|
395
|
+
next_run_at: Next scheduled run time (None for one-off)
|
|
396
|
+
user_guid: User GUID for multi-user support
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
True if successful
|
|
400
|
+
"""
|
|
401
|
+
try:
|
|
402
|
+
cursor = conn.cursor()
|
|
403
|
+
now = datetime.now()
|
|
404
|
+
|
|
405
|
+
cursor.execute('''
|
|
406
|
+
UPDATE autonomous_actions
|
|
407
|
+
SET last_run_at = ?, next_run_at = ?
|
|
408
|
+
WHERE id = ? AND user_guid = ?
|
|
409
|
+
''', (now, next_run_at, action_id, user_guid))
|
|
410
|
+
|
|
411
|
+
conn.commit()
|
|
412
|
+
return True
|
|
413
|
+
|
|
414
|
+
except Exception as e:
|
|
415
|
+
logging.error(f"Failed to update last run for action {action_id}: {e}")
|
|
416
|
+
conn.rollback()
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# --- Action Runs ---
|
|
421
|
+
|
|
422
|
+
def record_action_run(conn: sqlite3.Connection, action_id: int,
|
|
423
|
+
status: str, user_guid: str = None,
|
|
424
|
+
result_text: str = None, result_html: str = None,
|
|
425
|
+
error_message: str = None, input_tokens: int = 0,
|
|
426
|
+
output_tokens: int = 0, context_snapshot: str = None) -> int:
|
|
427
|
+
"""
|
|
428
|
+
Record a new action run.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
conn: Database connection
|
|
432
|
+
action_id: ID of the action
|
|
433
|
+
status: 'running', 'completed', or 'failed'
|
|
434
|
+
user_guid: User GUID for multi-user support
|
|
435
|
+
result_text: Plain text result
|
|
436
|
+
result_html: HTML formatted result
|
|
437
|
+
error_message: Error message if failed
|
|
438
|
+
input_tokens: Input tokens used
|
|
439
|
+
output_tokens: Output tokens used
|
|
440
|
+
context_snapshot: JSON snapshot of context if cumulative mode
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
ID of the run record
|
|
444
|
+
"""
|
|
445
|
+
cursor = conn.cursor()
|
|
446
|
+
now = datetime.now()
|
|
447
|
+
completed_at = now if status in ('completed', 'failed') else None
|
|
448
|
+
|
|
449
|
+
cursor.execute('''
|
|
450
|
+
INSERT INTO action_runs
|
|
451
|
+
(action_id, started_at, completed_at, status, result_text, result_html,
|
|
452
|
+
error_message, input_tokens, output_tokens, context_snapshot, user_guid)
|
|
453
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
454
|
+
''', (action_id, now, completed_at, status, result_text, result_html,
|
|
455
|
+
error_message, input_tokens, output_tokens, context_snapshot, user_guid))
|
|
456
|
+
|
|
457
|
+
conn.commit()
|
|
458
|
+
run_id = cursor.lastrowid
|
|
459
|
+
logging.info(f"Recorded action run {run_id} for action {action_id} with status '{status}'")
|
|
460
|
+
return run_id
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def update_action_run(conn: sqlite3.Connection, run_id: int,
|
|
464
|
+
status: str, user_guid: str = None,
|
|
465
|
+
result_text: str = None, result_html: str = None,
|
|
466
|
+
error_message: str = None, input_tokens: int = None,
|
|
467
|
+
output_tokens: int = None, context_snapshot: str = None) -> bool:
|
|
468
|
+
"""
|
|
469
|
+
Update an existing action run record.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
conn: Database connection
|
|
473
|
+
run_id: ID of the run
|
|
474
|
+
status: New status
|
|
475
|
+
user_guid: User GUID for multi-user support
|
|
476
|
+
result_text: Plain text result
|
|
477
|
+
result_html: HTML formatted result
|
|
478
|
+
error_message: Error message if failed
|
|
479
|
+
input_tokens: Input tokens used
|
|
480
|
+
output_tokens: Output tokens used
|
|
481
|
+
context_snapshot: JSON snapshot of context
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
True if successful
|
|
485
|
+
"""
|
|
486
|
+
try:
|
|
487
|
+
cursor = conn.cursor()
|
|
488
|
+
completed_at = datetime.now() if status in ('completed', 'failed') else None
|
|
489
|
+
|
|
490
|
+
cursor.execute('''
|
|
491
|
+
UPDATE action_runs
|
|
492
|
+
SET status = ?, completed_at = ?, result_text = ?, result_html = ?,
|
|
493
|
+
error_message = ?, input_tokens = COALESCE(?, input_tokens),
|
|
494
|
+
output_tokens = COALESCE(?, output_tokens),
|
|
495
|
+
context_snapshot = COALESCE(?, context_snapshot)
|
|
496
|
+
WHERE id = ? AND user_guid = ?
|
|
497
|
+
''', (status, completed_at, result_text, result_html, error_message,
|
|
498
|
+
input_tokens, output_tokens, context_snapshot, run_id, user_guid))
|
|
499
|
+
|
|
500
|
+
conn.commit()
|
|
501
|
+
return True
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
logging.error(f"Failed to update action run {run_id}: {e}")
|
|
505
|
+
conn.rollback()
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def get_action_run(conn: sqlite3.Connection, run_id: int,
|
|
510
|
+
user_guid: str = None) -> Optional[Dict]:
|
|
511
|
+
"""
|
|
512
|
+
Retrieve a specific action run.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
conn: Database connection
|
|
516
|
+
run_id: ID of the run
|
|
517
|
+
user_guid: User GUID for multi-user support
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
Run dictionary or None if not found
|
|
521
|
+
"""
|
|
522
|
+
cursor = conn.cursor()
|
|
523
|
+
cursor.execute('''
|
|
524
|
+
SELECT r.id, r.action_id, r.started_at, r.completed_at, r.status,
|
|
525
|
+
r.result_text, r.result_html, r.error_message, r.input_tokens,
|
|
526
|
+
r.output_tokens, r.context_snapshot, a.name as action_name
|
|
527
|
+
FROM action_runs r
|
|
528
|
+
JOIN autonomous_actions a ON r.action_id = a.id
|
|
529
|
+
WHERE r.id = ? AND r.user_guid = ?
|
|
530
|
+
''', (run_id, user_guid))
|
|
531
|
+
|
|
532
|
+
row = cursor.fetchone()
|
|
533
|
+
if row:
|
|
534
|
+
return _row_to_run_dict(row)
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def get_action_runs(conn: sqlite3.Connection, action_id: int,
|
|
539
|
+
user_guid: str = None, limit: int = 50,
|
|
540
|
+
offset: int = 0) -> List[Dict]:
|
|
541
|
+
"""
|
|
542
|
+
Retrieve runs for an action.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
conn: Database connection
|
|
546
|
+
action_id: ID of the action
|
|
547
|
+
user_guid: User GUID for multi-user support
|
|
548
|
+
limit: Maximum number of runs to return
|
|
549
|
+
offset: Offset for pagination
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
List of run dictionaries
|
|
553
|
+
"""
|
|
554
|
+
cursor = conn.cursor()
|
|
555
|
+
cursor.execute('''
|
|
556
|
+
SELECT r.id, r.action_id, r.started_at, r.completed_at, r.status,
|
|
557
|
+
r.result_text, r.result_html, r.error_message, r.input_tokens,
|
|
558
|
+
r.output_tokens, r.context_snapshot, a.name as action_name
|
|
559
|
+
FROM action_runs r
|
|
560
|
+
JOIN autonomous_actions a ON r.action_id = a.id
|
|
561
|
+
WHERE r.action_id = ? AND r.user_guid = ?
|
|
562
|
+
ORDER BY r.started_at DESC
|
|
563
|
+
LIMIT ? OFFSET ?
|
|
564
|
+
''', (action_id, user_guid, limit, offset))
|
|
565
|
+
|
|
566
|
+
return [_row_to_run_dict(row) for row in cursor.fetchall()]
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def get_recent_runs(conn: sqlite3.Connection, user_guid: str = None,
|
|
570
|
+
limit: int = 20) -> List[Dict]:
|
|
571
|
+
"""
|
|
572
|
+
Retrieve recent runs across all actions.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
conn: Database connection
|
|
576
|
+
user_guid: User GUID for multi-user support
|
|
577
|
+
limit: Maximum number of runs to return
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
List of run dictionaries
|
|
581
|
+
"""
|
|
582
|
+
cursor = conn.cursor()
|
|
583
|
+
cursor.execute('''
|
|
584
|
+
SELECT r.id, r.action_id, r.started_at, r.completed_at, r.status,
|
|
585
|
+
r.result_text, r.result_html, r.error_message, r.input_tokens,
|
|
586
|
+
r.output_tokens, r.context_snapshot, a.name as action_name
|
|
587
|
+
FROM action_runs r
|
|
588
|
+
JOIN autonomous_actions a ON r.action_id = a.id
|
|
589
|
+
WHERE r.user_guid = ?
|
|
590
|
+
ORDER BY r.started_at DESC
|
|
591
|
+
LIMIT ?
|
|
592
|
+
''', (user_guid, limit))
|
|
593
|
+
|
|
594
|
+
return [_row_to_run_dict(row) for row in cursor.fetchall()]
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def get_failed_action_count(conn: sqlite3.Connection,
|
|
598
|
+
user_guid: str = None) -> int:
|
|
599
|
+
"""
|
|
600
|
+
Get count of disabled actions (for home screen indicator).
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
conn: Database connection
|
|
604
|
+
user_guid: User GUID for multi-user support
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
Count of disabled actions
|
|
608
|
+
"""
|
|
609
|
+
cursor = conn.cursor()
|
|
610
|
+
cursor.execute('''
|
|
611
|
+
SELECT COUNT(*) as count
|
|
612
|
+
FROM autonomous_actions
|
|
613
|
+
WHERE user_guid = ? AND is_enabled = 0 AND failure_count > 0
|
|
614
|
+
''', (user_guid,))
|
|
615
|
+
|
|
616
|
+
row = cursor.fetchone()
|
|
617
|
+
return row['count'] if row else 0
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# --- Tool Permissions ---
|
|
621
|
+
|
|
622
|
+
def set_action_tool_permission(conn: sqlite3.Connection, action_id: int,
|
|
623
|
+
tool_name: str, server_name: str,
|
|
624
|
+
permission_state: str,
|
|
625
|
+
user_guid: str = None) -> bool:
|
|
626
|
+
"""
|
|
627
|
+
Set a tool permission for an action.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
conn: Database connection
|
|
631
|
+
action_id: ID of the action
|
|
632
|
+
tool_name: Name of the tool
|
|
633
|
+
server_name: Name of the MCP server
|
|
634
|
+
permission_state: Permission state ('allowed', 'denied')
|
|
635
|
+
user_guid: User GUID for multi-user support
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
True if successful
|
|
639
|
+
"""
|
|
640
|
+
try:
|
|
641
|
+
cursor = conn.cursor()
|
|
642
|
+
now = datetime.now()
|
|
643
|
+
|
|
644
|
+
cursor.execute('''
|
|
645
|
+
INSERT INTO action_tool_permissions
|
|
646
|
+
(action_id, tool_name, server_name, permission_state, granted_at, user_guid)
|
|
647
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
648
|
+
ON CONFLICT(action_id, tool_name) DO UPDATE SET
|
|
649
|
+
server_name = excluded.server_name,
|
|
650
|
+
permission_state = excluded.permission_state,
|
|
651
|
+
granted_at = excluded.granted_at
|
|
652
|
+
''', (action_id, tool_name, server_name, permission_state, now, user_guid))
|
|
653
|
+
|
|
654
|
+
conn.commit()
|
|
655
|
+
return True
|
|
656
|
+
|
|
657
|
+
except Exception as e:
|
|
658
|
+
logging.error(f"Failed to set tool permission: {e}")
|
|
659
|
+
conn.rollback()
|
|
660
|
+
return False
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def set_action_tool_permissions_batch(conn: sqlite3.Connection, action_id: int,
|
|
664
|
+
permissions: List[Dict[str, str]],
|
|
665
|
+
user_guid: str = None) -> bool:
|
|
666
|
+
"""
|
|
667
|
+
Set multiple tool permissions for an action.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
conn: Database connection
|
|
671
|
+
action_id: ID of the action
|
|
672
|
+
permissions: List of dicts with 'tool_name', 'server_name', 'permission_state'
|
|
673
|
+
user_guid: User GUID for multi-user support
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
True if successful
|
|
677
|
+
"""
|
|
678
|
+
try:
|
|
679
|
+
cursor = conn.cursor()
|
|
680
|
+
now = datetime.now()
|
|
681
|
+
|
|
682
|
+
# Clear existing permissions
|
|
683
|
+
cursor.execute('''
|
|
684
|
+
DELETE FROM action_tool_permissions
|
|
685
|
+
WHERE action_id = ? AND user_guid = ?
|
|
686
|
+
''', (action_id, user_guid))
|
|
687
|
+
|
|
688
|
+
# Insert new permissions
|
|
689
|
+
for perm in permissions:
|
|
690
|
+
cursor.execute('''
|
|
691
|
+
INSERT INTO action_tool_permissions
|
|
692
|
+
(action_id, tool_name, server_name, permission_state, granted_at, user_guid)
|
|
693
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
694
|
+
''', (action_id, perm['tool_name'], perm.get('server_name'),
|
|
695
|
+
perm['permission_state'], now, user_guid))
|
|
696
|
+
|
|
697
|
+
conn.commit()
|
|
698
|
+
logging.info(f"Set {len(permissions)} tool permissions for action {action_id}")
|
|
699
|
+
return True
|
|
700
|
+
|
|
701
|
+
except Exception as e:
|
|
702
|
+
logging.error(f"Failed to set tool permissions batch: {e}")
|
|
703
|
+
conn.rollback()
|
|
704
|
+
return False
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def get_action_tool_permissions(conn: sqlite3.Connection, action_id: int,
|
|
708
|
+
user_guid: str = None) -> List[Dict]:
|
|
709
|
+
"""
|
|
710
|
+
Get all tool permissions for an action.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
conn: Database connection
|
|
714
|
+
action_id: ID of the action
|
|
715
|
+
user_guid: User GUID for multi-user support
|
|
716
|
+
|
|
717
|
+
Returns:
|
|
718
|
+
List of permission dictionaries
|
|
719
|
+
"""
|
|
720
|
+
cursor = conn.cursor()
|
|
721
|
+
cursor.execute('''
|
|
722
|
+
SELECT tool_name, server_name, permission_state, granted_at
|
|
723
|
+
FROM action_tool_permissions
|
|
724
|
+
WHERE action_id = ? AND user_guid = ?
|
|
725
|
+
''', (action_id, user_guid))
|
|
726
|
+
|
|
727
|
+
return [
|
|
728
|
+
{
|
|
729
|
+
'tool_name': row['tool_name'],
|
|
730
|
+
'server_name': row['server_name'],
|
|
731
|
+
'permission_state': row['permission_state'],
|
|
732
|
+
'granted_at': row['granted_at']
|
|
733
|
+
}
|
|
734
|
+
for row in cursor.fetchall()
|
|
735
|
+
]
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
# --- Helper Functions ---
|
|
739
|
+
|
|
740
|
+
def _row_to_action_dict(row) -> Dict:
|
|
741
|
+
"""Convert a database row to an action dictionary."""
|
|
742
|
+
schedule_config = row['schedule_config']
|
|
743
|
+
if isinstance(schedule_config, str):
|
|
744
|
+
try:
|
|
745
|
+
schedule_config = json.loads(schedule_config)
|
|
746
|
+
except json.JSONDecodeError:
|
|
747
|
+
schedule_config = {}
|
|
748
|
+
|
|
749
|
+
result = {
|
|
750
|
+
'id': row['id'],
|
|
751
|
+
'name': row['name'],
|
|
752
|
+
'description': row['description'],
|
|
753
|
+
'action_prompt': row['action_prompt'],
|
|
754
|
+
'model_id': row['model_id'],
|
|
755
|
+
'schedule_type': row['schedule_type'],
|
|
756
|
+
'schedule_config': schedule_config,
|
|
757
|
+
'context_mode': row['context_mode'],
|
|
758
|
+
'max_failures': row['max_failures'],
|
|
759
|
+
'failure_count': row['failure_count'],
|
|
760
|
+
'is_enabled': bool(row['is_enabled']),
|
|
761
|
+
'max_tokens': row['max_tokens'] if 'max_tokens' in row.keys() else 8192,
|
|
762
|
+
'created_at': row['created_at'],
|
|
763
|
+
'last_run_at': row['last_run_at'],
|
|
764
|
+
'next_run_at': row['next_run_at']
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
# Add daemon support fields if available
|
|
768
|
+
if 'version' in row.keys():
|
|
769
|
+
result['version'] = row['version'] or 1
|
|
770
|
+
if 'locked_by' in row.keys():
|
|
771
|
+
result['locked_by'] = row['locked_by']
|
|
772
|
+
if 'locked_at' in row.keys():
|
|
773
|
+
result['locked_at'] = row['locked_at']
|
|
774
|
+
if 'updated_at' in row.keys():
|
|
775
|
+
result['updated_at'] = row['updated_at']
|
|
776
|
+
|
|
777
|
+
return result
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _row_to_run_dict(row) -> Dict:
|
|
781
|
+
"""Convert a database row to a run dictionary."""
|
|
782
|
+
return {
|
|
783
|
+
'id': row['id'],
|
|
784
|
+
'action_id': row['action_id'],
|
|
785
|
+
'action_name': row['action_name'],
|
|
786
|
+
'started_at': row['started_at'],
|
|
787
|
+
'completed_at': row['completed_at'],
|
|
788
|
+
'status': row['status'],
|
|
789
|
+
'result_text': row['result_text'],
|
|
790
|
+
'result_html': row['result_html'],
|
|
791
|
+
'error_message': row['error_message'],
|
|
792
|
+
'input_tokens': row['input_tokens'],
|
|
793
|
+
'output_tokens': row['output_tokens'],
|
|
794
|
+
'context_snapshot': row['context_snapshot']
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# --- Daemon Support Functions ---
|
|
799
|
+
|
|
800
|
+
def get_all_actions_with_version(
|
|
801
|
+
conn: sqlite3.Connection,
|
|
802
|
+
user_guid: str,
|
|
803
|
+
include_disabled: bool = False
|
|
804
|
+
) -> List[Dict]:
|
|
805
|
+
"""
|
|
806
|
+
Get all actions with version information for change detection.
|
|
807
|
+
|
|
808
|
+
Used by daemon to detect new, modified, or deleted actions.
|
|
809
|
+
|
|
810
|
+
Args:
|
|
811
|
+
conn: Database connection
|
|
812
|
+
user_guid: User GUID for filtering
|
|
813
|
+
include_disabled: Whether to include disabled actions
|
|
814
|
+
|
|
815
|
+
Returns:
|
|
816
|
+
List of action dictionaries with version field
|
|
817
|
+
"""
|
|
818
|
+
cursor = conn.cursor()
|
|
819
|
+
|
|
820
|
+
if include_disabled:
|
|
821
|
+
cursor.execute('''
|
|
822
|
+
SELECT * FROM autonomous_actions
|
|
823
|
+
WHERE user_guid = ?
|
|
824
|
+
ORDER BY name
|
|
825
|
+
''', (user_guid,))
|
|
826
|
+
else:
|
|
827
|
+
cursor.execute('''
|
|
828
|
+
SELECT * FROM autonomous_actions
|
|
829
|
+
WHERE user_guid = ? AND is_enabled = 1
|
|
830
|
+
ORDER BY name
|
|
831
|
+
''', (user_guid,))
|
|
832
|
+
|
|
833
|
+
return [_row_to_action_dict(row) for row in cursor.fetchall()]
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def increment_action_version(
|
|
837
|
+
conn: sqlite3.Connection,
|
|
838
|
+
action_id: int,
|
|
839
|
+
user_guid: str
|
|
840
|
+
) -> bool:
|
|
841
|
+
"""
|
|
842
|
+
Increment the version of an action to signal a change.
|
|
843
|
+
|
|
844
|
+
Should be called whenever an action is modified.
|
|
845
|
+
|
|
846
|
+
Args:
|
|
847
|
+
conn: Database connection
|
|
848
|
+
action_id: Action ID
|
|
849
|
+
user_guid: User GUID for verification
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
True if successful, False otherwise
|
|
853
|
+
"""
|
|
854
|
+
cursor = conn.cursor()
|
|
855
|
+
now = datetime.now().isoformat()
|
|
856
|
+
|
|
857
|
+
cursor.execute('''
|
|
858
|
+
UPDATE autonomous_actions
|
|
859
|
+
SET version = COALESCE(version, 0) + 1,
|
|
860
|
+
updated_at = ?
|
|
861
|
+
WHERE id = ? AND user_guid = ?
|
|
862
|
+
''', (now, action_id, user_guid))
|
|
863
|
+
|
|
864
|
+
conn.commit()
|
|
865
|
+
return cursor.rowcount > 0
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def try_lock_action(
|
|
869
|
+
conn: sqlite3.Connection,
|
|
870
|
+
action_id: int,
|
|
871
|
+
locked_by: str,
|
|
872
|
+
user_guid: str
|
|
873
|
+
) -> bool:
|
|
874
|
+
"""
|
|
875
|
+
Attempt to acquire an execution lock on an action.
|
|
876
|
+
|
|
877
|
+
Uses optimistic locking - only succeeds if action is not already locked.
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
conn: Database connection
|
|
881
|
+
action_id: Action ID to lock
|
|
882
|
+
locked_by: Identifier of the locking process (daemon_id or session_id)
|
|
883
|
+
user_guid: User GUID for verification
|
|
884
|
+
|
|
885
|
+
Returns:
|
|
886
|
+
True if lock acquired, False if already locked by another process
|
|
887
|
+
"""
|
|
888
|
+
cursor = conn.cursor()
|
|
889
|
+
now = datetime.now().isoformat()
|
|
890
|
+
|
|
891
|
+
# Try to acquire lock only if not already locked
|
|
892
|
+
cursor.execute('''
|
|
893
|
+
UPDATE autonomous_actions
|
|
894
|
+
SET locked_by = ?,
|
|
895
|
+
locked_at = ?
|
|
896
|
+
WHERE id = ? AND user_guid = ?
|
|
897
|
+
AND (locked_by IS NULL OR locked_by = ?)
|
|
898
|
+
''', (locked_by, now, action_id, user_guid, locked_by))
|
|
899
|
+
|
|
900
|
+
conn.commit()
|
|
901
|
+
return cursor.rowcount > 0
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def unlock_action(
|
|
905
|
+
conn: sqlite3.Connection,
|
|
906
|
+
action_id: int,
|
|
907
|
+
locked_by: str,
|
|
908
|
+
user_guid: str
|
|
909
|
+
) -> bool:
|
|
910
|
+
"""
|
|
911
|
+
Release an execution lock on an action.
|
|
912
|
+
|
|
913
|
+
Only releases if the lock is held by the specified process.
|
|
914
|
+
|
|
915
|
+
Args:
|
|
916
|
+
conn: Database connection
|
|
917
|
+
action_id: Action ID to unlock
|
|
918
|
+
locked_by: Identifier of the process holding the lock
|
|
919
|
+
user_guid: User GUID for verification
|
|
920
|
+
|
|
921
|
+
Returns:
|
|
922
|
+
True if unlocked successfully, False otherwise
|
|
923
|
+
"""
|
|
924
|
+
cursor = conn.cursor()
|
|
925
|
+
|
|
926
|
+
cursor.execute('''
|
|
927
|
+
UPDATE autonomous_actions
|
|
928
|
+
SET locked_by = NULL,
|
|
929
|
+
locked_at = NULL
|
|
930
|
+
WHERE id = ? AND user_guid = ? AND locked_by = ?
|
|
931
|
+
''', (action_id, user_guid, locked_by))
|
|
932
|
+
|
|
933
|
+
conn.commit()
|
|
934
|
+
return cursor.rowcount > 0
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def get_action_lock_info(
|
|
938
|
+
conn: sqlite3.Connection,
|
|
939
|
+
action_id: int,
|
|
940
|
+
user_guid: str
|
|
941
|
+
) -> Optional[Dict]:
|
|
942
|
+
"""
|
|
943
|
+
Get lock information for an action.
|
|
944
|
+
|
|
945
|
+
Args:
|
|
946
|
+
conn: Database connection
|
|
947
|
+
action_id: Action ID
|
|
948
|
+
user_guid: User GUID for verification
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
Dictionary with locked_by and locked_at, or None if not found
|
|
952
|
+
"""
|
|
953
|
+
cursor = conn.cursor()
|
|
954
|
+
|
|
955
|
+
cursor.execute('''
|
|
956
|
+
SELECT locked_by, locked_at
|
|
957
|
+
FROM autonomous_actions
|
|
958
|
+
WHERE id = ? AND user_guid = ?
|
|
959
|
+
''', (action_id, user_guid))
|
|
960
|
+
|
|
961
|
+
row = cursor.fetchone()
|
|
962
|
+
if row:
|
|
963
|
+
return {
|
|
964
|
+
'locked_by': row['locked_by'],
|
|
965
|
+
'locked_at': row['locked_at']
|
|
966
|
+
}
|
|
967
|
+
return None
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def clear_stale_locks(
|
|
971
|
+
conn: sqlite3.Connection,
|
|
972
|
+
lock_timeout_seconds: int = 300,
|
|
973
|
+
user_guid: Optional[str] = None
|
|
974
|
+
) -> int:
|
|
975
|
+
"""
|
|
976
|
+
Clear locks that are older than the timeout.
|
|
977
|
+
|
|
978
|
+
Used to recover from crashed processes that didn't release their locks.
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
conn: Database connection
|
|
982
|
+
lock_timeout_seconds: Seconds after which a lock is considered stale
|
|
983
|
+
user_guid: Optional user GUID filter (clears all if None)
|
|
984
|
+
|
|
985
|
+
Returns:
|
|
986
|
+
Number of stale locks cleared
|
|
987
|
+
"""
|
|
988
|
+
cursor = conn.cursor()
|
|
989
|
+
from datetime import timedelta
|
|
990
|
+
cutoff_time = (datetime.now() - timedelta(seconds=lock_timeout_seconds)).isoformat()
|
|
991
|
+
|
|
992
|
+
if user_guid:
|
|
993
|
+
cursor.execute('''
|
|
994
|
+
UPDATE autonomous_actions
|
|
995
|
+
SET locked_by = NULL,
|
|
996
|
+
locked_at = NULL
|
|
997
|
+
WHERE locked_at IS NOT NULL
|
|
998
|
+
AND locked_at < ?
|
|
999
|
+
AND user_guid = ?
|
|
1000
|
+
''', (cutoff_time, user_guid))
|
|
1001
|
+
else:
|
|
1002
|
+
cursor.execute('''
|
|
1003
|
+
UPDATE autonomous_actions
|
|
1004
|
+
SET locked_by = NULL,
|
|
1005
|
+
locked_at = NULL
|
|
1006
|
+
WHERE locked_at IS NOT NULL
|
|
1007
|
+
AND locked_at < ?
|
|
1008
|
+
''', (cutoff_time,))
|
|
1009
|
+
|
|
1010
|
+
conn.commit()
|
|
1011
|
+
cleared = cursor.rowcount
|
|
1012
|
+
|
|
1013
|
+
if cleared > 0:
|
|
1014
|
+
logging.info(f"Cleared {cleared} stale action lock(s)")
|
|
1015
|
+
|
|
1016
|
+
return cleared
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
# --- Daemon Registry Functions ---
|
|
1020
|
+
|
|
1021
|
+
def register_daemon(
|
|
1022
|
+
conn: sqlite3.Connection,
|
|
1023
|
+
daemon_id: str,
|
|
1024
|
+
hostname: str,
|
|
1025
|
+
pid: int,
|
|
1026
|
+
user_guid: str
|
|
1027
|
+
) -> bool:
|
|
1028
|
+
"""
|
|
1029
|
+
Register a daemon process in the database.
|
|
1030
|
+
|
|
1031
|
+
Args:
|
|
1032
|
+
conn: Database connection
|
|
1033
|
+
daemon_id: Unique daemon identifier
|
|
1034
|
+
hostname: Hostname where daemon is running
|
|
1035
|
+
pid: Process ID
|
|
1036
|
+
user_guid: User GUID
|
|
1037
|
+
|
|
1038
|
+
Returns:
|
|
1039
|
+
True if registered successfully
|
|
1040
|
+
"""
|
|
1041
|
+
cursor = conn.cursor()
|
|
1042
|
+
now = datetime.now().isoformat()
|
|
1043
|
+
|
|
1044
|
+
try:
|
|
1045
|
+
cursor.execute('''
|
|
1046
|
+
INSERT INTO daemon_registry (daemon_id, hostname, pid, started_at, last_heartbeat, status, user_guid)
|
|
1047
|
+
VALUES (?, ?, ?, ?, ?, 'running', ?)
|
|
1048
|
+
''', (daemon_id, hostname, pid, now, now, user_guid))
|
|
1049
|
+
conn.commit()
|
|
1050
|
+
logging.info(f"Daemon registered: {daemon_id} (PID: {pid})")
|
|
1051
|
+
return True
|
|
1052
|
+
except sqlite3.IntegrityError:
|
|
1053
|
+
# Daemon ID already exists, update it
|
|
1054
|
+
cursor.execute('''
|
|
1055
|
+
UPDATE daemon_registry
|
|
1056
|
+
SET hostname = ?, pid = ?, started_at = ?, last_heartbeat = ?, status = 'running'
|
|
1057
|
+
WHERE daemon_id = ?
|
|
1058
|
+
''', (hostname, pid, now, now, daemon_id))
|
|
1059
|
+
conn.commit()
|
|
1060
|
+
logging.info(f"Daemon re-registered: {daemon_id} (PID: {pid})")
|
|
1061
|
+
return True
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def update_daemon_heartbeat(
|
|
1065
|
+
conn: sqlite3.Connection,
|
|
1066
|
+
daemon_id: str
|
|
1067
|
+
) -> bool:
|
|
1068
|
+
"""
|
|
1069
|
+
Update daemon heartbeat timestamp.
|
|
1070
|
+
|
|
1071
|
+
Should be called periodically to indicate daemon is alive.
|
|
1072
|
+
|
|
1073
|
+
Args:
|
|
1074
|
+
conn: Database connection
|
|
1075
|
+
daemon_id: Daemon identifier
|
|
1076
|
+
|
|
1077
|
+
Returns:
|
|
1078
|
+
True if updated successfully
|
|
1079
|
+
"""
|
|
1080
|
+
cursor = conn.cursor()
|
|
1081
|
+
now = datetime.now().isoformat()
|
|
1082
|
+
|
|
1083
|
+
cursor.execute('''
|
|
1084
|
+
UPDATE daemon_registry
|
|
1085
|
+
SET last_heartbeat = ?
|
|
1086
|
+
WHERE daemon_id = ?
|
|
1087
|
+
''', (now, daemon_id))
|
|
1088
|
+
|
|
1089
|
+
conn.commit()
|
|
1090
|
+
return cursor.rowcount > 0
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def unregister_daemon(
|
|
1094
|
+
conn: sqlite3.Connection,
|
|
1095
|
+
daemon_id: str
|
|
1096
|
+
) -> bool:
|
|
1097
|
+
"""
|
|
1098
|
+
Unregister a daemon process.
|
|
1099
|
+
|
|
1100
|
+
Args:
|
|
1101
|
+
conn: Database connection
|
|
1102
|
+
daemon_id: Daemon identifier
|
|
1103
|
+
|
|
1104
|
+
Returns:
|
|
1105
|
+
True if unregistered successfully
|
|
1106
|
+
"""
|
|
1107
|
+
cursor = conn.cursor()
|
|
1108
|
+
|
|
1109
|
+
cursor.execute('''
|
|
1110
|
+
UPDATE daemon_registry
|
|
1111
|
+
SET status = 'stopped'
|
|
1112
|
+
WHERE daemon_id = ?
|
|
1113
|
+
''', (daemon_id,))
|
|
1114
|
+
|
|
1115
|
+
conn.commit()
|
|
1116
|
+
logging.info(f"Daemon unregistered: {daemon_id}")
|
|
1117
|
+
return cursor.rowcount > 0
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def get_running_daemons(
|
|
1121
|
+
conn: sqlite3.Connection,
|
|
1122
|
+
user_guid: Optional[str] = None
|
|
1123
|
+
) -> List[Dict]:
|
|
1124
|
+
"""
|
|
1125
|
+
Get list of running daemons.
|
|
1126
|
+
|
|
1127
|
+
Args:
|
|
1128
|
+
conn: Database connection
|
|
1129
|
+
user_guid: Optional user GUID filter
|
|
1130
|
+
|
|
1131
|
+
Returns:
|
|
1132
|
+
List of daemon dictionaries
|
|
1133
|
+
"""
|
|
1134
|
+
cursor = conn.cursor()
|
|
1135
|
+
|
|
1136
|
+
if user_guid:
|
|
1137
|
+
cursor.execute('''
|
|
1138
|
+
SELECT * FROM daemon_registry
|
|
1139
|
+
WHERE status = 'running' AND user_guid = ?
|
|
1140
|
+
''', (user_guid,))
|
|
1141
|
+
else:
|
|
1142
|
+
cursor.execute('''
|
|
1143
|
+
SELECT * FROM daemon_registry
|
|
1144
|
+
WHERE status = 'running'
|
|
1145
|
+
''')
|
|
1146
|
+
|
|
1147
|
+
return [
|
|
1148
|
+
{
|
|
1149
|
+
'daemon_id': row['daemon_id'],
|
|
1150
|
+
'hostname': row['hostname'],
|
|
1151
|
+
'pid': row['pid'],
|
|
1152
|
+
'started_at': row['started_at'],
|
|
1153
|
+
'last_heartbeat': row['last_heartbeat'],
|
|
1154
|
+
'user_guid': row['user_guid']
|
|
1155
|
+
}
|
|
1156
|
+
for row in cursor.fetchall()
|
|
1157
|
+
]
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def cleanup_stale_daemons(
|
|
1161
|
+
conn: sqlite3.Connection,
|
|
1162
|
+
heartbeat_timeout_seconds: int = 120
|
|
1163
|
+
) -> int:
|
|
1164
|
+
"""
|
|
1165
|
+
Mark daemons as stopped if their heartbeat is stale.
|
|
1166
|
+
|
|
1167
|
+
Args:
|
|
1168
|
+
conn: Database connection
|
|
1169
|
+
heartbeat_timeout_seconds: Seconds without heartbeat to consider stale
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
Number of stale daemons cleaned up
|
|
1173
|
+
"""
|
|
1174
|
+
cursor = conn.cursor()
|
|
1175
|
+
from datetime import timedelta
|
|
1176
|
+
cutoff_time = (datetime.now() - timedelta(seconds=heartbeat_timeout_seconds)).isoformat()
|
|
1177
|
+
|
|
1178
|
+
cursor.execute('''
|
|
1179
|
+
UPDATE daemon_registry
|
|
1180
|
+
SET status = 'stale'
|
|
1181
|
+
WHERE status = 'running'
|
|
1182
|
+
AND last_heartbeat < ?
|
|
1183
|
+
''', (cutoff_time,))
|
|
1184
|
+
|
|
1185
|
+
conn.commit()
|
|
1186
|
+
cleaned = cursor.rowcount
|
|
1187
|
+
|
|
1188
|
+
if cleaned > 0:
|
|
1189
|
+
logging.info(f"Marked {cleaned} stale daemon(s) as stopped")
|
|
1190
|
+
|
|
1191
|
+
return cleaned
|