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.
Files changed (96) hide show
  1. dtSpark/__init__.py +0 -0
  2. dtSpark/_description.txt +1 -0
  3. dtSpark/_full_name.txt +1 -0
  4. dtSpark/_licence.txt +21 -0
  5. dtSpark/_metadata.yaml +6 -0
  6. dtSpark/_name.txt +1 -0
  7. dtSpark/_version.txt +1 -0
  8. dtSpark/aws/__init__.py +7 -0
  9. dtSpark/aws/authentication.py +296 -0
  10. dtSpark/aws/bedrock.py +578 -0
  11. dtSpark/aws/costs.py +318 -0
  12. dtSpark/aws/pricing.py +580 -0
  13. dtSpark/cli_interface.py +2645 -0
  14. dtSpark/conversation_manager.py +3050 -0
  15. dtSpark/core/__init__.py +12 -0
  16. dtSpark/core/application.py +3355 -0
  17. dtSpark/core/context_compaction.py +735 -0
  18. dtSpark/daemon/__init__.py +104 -0
  19. dtSpark/daemon/__main__.py +10 -0
  20. dtSpark/daemon/action_monitor.py +213 -0
  21. dtSpark/daemon/daemon_app.py +730 -0
  22. dtSpark/daemon/daemon_manager.py +289 -0
  23. dtSpark/daemon/execution_coordinator.py +194 -0
  24. dtSpark/daemon/pid_file.py +169 -0
  25. dtSpark/database/__init__.py +482 -0
  26. dtSpark/database/autonomous_actions.py +1191 -0
  27. dtSpark/database/backends.py +329 -0
  28. dtSpark/database/connection.py +122 -0
  29. dtSpark/database/conversations.py +520 -0
  30. dtSpark/database/credential_prompt.py +218 -0
  31. dtSpark/database/files.py +205 -0
  32. dtSpark/database/mcp_ops.py +355 -0
  33. dtSpark/database/messages.py +161 -0
  34. dtSpark/database/schema.py +673 -0
  35. dtSpark/database/tool_permissions.py +186 -0
  36. dtSpark/database/usage.py +167 -0
  37. dtSpark/files/__init__.py +4 -0
  38. dtSpark/files/manager.py +322 -0
  39. dtSpark/launch.py +39 -0
  40. dtSpark/limits/__init__.py +10 -0
  41. dtSpark/limits/costs.py +296 -0
  42. dtSpark/limits/tokens.py +342 -0
  43. dtSpark/llm/__init__.py +17 -0
  44. dtSpark/llm/anthropic_direct.py +446 -0
  45. dtSpark/llm/base.py +146 -0
  46. dtSpark/llm/context_limits.py +438 -0
  47. dtSpark/llm/manager.py +177 -0
  48. dtSpark/llm/ollama.py +578 -0
  49. dtSpark/mcp_integration/__init__.py +5 -0
  50. dtSpark/mcp_integration/manager.py +653 -0
  51. dtSpark/mcp_integration/tool_selector.py +225 -0
  52. dtSpark/resources/config.yaml.template +631 -0
  53. dtSpark/safety/__init__.py +22 -0
  54. dtSpark/safety/llm_service.py +111 -0
  55. dtSpark/safety/patterns.py +229 -0
  56. dtSpark/safety/prompt_inspector.py +442 -0
  57. dtSpark/safety/violation_logger.py +346 -0
  58. dtSpark/scheduler/__init__.py +20 -0
  59. dtSpark/scheduler/creation_tools.py +599 -0
  60. dtSpark/scheduler/execution_queue.py +159 -0
  61. dtSpark/scheduler/executor.py +1152 -0
  62. dtSpark/scheduler/manager.py +395 -0
  63. dtSpark/tools/__init__.py +4 -0
  64. dtSpark/tools/builtin.py +833 -0
  65. dtSpark/web/__init__.py +20 -0
  66. dtSpark/web/auth.py +152 -0
  67. dtSpark/web/dependencies.py +37 -0
  68. dtSpark/web/endpoints/__init__.py +17 -0
  69. dtSpark/web/endpoints/autonomous_actions.py +1125 -0
  70. dtSpark/web/endpoints/chat.py +621 -0
  71. dtSpark/web/endpoints/conversations.py +353 -0
  72. dtSpark/web/endpoints/main_menu.py +547 -0
  73. dtSpark/web/endpoints/streaming.py +421 -0
  74. dtSpark/web/server.py +578 -0
  75. dtSpark/web/session.py +167 -0
  76. dtSpark/web/ssl_utils.py +195 -0
  77. dtSpark/web/static/css/dark-theme.css +427 -0
  78. dtSpark/web/static/js/actions.js +1101 -0
  79. dtSpark/web/static/js/chat.js +614 -0
  80. dtSpark/web/static/js/main.js +496 -0
  81. dtSpark/web/static/js/sse-client.js +242 -0
  82. dtSpark/web/templates/actions.html +408 -0
  83. dtSpark/web/templates/base.html +93 -0
  84. dtSpark/web/templates/chat.html +814 -0
  85. dtSpark/web/templates/conversations.html +350 -0
  86. dtSpark/web/templates/goodbye.html +81 -0
  87. dtSpark/web/templates/login.html +90 -0
  88. dtSpark/web/templates/main_menu.html +983 -0
  89. dtSpark/web/templates/new_conversation.html +191 -0
  90. dtSpark/web/web_interface.py +137 -0
  91. dtspark-1.0.4.dist-info/METADATA +187 -0
  92. dtspark-1.0.4.dist-info/RECORD +96 -0
  93. dtspark-1.0.4.dist-info/WHEEL +5 -0
  94. dtspark-1.0.4.dist-info/entry_points.txt +3 -0
  95. dtspark-1.0.4.dist-info/licenses/LICENSE +21 -0
  96. 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