machinaos 0.0.10 → 0.0.13
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.
- package/.env.template +16 -0
- package/client/package.json +1 -1
- package/client/src/Dashboard.tsx +3 -3
- package/client/src/components/AIAgentNode.tsx +24 -12
- package/client/src/components/OutputPanel.tsx +3 -2
- package/client/src/components/parameterPanel/InputSection.tsx +16 -3
- package/client/src/nodeDefinitions/aiAgentNodes.ts +12 -0
- package/client/src/nodeDefinitions/specializedAgentNodes.ts +68 -320
- package/client/src/nodeDefinitions/toolNodes.ts +87 -1
- package/client/src/nodeDefinitions/workflowNodes.ts +55 -1
- package/package.json +12 -3
- package/scripts/daemon.js +427 -0
- package/scripts/start.js +7 -1
- package/scripts/sync-version.js +108 -0
- package/server/Dockerfile +6 -7
- package/server/constants.py +2 -0
- package/server/core/cleanup.py +123 -0
- package/server/core/config.py +16 -0
- package/server/core/database.py +92 -1
- package/server/core/health.py +121 -0
- package/server/examples/__init__.py +1 -0
- package/server/gunicorn.conf.py +46 -0
- package/server/main.py +38 -3
- package/server/models/database.py +1 -0
- package/server/models/nodes.py +18 -2
- package/server/requirements-docker.txt +86 -0
- package/server/routers/database.py +16 -0
- package/server/routers/websocket.py +6 -5
- package/server/services/ai.py +115 -14
- package/server/services/auth.py +6 -1
- package/server/services/deployment/manager.py +14 -0
- package/server/services/event_waiter.py +55 -0
- package/server/services/example_loader.py +60 -0
- package/server/services/execution/executor.py +2 -0
- package/server/services/execution/models.py +8 -0
- package/server/services/handlers/__init__.py +2 -0
- package/server/services/handlers/ai.py +164 -11
- package/server/services/handlers/document.py +13 -4
- package/server/services/handlers/tools.py +445 -14
- package/server/services/node_executor.py +3 -0
- package/server/services/temporal/activities.py +3 -0
- package/server/services/workflow.py +2 -0
- package/server/skills/android_agent/app-launcher-skill/SKILL.md +137 -0
- package/server/skills/android_agent/app-list-skill/SKILL.md +148 -0
- package/server/skills/android_agent/audio-skill/SKILL.md +169 -0
- package/server/skills/android_agent/battery-skill/SKILL.md +114 -0
- package/server/skills/android_agent/bluetooth-skill/SKILL.md +151 -0
- package/server/skills/android_agent/camera-skill/SKILL.md +148 -0
- package/server/skills/android_agent/environmental-skill/SKILL.md +140 -0
- package/server/skills/android_agent/location-skill/SKILL.md +163 -0
- package/server/skills/android_agent/motion-skill/SKILL.md +141 -0
- package/server/skills/android_agent/screen-control-skill/SKILL.md +164 -0
- package/server/skills/android_agent/wifi-skill/SKILL.md +182 -0
- package/server/skills/assistant/subagent-skill/SKILL.md +205 -0
- package/server/skills/coding_agent/javascript-skill/SKILL.md +196 -0
- package/server/skills/coding_agent/python-skill/SKILL.md +165 -0
- package/server/skills/social_agent/whatsapp-db-skill/SKILL.md +284 -0
- package/server/skills/social_agent/whatsapp-send-skill/SKILL.md +180 -0
- package/server/skills/task_agent/cron-scheduler-skill/SKILL.md +215 -0
- package/server/skills/task_agent/task-manager-skill/SKILL.md +251 -0
- package/server/skills/task_agent/timer-skill/SKILL.md +168 -0
- package/server/skills/travel_agent/geocoding-skill/SKILL.md +186 -0
- package/server/skills/travel_agent/nearby-places-skill/SKILL.md +234 -0
- package/server/skills/web_agent/http-request-skill/SKILL.md +211 -0
- package/server/skills/android/skill/SKILL.md +0 -84
- package/server/skills/assistant/code-skill/SKILL.md +0 -176
- package/server/skills/assistant/http-skill/SKILL.md +0 -163
- package/server/skills/assistant/maps-skill/SKILL.md +0 -172
- package/server/skills/assistant/scheduler-skill/SKILL.md +0 -86
- package/server/skills/assistant/whatsapp-skill/SKILL.md +0 -285
- /package/server/skills/{android → android_agent}/personality/SKILL.md +0 -0
- /package/server/skills/{assistant → web_agent}/web-search-skill/SKILL.md +0 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Periodic cleanup service for long-running daemon.
|
|
2
|
+
|
|
3
|
+
Follows the RecoverySweeper pattern from execution/recovery.py.
|
|
4
|
+
All configuration from Settings (environment variables).
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
import gc
|
|
8
|
+
from typing import Optional, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from core.logging import get_logger
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from core.config import Settings
|
|
14
|
+
from core.database import Database
|
|
15
|
+
from core.cache import CacheService
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CleanupService:
|
|
21
|
+
"""Background cleanup to prevent resource exhaustion.
|
|
22
|
+
|
|
23
|
+
Periodically cleans up:
|
|
24
|
+
- Expired cache entries
|
|
25
|
+
- Old console logs (keeps configurable count)
|
|
26
|
+
- Old cache entries by age
|
|
27
|
+
- Forces garbage collection
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
database: "Database",
|
|
33
|
+
cache: "CacheService",
|
|
34
|
+
settings: "Settings"
|
|
35
|
+
):
|
|
36
|
+
self.database = database
|
|
37
|
+
self.cache = cache
|
|
38
|
+
self.settings = settings
|
|
39
|
+
self._running = False
|
|
40
|
+
self._task: Optional[asyncio.Task] = None
|
|
41
|
+
|
|
42
|
+
async def start(self) -> None:
|
|
43
|
+
"""Start the cleanup service background task."""
|
|
44
|
+
if self._running:
|
|
45
|
+
return
|
|
46
|
+
self._running = True
|
|
47
|
+
self._task = asyncio.create_task(self._cleanup_loop())
|
|
48
|
+
logger.info(
|
|
49
|
+
"Cleanup service started",
|
|
50
|
+
interval=self.settings.cleanup_interval,
|
|
51
|
+
logs_max=self.settings.cleanup_logs_max_count,
|
|
52
|
+
cache_max_age_hours=self.settings.cleanup_cache_max_age_hours
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def stop(self) -> None:
|
|
56
|
+
"""Stop the cleanup service gracefully."""
|
|
57
|
+
self._running = False
|
|
58
|
+
if self._task:
|
|
59
|
+
self._task.cancel()
|
|
60
|
+
try:
|
|
61
|
+
await self._task
|
|
62
|
+
except asyncio.CancelledError:
|
|
63
|
+
pass
|
|
64
|
+
logger.info("Cleanup service stopped")
|
|
65
|
+
|
|
66
|
+
async def _cleanup_loop(self) -> None:
|
|
67
|
+
"""Main cleanup loop - runs at configured interval."""
|
|
68
|
+
while self._running:
|
|
69
|
+
try:
|
|
70
|
+
await self._run_cleanup()
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.error("Cleanup failed", error=str(e))
|
|
73
|
+
await asyncio.sleep(self.settings.cleanup_interval)
|
|
74
|
+
|
|
75
|
+
async def _run_cleanup(self) -> None:
|
|
76
|
+
"""Execute all cleanup tasks."""
|
|
77
|
+
results = {}
|
|
78
|
+
|
|
79
|
+
# 1. Expired cache entries (TTL-based)
|
|
80
|
+
try:
|
|
81
|
+
results['expired_cache'] = await self.database.cleanup_expired_cache()
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.warning("Failed to cleanup expired cache", error=str(e))
|
|
84
|
+
results['expired_cache'] = 0
|
|
85
|
+
|
|
86
|
+
# 2. Old console logs (keep last N)
|
|
87
|
+
try:
|
|
88
|
+
results['old_logs'] = await self.database.cleanup_old_console_logs(
|
|
89
|
+
keep=self.settings.cleanup_logs_max_count
|
|
90
|
+
)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.warning("Failed to cleanup old console logs", error=str(e))
|
|
93
|
+
results['old_logs'] = 0
|
|
94
|
+
|
|
95
|
+
# 3. Old cache entries by age
|
|
96
|
+
try:
|
|
97
|
+
results['old_cache'] = await self.database.cleanup_old_cache(
|
|
98
|
+
max_age_hours=self.settings.cleanup_cache_max_age_hours
|
|
99
|
+
)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.warning("Failed to cleanup old cache", error=str(e))
|
|
102
|
+
results['old_cache'] = 0
|
|
103
|
+
|
|
104
|
+
# 4. Force garbage collection
|
|
105
|
+
gc.collect()
|
|
106
|
+
|
|
107
|
+
# Only log if something was cleaned up
|
|
108
|
+
total_cleaned = sum(results.values())
|
|
109
|
+
if total_cleaned > 0:
|
|
110
|
+
logger.info("Cleanup completed", **results)
|
|
111
|
+
|
|
112
|
+
async def run_once(self) -> dict:
|
|
113
|
+
"""Run cleanup once and return results. Useful for testing."""
|
|
114
|
+
results = {}
|
|
115
|
+
results['expired_cache'] = await self.database.cleanup_expired_cache()
|
|
116
|
+
results['old_logs'] = await self.database.cleanup_old_console_logs(
|
|
117
|
+
keep=self.settings.cleanup_logs_max_count
|
|
118
|
+
)
|
|
119
|
+
results['old_cache'] = await self.database.cleanup_old_cache(
|
|
120
|
+
max_age_hours=self.settings.cleanup_cache_max_age_hours
|
|
121
|
+
)
|
|
122
|
+
gc.collect()
|
|
123
|
+
return results
|
package/server/core/config.py
CHANGED
|
@@ -100,6 +100,22 @@ class Settings(BaseSettings):
|
|
|
100
100
|
# Health Check
|
|
101
101
|
health_check_interval: int = Field(default=30, env="HEALTH_CHECK_INTERVAL", ge=10)
|
|
102
102
|
|
|
103
|
+
# Cleanup Configuration (for long-running daemon)
|
|
104
|
+
cleanup_enabled: bool = Field(default=True, env="CLEANUP_ENABLED")
|
|
105
|
+
cleanup_interval: int = Field(default=3600, env="CLEANUP_INTERVAL", ge=60)
|
|
106
|
+
cleanup_logs_max_count: int = Field(default=1000, env="CLEANUP_LOGS_MAX_COUNT", ge=100)
|
|
107
|
+
cleanup_cache_max_age_hours: int = Field(default=24, env="CLEANUP_CACHE_MAX_AGE_HOURS", ge=1)
|
|
108
|
+
|
|
109
|
+
# Feature Toggles
|
|
110
|
+
ws_logging_enabled: bool = Field(default=True, env="WS_LOGGING_ENABLED")
|
|
111
|
+
|
|
112
|
+
# Gunicorn Configuration (for production deployment)
|
|
113
|
+
gunicorn_timeout: int = Field(default=120, env="GUNICORN_TIMEOUT", ge=30)
|
|
114
|
+
gunicorn_graceful_timeout: int = Field(default=30, env="GUNICORN_GRACEFUL_TIMEOUT", ge=10)
|
|
115
|
+
gunicorn_keepalive: int = Field(default=5, env="GUNICORN_KEEPALIVE", ge=1)
|
|
116
|
+
gunicorn_max_requests: int = Field(default=10000, env="GUNICORN_MAX_REQUESTS", ge=0)
|
|
117
|
+
gunicorn_max_requests_jitter: int = Field(default=1000, env="GUNICORN_MAX_REQUESTS_JITTER", ge=0)
|
|
118
|
+
|
|
103
119
|
@field_validator("database_url")
|
|
104
120
|
@classmethod
|
|
105
121
|
def validate_database_url(cls, v):
|
package/server/core/database.py
CHANGED
|
@@ -5,7 +5,7 @@ from typing import Dict, Any, List, Optional
|
|
|
5
5
|
from sqlmodel import SQLModel, select, Session
|
|
6
6
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
|
7
7
|
from sqlalchemy.exc import IntegrityError
|
|
8
|
-
from sqlalchemy import text
|
|
8
|
+
from sqlalchemy import text, func
|
|
9
9
|
from contextlib import asynccontextmanager
|
|
10
10
|
|
|
11
11
|
from core.config import Settings
|
|
@@ -77,6 +77,12 @@ class Database:
|
|
|
77
77
|
"ALTER TABLE user_settings ADD COLUMN console_panel_default_open BOOLEAN DEFAULT 0"
|
|
78
78
|
))
|
|
79
79
|
logger.info("Added console_panel_default_open column to user_settings")
|
|
80
|
+
|
|
81
|
+
if "examples_loaded" not in columns:
|
|
82
|
+
await conn.execute(text(
|
|
83
|
+
"ALTER TABLE user_settings ADD COLUMN examples_loaded BOOLEAN DEFAULT 0"
|
|
84
|
+
))
|
|
85
|
+
logger.info("Added examples_loaded column to user_settings")
|
|
80
86
|
except Exception as e:
|
|
81
87
|
logger.warning(f"Migration check failed (table may not exist yet): {e}")
|
|
82
88
|
|
|
@@ -489,6 +495,27 @@ class Database:
|
|
|
489
495
|
logger.error("Failed to get node output", node_id=node_id, error=str(e))
|
|
490
496
|
return None
|
|
491
497
|
|
|
498
|
+
async def get_node_output_by_session(self, session_id: str,
|
|
499
|
+
output_name: str = "output_0") -> Optional[Dict[str, Any]]:
|
|
500
|
+
"""Get node output by session_id only (for delegation result lookup).
|
|
501
|
+
|
|
502
|
+
Used when node_id is unknown but session_id encodes the lookup key.
|
|
503
|
+
"""
|
|
504
|
+
try:
|
|
505
|
+
async with self.get_session() as session:
|
|
506
|
+
stmt = select(NodeOutput).where(
|
|
507
|
+
NodeOutput.session_id == session_id,
|
|
508
|
+
NodeOutput.output_name == output_name
|
|
509
|
+
)
|
|
510
|
+
result = await session.execute(stmt)
|
|
511
|
+
output = result.scalar_one_or_none()
|
|
512
|
+
|
|
513
|
+
return {"data": output.data} if output else None
|
|
514
|
+
|
|
515
|
+
except Exception as e:
|
|
516
|
+
logger.error("Failed to get node output by session", session_id=session_id, error=str(e))
|
|
517
|
+
return None
|
|
518
|
+
|
|
492
519
|
async def delete_node_output(self, node_id: str) -> int:
|
|
493
520
|
"""Delete all outputs for a node (any session). Returns count deleted."""
|
|
494
521
|
try:
|
|
@@ -814,6 +841,47 @@ class Database:
|
|
|
814
841
|
logger.error("Failed to clear console logs", error=str(e))
|
|
815
842
|
return 0
|
|
816
843
|
|
|
844
|
+
async def cleanup_old_console_logs(self, keep: int = 1000) -> int:
|
|
845
|
+
"""Keep only the most recent N console logs. Returns count deleted."""
|
|
846
|
+
from models.database import ConsoleLog
|
|
847
|
+
|
|
848
|
+
try:
|
|
849
|
+
async with self.get_session() as session:
|
|
850
|
+
# Count total logs
|
|
851
|
+
count_stmt = select(func.count()).select_from(ConsoleLog)
|
|
852
|
+
total_result = await session.execute(count_stmt)
|
|
853
|
+
total = total_result.scalar() or 0
|
|
854
|
+
|
|
855
|
+
if total <= keep:
|
|
856
|
+
return 0
|
|
857
|
+
|
|
858
|
+
# Get IDs of logs to keep (most recent)
|
|
859
|
+
keep_stmt = (
|
|
860
|
+
select(ConsoleLog.id)
|
|
861
|
+
.order_by(ConsoleLog.created_at.desc())
|
|
862
|
+
.limit(keep)
|
|
863
|
+
)
|
|
864
|
+
keep_result = await session.execute(keep_stmt)
|
|
865
|
+
keep_ids = {row[0] for row in keep_result.fetchall()}
|
|
866
|
+
|
|
867
|
+
# Delete logs not in keep list
|
|
868
|
+
delete_stmt = select(ConsoleLog).where(ConsoleLog.id.notin_(keep_ids))
|
|
869
|
+
result = await session.execute(delete_stmt)
|
|
870
|
+
old_logs = result.scalars().all()
|
|
871
|
+
|
|
872
|
+
count = len(old_logs)
|
|
873
|
+
for log in old_logs:
|
|
874
|
+
await session.delete(log)
|
|
875
|
+
|
|
876
|
+
await session.commit()
|
|
877
|
+
if count > 0:
|
|
878
|
+
logger.info("Cleaned up old console logs", deleted=count, kept=keep)
|
|
879
|
+
return count
|
|
880
|
+
|
|
881
|
+
except Exception as e:
|
|
882
|
+
logger.error("Failed to cleanup old console logs", error=str(e))
|
|
883
|
+
return 0
|
|
884
|
+
|
|
817
885
|
# ============================================================================
|
|
818
886
|
# Cache Entries (SQLite-backed Redis alternative)
|
|
819
887
|
# ============================================================================
|
|
@@ -941,6 +1009,29 @@ class Database:
|
|
|
941
1009
|
logger.error("Failed to cleanup expired cache", error=str(e))
|
|
942
1010
|
return 0
|
|
943
1011
|
|
|
1012
|
+
async def cleanup_old_cache(self, max_age_hours: int = 24) -> int:
|
|
1013
|
+
"""Remove cache entries older than max_age_hours. Returns count deleted."""
|
|
1014
|
+
import time
|
|
1015
|
+
try:
|
|
1016
|
+
async with self.get_session() as session:
|
|
1017
|
+
cutoff_time = time.time() - (max_age_hours * 3600)
|
|
1018
|
+
stmt = select(CacheEntry).where(CacheEntry.created_at < cutoff_time)
|
|
1019
|
+
result = await session.execute(stmt)
|
|
1020
|
+
entries = result.scalars().all()
|
|
1021
|
+
|
|
1022
|
+
count = len(entries)
|
|
1023
|
+
for entry in entries:
|
|
1024
|
+
await session.delete(entry)
|
|
1025
|
+
|
|
1026
|
+
await session.commit()
|
|
1027
|
+
if count > 0:
|
|
1028
|
+
logger.info("Cleaned up old cache entries", count=count, max_age_hours=max_age_hours)
|
|
1029
|
+
return count
|
|
1030
|
+
|
|
1031
|
+
except Exception as e:
|
|
1032
|
+
logger.error("Failed to cleanup old cache", error=str(e))
|
|
1033
|
+
return 0
|
|
1034
|
+
|
|
944
1035
|
async def cache_exists(self, key: str) -> bool:
|
|
945
1036
|
"""Check if cache key exists and is not expired."""
|
|
946
1037
|
import time
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Health check utilities for daemon monitoring.
|
|
2
|
+
|
|
3
|
+
Provides uptime tracking and comprehensive health status for /health endpoint.
|
|
4
|
+
"""
|
|
5
|
+
import time
|
|
6
|
+
from typing import Dict, Any, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import psutil
|
|
10
|
+
PSUTIL_AVAILABLE = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
PSUTIL_AVAILABLE = False
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from core.config import Settings
|
|
16
|
+
from core.database import Database
|
|
17
|
+
from core.cache import CacheService
|
|
18
|
+
|
|
19
|
+
# Module-level startup time tracking
|
|
20
|
+
_startup_time: float = 0.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_startup_time() -> None:
|
|
24
|
+
"""Record the application startup time. Call once during lifespan startup."""
|
|
25
|
+
global _startup_time
|
|
26
|
+
_startup_time = time.time()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_uptime() -> float:
|
|
30
|
+
"""Get uptime in seconds since startup."""
|
|
31
|
+
return time.time() - _startup_time if _startup_time else 0.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_memory_mb() -> float:
|
|
35
|
+
"""Get current process memory usage in MB."""
|
|
36
|
+
if not PSUTIL_AVAILABLE:
|
|
37
|
+
return 0.0
|
|
38
|
+
try:
|
|
39
|
+
return psutil.Process().memory_info().rss / (1024 * 1024)
|
|
40
|
+
except Exception:
|
|
41
|
+
return 0.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_disk_percent(path: str = ".") -> float:
|
|
45
|
+
"""Get disk usage percentage for given path."""
|
|
46
|
+
if not PSUTIL_AVAILABLE:
|
|
47
|
+
return 0.0
|
|
48
|
+
try:
|
|
49
|
+
return psutil.disk_usage(path).percent
|
|
50
|
+
except Exception:
|
|
51
|
+
return 0.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_cpu_percent() -> float:
|
|
55
|
+
"""Get current process CPU usage percentage."""
|
|
56
|
+
if not PSUTIL_AVAILABLE:
|
|
57
|
+
return 0.0
|
|
58
|
+
try:
|
|
59
|
+
return psutil.Process().cpu_percent(interval=0.1)
|
|
60
|
+
except Exception:
|
|
61
|
+
return 0.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def check_database(database: "Database") -> bool:
|
|
65
|
+
"""Check database connectivity."""
|
|
66
|
+
try:
|
|
67
|
+
async with database.get_session() as session:
|
|
68
|
+
from sqlalchemy import text
|
|
69
|
+
await session.execute(text("SELECT 1"))
|
|
70
|
+
return True
|
|
71
|
+
except Exception:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def check_cache(cache: "CacheService") -> bool:
|
|
76
|
+
"""Check cache connectivity."""
|
|
77
|
+
try:
|
|
78
|
+
test_key = "_health_check"
|
|
79
|
+
await cache.set(test_key, "ok", ttl=10)
|
|
80
|
+
result = await cache.get(test_key)
|
|
81
|
+
await cache.delete(test_key)
|
|
82
|
+
return result == "ok"
|
|
83
|
+
except Exception:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def get_health_status(
|
|
88
|
+
database: "Database",
|
|
89
|
+
cache: "CacheService",
|
|
90
|
+
settings: "Settings"
|
|
91
|
+
) -> Dict[str, Any]:
|
|
92
|
+
"""Get comprehensive health status for /health endpoint.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Dict containing status, uptime, resource usage, and feature flags.
|
|
96
|
+
"""
|
|
97
|
+
# Run health checks
|
|
98
|
+
db_healthy = await check_database(database)
|
|
99
|
+
cache_healthy = await check_cache(cache)
|
|
100
|
+
|
|
101
|
+
# Determine overall status
|
|
102
|
+
overall_status = "healthy" if (db_healthy and cache_healthy) else "degraded"
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"status": overall_status,
|
|
106
|
+
"uptime_seconds": round(get_uptime(), 1),
|
|
107
|
+
"memory_mb": round(get_memory_mb(), 1),
|
|
108
|
+
"disk_percent": round(get_disk_percent(), 1),
|
|
109
|
+
"cpu_percent": round(get_cpu_percent(), 1),
|
|
110
|
+
"checks": {
|
|
111
|
+
"database": db_healthy,
|
|
112
|
+
"cache": cache_healthy,
|
|
113
|
+
},
|
|
114
|
+
"features": {
|
|
115
|
+
"redis": settings.redis_enabled,
|
|
116
|
+
"temporal": settings.temporal_enabled,
|
|
117
|
+
"cleanup": settings.cleanup_enabled,
|
|
118
|
+
"ws_logging": settings.ws_logging_enabled,
|
|
119
|
+
},
|
|
120
|
+
"psutil_available": PSUTIL_AVAILABLE,
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Example workflows package
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Gunicorn configuration for production deployment.
|
|
2
|
+
|
|
3
|
+
Reads settings from environment variables (same as config.py).
|
|
4
|
+
No hardcoded values - everything from .env file.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
gunicorn main:app -c gunicorn.conf.py
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import multiprocessing
|
|
11
|
+
|
|
12
|
+
# Load from environment (same vars used by config.py)
|
|
13
|
+
host = os.getenv("HOST", "0.0.0.0")
|
|
14
|
+
port = os.getenv("PORT", "3010")
|
|
15
|
+
workers_env = os.getenv("WORKERS", "0") # 0 = auto-calculate
|
|
16
|
+
log_level = os.getenv("LOG_LEVEL", "INFO").lower()
|
|
17
|
+
debug = os.getenv("DEBUG", "false").lower() == "true"
|
|
18
|
+
|
|
19
|
+
# Bind - use HOST and PORT from .env
|
|
20
|
+
bind = f"{host}:{port}"
|
|
21
|
+
|
|
22
|
+
# Workers - from WORKERS env or auto-calculate
|
|
23
|
+
# WORKERS=0 means auto (2 * cpu + 1), WORKERS=N means use N
|
|
24
|
+
workers_count = int(workers_env)
|
|
25
|
+
workers = workers_count if workers_count > 0 else (multiprocessing.cpu_count() * 2 + 1)
|
|
26
|
+
worker_class = "uvicorn.workers.UvicornWorker"
|
|
27
|
+
|
|
28
|
+
# Timeouts - configurable via env
|
|
29
|
+
timeout = int(os.getenv("GUNICORN_TIMEOUT", "120"))
|
|
30
|
+
graceful_timeout = int(os.getenv("GUNICORN_GRACEFUL_TIMEOUT", "30"))
|
|
31
|
+
keepalive = int(os.getenv("GUNICORN_KEEPALIVE", "5"))
|
|
32
|
+
|
|
33
|
+
# Restart workers periodically to prevent memory leaks
|
|
34
|
+
max_requests = int(os.getenv("GUNICORN_MAX_REQUESTS", "10000"))
|
|
35
|
+
max_requests_jitter = int(os.getenv("GUNICORN_MAX_REQUESTS_JITTER", "1000"))
|
|
36
|
+
|
|
37
|
+
# Logging - use LOG_LEVEL from .env
|
|
38
|
+
accesslog = "-" if not debug else None
|
|
39
|
+
errorlog = "-"
|
|
40
|
+
loglevel = log_level
|
|
41
|
+
|
|
42
|
+
# Process naming
|
|
43
|
+
proc_name = "machina-backend"
|
|
44
|
+
|
|
45
|
+
# Preload app for faster worker startup (disable in debug for reload)
|
|
46
|
+
preload_app = not debug
|
package/server/main.py
CHANGED
|
@@ -104,6 +104,21 @@ async def lifespan(app: FastAPI):
|
|
|
104
104
|
setup_websocket_logging(loop)
|
|
105
105
|
logger.info("WebSocket logging handler started")
|
|
106
106
|
|
|
107
|
+
# Start cleanup service for long-running daemon
|
|
108
|
+
from core.cleanup import CleanupService
|
|
109
|
+
from core.health import set_startup_time
|
|
110
|
+
cleanup_service = None
|
|
111
|
+
if settings.cleanup_enabled:
|
|
112
|
+
cleanup_service = CleanupService(
|
|
113
|
+
database=container.database(),
|
|
114
|
+
cache=container.cache(),
|
|
115
|
+
settings=settings
|
|
116
|
+
)
|
|
117
|
+
await cleanup_service.start()
|
|
118
|
+
|
|
119
|
+
# Record startup time for health reporting
|
|
120
|
+
set_startup_time()
|
|
121
|
+
|
|
107
122
|
# Initialize Temporal if enabled
|
|
108
123
|
temporal_worker_manager = None
|
|
109
124
|
if settings.temporal_enabled:
|
|
@@ -165,6 +180,10 @@ async def lifespan(app: FastAPI):
|
|
|
165
180
|
from services.android.manager import close_relay_client
|
|
166
181
|
await close_relay_client(clear_stored_session=False)
|
|
167
182
|
|
|
183
|
+
# Stop cleanup service
|
|
184
|
+
if cleanup_service is not None:
|
|
185
|
+
await cleanup_service.stop()
|
|
186
|
+
|
|
168
187
|
# Stop recovery sweeper first
|
|
169
188
|
if settings.redis_enabled:
|
|
170
189
|
await recovery_sweeper.stop()
|
|
@@ -239,12 +258,20 @@ app.include_router(webhook.router)
|
|
|
239
258
|
|
|
240
259
|
@app.get("/health")
|
|
241
260
|
async def health_check():
|
|
242
|
-
"""Detailed health check."""
|
|
261
|
+
"""Detailed health check with resource monitoring."""
|
|
243
262
|
from services import event_waiter
|
|
244
263
|
from services.execution import get_recovery_sweeper
|
|
264
|
+
from core.health import get_health_status, get_uptime, get_memory_mb, get_disk_percent, get_cpu_percent
|
|
245
265
|
|
|
246
266
|
sweeper = get_recovery_sweeper()
|
|
247
267
|
|
|
268
|
+
# Get comprehensive health status
|
|
269
|
+
health = await get_health_status(
|
|
270
|
+
database=container.database(),
|
|
271
|
+
cache=container.cache(),
|
|
272
|
+
settings=settings
|
|
273
|
+
)
|
|
274
|
+
|
|
248
275
|
# Check Temporal status
|
|
249
276
|
temporal_status = {
|
|
250
277
|
"enabled": settings.temporal_enabled,
|
|
@@ -259,10 +286,18 @@ async def health_check():
|
|
|
259
286
|
pass
|
|
260
287
|
|
|
261
288
|
return {
|
|
262
|
-
"status": "
|
|
289
|
+
"status": health["status"],
|
|
263
290
|
"service": "python",
|
|
264
|
-
"version": "3.
|
|
291
|
+
"version": "3.3.0", # Bumped for daemon service support
|
|
265
292
|
"environment": "development" if settings.debug else "production",
|
|
293
|
+
"uptime_seconds": health["uptime_seconds"],
|
|
294
|
+
"resources": {
|
|
295
|
+
"memory_mb": health["memory_mb"],
|
|
296
|
+
"disk_percent": health["disk_percent"],
|
|
297
|
+
"cpu_percent": health["cpu_percent"],
|
|
298
|
+
},
|
|
299
|
+
"checks": health["checks"],
|
|
300
|
+
"features": health["features"],
|
|
266
301
|
"redis_enabled": settings.redis_enabled,
|
|
267
302
|
"event_waiter_mode": event_waiter.get_backend_mode(),
|
|
268
303
|
"execution_engine": {
|
|
@@ -252,6 +252,7 @@ class UserSettings(SQLModel, table=True):
|
|
|
252
252
|
sidebar_default_open: bool = Field(default=True)
|
|
253
253
|
component_palette_default_open: bool = Field(default=True)
|
|
254
254
|
console_panel_default_open: bool = Field(default=False)
|
|
255
|
+
examples_loaded: bool = Field(default=False) # Track if example workflows were imported
|
|
255
256
|
created_at: datetime = Field(
|
|
256
257
|
default_factory=lambda: datetime.now(timezone.utc),
|
|
257
258
|
sa_column=Column(DateTime(timezone=True), server_default=func.now())
|
package/server/models/nodes.py
CHANGED
|
@@ -147,6 +147,22 @@ class WhatsAppReceiveParams(BaseNodeParams):
|
|
|
147
147
|
include_media: bool = Field(default=False, alias="includeMediaData")
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
class WhatsAppDbParams(BaseNodeParams):
|
|
151
|
+
"""Parameters for WhatsApp database query node."""
|
|
152
|
+
type: Literal["whatsappDb"]
|
|
153
|
+
operation: Literal["chat_history", "search_groups", "get_group_info", "get_contact_info", "list_contacts", "check_contacts"] = "chat_history"
|
|
154
|
+
# chat_history params
|
|
155
|
+
phone_number: str = Field(default="", alias="phoneNumber")
|
|
156
|
+
group_id: str = Field(default="", alias="groupId")
|
|
157
|
+
group_name: str = Field(default="", alias="groupName")
|
|
158
|
+
limit: int = Field(default=50, ge=1, le=500)
|
|
159
|
+
offset: int = Field(default=0, ge=0)
|
|
160
|
+
# search_groups params
|
|
161
|
+
search_query: str = Field(default="", alias="searchQuery")
|
|
162
|
+
# check_contacts params
|
|
163
|
+
phone_numbers: str = Field(default="", alias="phoneNumbers")
|
|
164
|
+
|
|
165
|
+
|
|
150
166
|
# =============================================================================
|
|
151
167
|
# CODE EXECUTOR NODE MODELS
|
|
152
168
|
# =============================================================================
|
|
@@ -277,7 +293,7 @@ MapsNodeParams = Annotated[
|
|
|
277
293
|
|
|
278
294
|
# WhatsApp Nodes
|
|
279
295
|
WhatsAppNodeParams = Annotated[
|
|
280
|
-
Union[WhatsAppSendParams, WhatsAppReceiveParams],
|
|
296
|
+
Union[WhatsAppSendParams, WhatsAppReceiveParams, WhatsAppDbParams],
|
|
281
297
|
Field(discriminator="type")
|
|
282
298
|
]
|
|
283
299
|
|
|
@@ -319,7 +335,7 @@ KnownNodeParams = Annotated[
|
|
|
319
335
|
# Maps
|
|
320
336
|
CreateMapParams, GmapsLocationsParams, GmapsNearbyPlacesParams,
|
|
321
337
|
# WhatsApp
|
|
322
|
-
WhatsAppSendParams, WhatsAppReceiveParams,
|
|
338
|
+
WhatsAppSendParams, WhatsAppReceiveParams, WhatsAppDbParams,
|
|
323
339
|
# Code
|
|
324
340
|
PythonExecutorParams, JavaScriptExecutorParams,
|
|
325
341
|
# HTTP
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Docker-specific requirements (CPU-only, no CUDA)
|
|
2
|
+
# This file is used by Dockerfile to avoid large CUDA dependencies
|
|
3
|
+
|
|
4
|
+
# Core Framework
|
|
5
|
+
fastapi>=0.110.0
|
|
6
|
+
uvicorn[standard]>=0.27.0
|
|
7
|
+
|
|
8
|
+
# Performance optimization
|
|
9
|
+
uvloop>=0.19.0;sys_platform!='win32'
|
|
10
|
+
orjson>=3.10.0
|
|
11
|
+
|
|
12
|
+
# Database & ORM (Python 3.13 compatible versions)
|
|
13
|
+
sqlmodel>=0.0.18
|
|
14
|
+
sqlalchemy[asyncio]>=2.0.35
|
|
15
|
+
aiosqlite>=0.20.0
|
|
16
|
+
|
|
17
|
+
# Settings & Configuration
|
|
18
|
+
pydantic>=2.6.0
|
|
19
|
+
pydantic-settings>=2.2.0
|
|
20
|
+
python-dotenv>=1.0.0
|
|
21
|
+
|
|
22
|
+
# Dependency Injection
|
|
23
|
+
dependency-injector>=4.41.0
|
|
24
|
+
|
|
25
|
+
# Caching (Redis) - Python 3.13 compatible
|
|
26
|
+
redis>=5.0.0
|
|
27
|
+
|
|
28
|
+
# HTTP Client
|
|
29
|
+
httpx>=0.27.0
|
|
30
|
+
|
|
31
|
+
# WebSocket Client (for WhatsApp RPC)
|
|
32
|
+
websockets>=12.0
|
|
33
|
+
|
|
34
|
+
# Async HTTP Client (for Android relay WebSocket)
|
|
35
|
+
aiohttp>=3.9.0
|
|
36
|
+
|
|
37
|
+
# QR Code Generation (for WhatsApp QR display)
|
|
38
|
+
qrcode[pil]>=7.4.0
|
|
39
|
+
|
|
40
|
+
# Logging
|
|
41
|
+
structlog>=23.2.0
|
|
42
|
+
|
|
43
|
+
# AI Providers (LangChain) - compatible versions
|
|
44
|
+
langchain-core>=1.2.6
|
|
45
|
+
langchain-openai>=1.0.0
|
|
46
|
+
langchain-anthropic>=1.3.0
|
|
47
|
+
langchain-google-genai>=3.1.0
|
|
48
|
+
langchain-groq>=0.1.0
|
|
49
|
+
langchain-cerebras>=0.1.0
|
|
50
|
+
|
|
51
|
+
# LangGraph - State machine for AI agents
|
|
52
|
+
langgraph>=1.0.6
|
|
53
|
+
|
|
54
|
+
# Temporal - Durable workflow execution (optional)
|
|
55
|
+
temporalio>=1.21.1
|
|
56
|
+
|
|
57
|
+
# Google Services
|
|
58
|
+
googlemaps>=4.10.0
|
|
59
|
+
|
|
60
|
+
# Scheduling
|
|
61
|
+
APScheduler>=3.10.0
|
|
62
|
+
|
|
63
|
+
# Web Search
|
|
64
|
+
ddgs>=9.0.0
|
|
65
|
+
|
|
66
|
+
# Authentication
|
|
67
|
+
bcrypt>=4.1.0
|
|
68
|
+
python-jose[cryptography]>=3.3.0
|
|
69
|
+
email-validator>=2.0.0
|
|
70
|
+
|
|
71
|
+
# Timezone support
|
|
72
|
+
pytz>=2024.1
|
|
73
|
+
|
|
74
|
+
# Document Processing (CPU-only)
|
|
75
|
+
beautifulsoup4>=4.12.0
|
|
76
|
+
langchain-text-splitters>=0.3.0
|
|
77
|
+
pypdf>=4.0.0
|
|
78
|
+
|
|
79
|
+
# Note: The following are excluded for Docker builds (CUDA/GPU heavy):
|
|
80
|
+
# - sentence-transformers (pulls PyTorch with CUDA)
|
|
81
|
+
# - langchain-huggingface (pulls sentence-transformers)
|
|
82
|
+
# - chromadb (pulls sentence-transformers for embeddings)
|
|
83
|
+
# - qdrant-client (optional, can be re-added if needed without embeddings)
|
|
84
|
+
#
|
|
85
|
+
# For production with embeddings, use external embedding APIs (OpenAI, etc.)
|
|
86
|
+
# or build a separate GPU-enabled image.
|
|
@@ -7,6 +7,7 @@ from typing import Dict, Any, Optional, List
|
|
|
7
7
|
from core.container import container
|
|
8
8
|
from core.database import Database
|
|
9
9
|
from core.logging import get_logger
|
|
10
|
+
from services.example_loader import import_examples_for_user
|
|
10
11
|
|
|
11
12
|
logger = get_logger(__name__)
|
|
12
13
|
router = APIRouter(prefix="/api/database", tags=["database"])
|
|
@@ -93,6 +94,21 @@ async def get_all_workflows(
|
|
|
93
94
|
):
|
|
94
95
|
"""Get all workflows."""
|
|
95
96
|
try:
|
|
97
|
+
# Auto-load example workflows on first fetch
|
|
98
|
+
user_id = "default"
|
|
99
|
+
settings = await database.get_user_settings(user_id)
|
|
100
|
+
|
|
101
|
+
if not settings or not settings.get("examples_loaded", False):
|
|
102
|
+
# First time - import examples
|
|
103
|
+
count = await import_examples_for_user(database)
|
|
104
|
+
if count > 0:
|
|
105
|
+
logger.info(f"Auto-loaded {count} example workflows")
|
|
106
|
+
|
|
107
|
+
# Mark as loaded using existing save_user_settings
|
|
108
|
+
current = settings or {}
|
|
109
|
+
current["examples_loaded"] = True
|
|
110
|
+
await database.save_user_settings(current, user_id)
|
|
111
|
+
|
|
96
112
|
workflows = await database.get_all_workflows()
|
|
97
113
|
return {
|
|
98
114
|
"success": True,
|