webpilot-engine 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adapters/__init__.py +14 -0
- adapters/base.py +34 -0
- adapters/browser_adapter.py +300 -0
- adapters/dom_scripts.py +518 -0
- adapters/envelope_builder.py +108 -0
- adapters/live_session_manager.py +326 -0
- adapters/upload_adapter.py +77 -0
- builder.py +83 -0
- cli.py +341 -0
- config/__init__.py +23 -0
- config/settings.py +339 -0
- constants/__init__.py +165 -0
- constants/contracts.py +95 -0
- constants/selectors.py +27 -0
- constants/timeouts.py +25 -0
- core/__init__.py +45 -0
- core/exceptions.py +33 -0
- core/models.py +273 -0
- engine.py +199 -0
- exceptions.py +23 -0
- mcp_server.py +347 -0
- models.py +29 -0
- orchestration/__init__.py +19 -0
- orchestration/context.py +71 -0
- orchestration/flow_orchestrator.py +358 -0
- orchestration/session_coordinator.py +88 -0
- services/__init__.py +17 -0
- services/auth_navigation_service.py +129 -0
- services/field_interaction_service.py +174 -0
- services/form_filler_service.py +106 -0
- services/inspection_formatter_service.py +68 -0
- services/inspection_service.py +40 -0
- services/reactive_interaction_service.py +174 -0
- supervisor/__init__.py +13 -0
- supervisor/client.py +171 -0
- supervisor/contracts.py +62 -0
- supervisor/master_daemon.py +303 -0
- supervisor/shell.py +194 -0
- supervisor/worker_process.py +400 -0
- validators/__init__.py +9 -0
- validators/form_validator.py +28 -0
- validators/payload_validator.py +25 -0
- webpilot_engine-2.0.0.dist-info/METADATA +299 -0
- webpilot_engine-2.0.0.dist-info/RECORD +47 -0
- webpilot_engine-2.0.0.dist-info/WHEEL +5 -0
- webpilot_engine-2.0.0.dist-info/entry_points.txt +6 -0
- webpilot_engine-2.0.0.dist-info/top_level.txt +14 -0
supervisor/client.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Lightweight HTTP Client for communicating with the Master Supervisor (Layer 1).
|
|
2
|
+
|
|
3
|
+
Includes Zero-Touch Auto-Spawn capability: automatically boots the background
|
|
4
|
+
supervisor host detached via WMI if not already running.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
if PROJECT_ROOT not in sys.path:
|
|
21
|
+
sys.path.insert(0, PROJECT_ROOT)
|
|
22
|
+
|
|
23
|
+
from supervisor.contracts import SupervisorActionRequest, SupervisorActionResponse
|
|
24
|
+
|
|
25
|
+
DEFAULT_SUPERVISOR_URL: str = "http://127.0.0.1:9333"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SupervisorClient:
|
|
29
|
+
"""Thin client for interacting with the Master Supervisor daemon."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, base_url: str = DEFAULT_SUPERVISOR_URL):
|
|
32
|
+
self.base_url = base_url.rstrip("/")
|
|
33
|
+
|
|
34
|
+
def is_running(self) -> bool:
|
|
35
|
+
"""Pings the supervisor health endpoint."""
|
|
36
|
+
try:
|
|
37
|
+
req = urllib.request.Request(f"{self.base_url}/ping", headers={"User-Agent": "WebPilotClient/2.0"})
|
|
38
|
+
with urllib.request.urlopen(req, timeout=0.8) as resp:
|
|
39
|
+
return resp.status == 200
|
|
40
|
+
except Exception:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
def ensure_running(self) -> None:
|
|
44
|
+
"""Checks if supervisor is alive; if not, launches it detached via WMI."""
|
|
45
|
+
if self.is_running():
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
print("[*] Master Supervisor (Layer 1) not running. Auto-spawning background host...")
|
|
49
|
+
script_path = os.path.join(PROJECT_ROOT, "supervisor", "master_daemon.py")
|
|
50
|
+
py_exe = sys.executable
|
|
51
|
+
|
|
52
|
+
# Launch detached via WMI outside any parent terminal Job Object
|
|
53
|
+
if sys.platform == "win32":
|
|
54
|
+
if getattr(sys, "frozen", False):
|
|
55
|
+
py_exe = sys.executable
|
|
56
|
+
cwd = os.path.dirname(py_exe)
|
|
57
|
+
cmd_line = f'`"{py_exe}`" --run-daemon'
|
|
58
|
+
else:
|
|
59
|
+
script_path = os.path.join(PROJECT_ROOT, "supervisor", "master_daemon.py")
|
|
60
|
+
py_exe = sys.executable
|
|
61
|
+
cwd = PROJECT_ROOT
|
|
62
|
+
cmd_line = f'`"{py_exe}`" `"{script_path}`"'
|
|
63
|
+
|
|
64
|
+
ps_script = f"""
|
|
65
|
+
$cwd = '{cwd}'
|
|
66
|
+
$cmd = '{cmd_line}'
|
|
67
|
+
$proc = ([wmiclass]'Win32_Process').Create($cmd, $cwd, $null)
|
|
68
|
+
if ($proc.ReturnValue -eq 0) {{
|
|
69
|
+
Write-Output $proc.ProcessId
|
|
70
|
+
}} else {{
|
|
71
|
+
exit 1
|
|
72
|
+
}}
|
|
73
|
+
"""
|
|
74
|
+
encoded = base64.b64encode(ps_script.encode("utf-16le")).decode("ascii")
|
|
75
|
+
subprocess.run(
|
|
76
|
+
["powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
|
77
|
+
stdout=subprocess.DEVNULL,
|
|
78
|
+
stderr=subprocess.DEVNULL,
|
|
79
|
+
timeout=5,
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
subprocess.Popen(
|
|
83
|
+
[sys.executable, script_path],
|
|
84
|
+
stdout=subprocess.DEVNULL,
|
|
85
|
+
stderr=subprocess.DEVNULL,
|
|
86
|
+
start_new_session=True,
|
|
87
|
+
cwd=PROJECT_ROOT,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Wait up to 4 seconds for supervisor to become active
|
|
91
|
+
start = time.time()
|
|
92
|
+
while time.time() - start < 4.0:
|
|
93
|
+
if self.is_running():
|
|
94
|
+
print("[✓] Master Supervisor is ready and active.")
|
|
95
|
+
return
|
|
96
|
+
time.sleep(0.2)
|
|
97
|
+
|
|
98
|
+
raise RuntimeError("Failed to auto-spawn Master Supervisor on http://127.0.0.1:9333.")
|
|
99
|
+
|
|
100
|
+
def execute(self, action_req: SupervisorActionRequest) -> SupervisorActionResponse:
|
|
101
|
+
"""Sends an action request to the supervisor and returns structured response."""
|
|
102
|
+
self.ensure_running()
|
|
103
|
+
url = f"{self.base_url}/execute"
|
|
104
|
+
body = json.dumps(action_req.to_dict()).encode("utf-8")
|
|
105
|
+
req = urllib.request.Request(
|
|
106
|
+
url,
|
|
107
|
+
data=body,
|
|
108
|
+
headers={"Content-Type": "application/json", "User-Agent": "WebPilotClient/2.0"},
|
|
109
|
+
method="POST",
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
with urllib.request.urlopen(req, timeout=90) as resp:
|
|
113
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
114
|
+
return SupervisorActionResponse.from_dict(data)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
return SupervisorActionResponse(
|
|
117
|
+
success=False,
|
|
118
|
+
action=action_req.action,
|
|
119
|
+
message=f"Network error communicating with Master Supervisor: {exc}",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def restart_worker(self) -> SupervisorActionResponse:
|
|
123
|
+
"""Triggers cascading kill of Layer 2 Worker and Chromium, then respawns fresh."""
|
|
124
|
+
self.ensure_running()
|
|
125
|
+
req = urllib.request.Request(f"{self.base_url}/restart", data=b"{}", method="POST")
|
|
126
|
+
try:
|
|
127
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
128
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
129
|
+
return SupervisorActionResponse.from_dict(data)
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
return SupervisorActionResponse(
|
|
132
|
+
success=False,
|
|
133
|
+
action="restart",
|
|
134
|
+
message=f"Failed to restart worker: {exc}",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def stop_service(self) -> SupervisorActionResponse:
|
|
138
|
+
"""Completely terminates Master Supervisor and all worker tiers."""
|
|
139
|
+
if not self.is_running():
|
|
140
|
+
return SupervisorActionResponse(
|
|
141
|
+
success=True,
|
|
142
|
+
action="stop",
|
|
143
|
+
message="Master Supervisor is already stopped.",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
req = urllib.request.Request(f"{self.base_url}/stop", data=b"{}", method="POST")
|
|
147
|
+
try:
|
|
148
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
149
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
150
|
+
return SupervisorActionResponse.from_dict(data)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
return SupervisorActionResponse(
|
|
153
|
+
success=False,
|
|
154
|
+
action="stop",
|
|
155
|
+
message=f"Error stopping service: {exc}",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def get_status(self) -> Dict[str, Any]:
|
|
159
|
+
"""Retrieves runtime health and PID telemetry."""
|
|
160
|
+
if not self.is_running():
|
|
161
|
+
return {"status": "stopped", "supervisor_alive": False}
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
req = urllib.request.Request(f"{self.base_url}/status")
|
|
165
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
166
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
167
|
+
data["status"] = "running"
|
|
168
|
+
data["supervisor_alive"] = True
|
|
169
|
+
return data
|
|
170
|
+
except Exception:
|
|
171
|
+
return {"status": "unreachable", "supervisor_alive": False}
|
supervisor/contracts.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Data contracts for Master Supervisor, Worker Process, and CLI Client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SupervisorActionRequest:
|
|
12
|
+
"""Action payload sent from CLI or Shell to the Master Supervisor."""
|
|
13
|
+
action: str # 'apply', 'inspect', 'list_tabs', 'switch_tab', 'restart_worker', 'status', 'stop'
|
|
14
|
+
url: Optional[str] = None
|
|
15
|
+
data_path: Optional[str] = None
|
|
16
|
+
fill_arguments: List[str] = field(default_factory=list)
|
|
17
|
+
press_buttons: List[str] = field(default_factory=list)
|
|
18
|
+
upload_files: List[str] = field(default_factory=list)
|
|
19
|
+
cookies_path: Optional[str] = None
|
|
20
|
+
submit: bool = False
|
|
21
|
+
screenshot_path: Optional[str] = None
|
|
22
|
+
tab_index: Optional[int] = None
|
|
23
|
+
unpack_options: bool = False
|
|
24
|
+
headed: bool = False
|
|
25
|
+
auto_inspect: bool = True
|
|
26
|
+
timeout_minutes: int = 30
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
29
|
+
return asdict(self)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, data: Dict[str, Any]) -> SupervisorActionRequest:
|
|
33
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SupervisorActionResponse:
|
|
38
|
+
"""Standardized response returned by Master Supervisor to CLI or Shell."""
|
|
39
|
+
success: bool
|
|
40
|
+
action: str
|
|
41
|
+
message: str = ""
|
|
42
|
+
browser_pid: Optional[int] = None
|
|
43
|
+
worker_pid: Optional[int] = None
|
|
44
|
+
active_tab_index: Optional[int] = None
|
|
45
|
+
active_url: Optional[str] = None
|
|
46
|
+
active_title: Optional[str] = None
|
|
47
|
+
total_tabs: int = 0
|
|
48
|
+
tabs: List[Dict[str, Any]] = field(default_factory=list)
|
|
49
|
+
telemetry: Dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
schema: Optional[Dict[str, Any]] = None
|
|
51
|
+
confirmed_fields: List[Any] = field(default_factory=list)
|
|
52
|
+
failed_fields: List[Any] = field(default_factory=list)
|
|
53
|
+
validation_errors: List[str] = field(default_factory=list)
|
|
54
|
+
reactive_event: Optional[Dict[str, Any]] = None
|
|
55
|
+
output_lines: List[str] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
58
|
+
return asdict(self)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, data: Dict[str, Any]) -> SupervisorActionResponse:
|
|
62
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Layer 1: Master Supervisor Daemon Host.
|
|
2
|
+
|
|
3
|
+
Runs as a lightweight, persistent background daemon (Zero-Admin, Standard User).
|
|
4
|
+
Supervises Layer 2 (Operational Worker) and enforces cascading teardown.
|
|
5
|
+
Exposes a minimal HTTP endpoint on 127.0.0.1:9333 for CLI and Shell clients.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
if PROJECT_ROOT not in sys.path:
|
|
21
|
+
sys.path.insert(0, PROJECT_ROOT)
|
|
22
|
+
|
|
23
|
+
from supervisor.contracts import SupervisorActionRequest, SupervisorActionResponse
|
|
24
|
+
|
|
25
|
+
DEFAULT_SUPERVISOR_HOST: str = "127.0.0.1"
|
|
26
|
+
DEFAULT_SUPERVISOR_PORT: int = 9333
|
|
27
|
+
DEFAULT_INACTIVITY_TIMEOUT_SECONDS: int = 1800 # 30 minutes
|
|
28
|
+
LOG_FILE: str = os.path.join(PROJECT_ROOT, "master_daemon.log")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LogWriter:
|
|
32
|
+
"""Safe file-backed stream writer for headless daemon background logging."""
|
|
33
|
+
def __init__(self, filepath: str):
|
|
34
|
+
self.filepath = filepath
|
|
35
|
+
|
|
36
|
+
def write(self, s: str) -> None:
|
|
37
|
+
if s:
|
|
38
|
+
try:
|
|
39
|
+
with open(self.filepath, "a", encoding="utf-8") as f:
|
|
40
|
+
f.write(s)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def flush(self) -> None:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def log_daemon(msg: str) -> None:
|
|
49
|
+
"""Appends timestamped event to master_daemon.log."""
|
|
50
|
+
try:
|
|
51
|
+
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
52
|
+
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
|
53
|
+
f.write(f"[{ts}] {msg}\n")
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MasterSupervisor:
|
|
59
|
+
"""Layer 1 Master Supervisor: controls Worker process lifecycle and inactivity watchdog."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
host: str = DEFAULT_SUPERVISOR_HOST,
|
|
64
|
+
port: int = DEFAULT_SUPERVISOR_PORT,
|
|
65
|
+
inactivity_timeout_seconds: int = DEFAULT_INACTIVITY_TIMEOUT_SECONDS,
|
|
66
|
+
):
|
|
67
|
+
self.host = host
|
|
68
|
+
self.port = port
|
|
69
|
+
self.inactivity_timeout_seconds = inactivity_timeout_seconds
|
|
70
|
+
self.worker_process: Optional[subprocess.Popen] = None
|
|
71
|
+
self.last_active_time: float = time.time()
|
|
72
|
+
self.lock = threading.Lock()
|
|
73
|
+
self._is_running = True
|
|
74
|
+
|
|
75
|
+
# Launch watchdog thread
|
|
76
|
+
self.watchdog_thread = threading.Thread(target=self._watchdog_loop, daemon=True)
|
|
77
|
+
self.watchdog_thread.start()
|
|
78
|
+
|
|
79
|
+
def _ensure_worker(self) -> subprocess.Popen:
|
|
80
|
+
"""Spawns Layer 2 Worker subprocess if not currently running."""
|
|
81
|
+
with self.lock:
|
|
82
|
+
if self.worker_process is None or self.worker_process.poll() is not None:
|
|
83
|
+
if getattr(sys, "frozen", False):
|
|
84
|
+
worker_cmd = [sys.executable, "--run-worker"]
|
|
85
|
+
worker_cwd = os.path.dirname(sys.executable)
|
|
86
|
+
else:
|
|
87
|
+
worker_cmd = [sys.executable, "-m", "supervisor.worker_process"]
|
|
88
|
+
worker_cwd = PROJECT_ROOT
|
|
89
|
+
|
|
90
|
+
self.worker_process = subprocess.Popen(
|
|
91
|
+
worker_cmd,
|
|
92
|
+
stdin=subprocess.PIPE,
|
|
93
|
+
stdout=subprocess.PIPE,
|
|
94
|
+
stderr=subprocess.PIPE,
|
|
95
|
+
text=True,
|
|
96
|
+
bufsize=1,
|
|
97
|
+
cwd=worker_cwd,
|
|
98
|
+
)
|
|
99
|
+
return self.worker_process
|
|
100
|
+
|
|
101
|
+
def execute_action(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
|
|
102
|
+
"""Proxies action request to Layer 2 Worker and returns structured response."""
|
|
103
|
+
self.last_active_time = time.time()
|
|
104
|
+
|
|
105
|
+
if req.action == "restart_worker":
|
|
106
|
+
return self.restart_worker()
|
|
107
|
+
|
|
108
|
+
if req.action in ("stop", "shutdown"):
|
|
109
|
+
self.terminate_worker()
|
|
110
|
+
self._is_running = False
|
|
111
|
+
return SupervisorActionResponse(
|
|
112
|
+
success=True,
|
|
113
|
+
action="stop",
|
|
114
|
+
message="Master Supervisor and all worker tiers shut down.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
worker = self._ensure_worker()
|
|
118
|
+
assert worker.stdin is not None and worker.stdout is not None
|
|
119
|
+
|
|
120
|
+
req_line = json.dumps(req.to_dict()) + "\n"
|
|
121
|
+
try:
|
|
122
|
+
worker.stdin.write(req_line)
|
|
123
|
+
worker.stdin.flush()
|
|
124
|
+
|
|
125
|
+
data = None
|
|
126
|
+
while True:
|
|
127
|
+
line = worker.stdout.readline()
|
|
128
|
+
if not line:
|
|
129
|
+
break
|
|
130
|
+
line_str = line.strip()
|
|
131
|
+
if not line_str:
|
|
132
|
+
continue
|
|
133
|
+
try:
|
|
134
|
+
data = json.loads(line_str)
|
|
135
|
+
break
|
|
136
|
+
except Exception:
|
|
137
|
+
log_daemon(f"Skipping non-JSON worker stdout: {line_str}")
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if not data:
|
|
141
|
+
log_daemon("Worker returned no valid JSON response.")
|
|
142
|
+
self.terminate_worker()
|
|
143
|
+
return SupervisorActionResponse(
|
|
144
|
+
success=False,
|
|
145
|
+
action=req.action,
|
|
146
|
+
message="Operational Worker failed to return valid JSON response.",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return SupervisorActionResponse.from_dict(data)
|
|
150
|
+
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
return SupervisorActionResponse(
|
|
153
|
+
success=False,
|
|
154
|
+
action=req.action,
|
|
155
|
+
message=f"Supervisor dispatch error: {exc}",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def restart_worker(self) -> SupervisorActionResponse:
|
|
159
|
+
"""Cascading kill of Layer 2 Worker and all child Chromium processes, then respawns fresh."""
|
|
160
|
+
with self.lock:
|
|
161
|
+
old_pid = self.worker_process.pid if self.worker_process else None
|
|
162
|
+
self.terminate_worker()
|
|
163
|
+
# Spawn fresh Worker
|
|
164
|
+
new_worker = subprocess.Popen(
|
|
165
|
+
[sys.executable, "-m", "supervisor.worker_process"],
|
|
166
|
+
stdin=subprocess.PIPE,
|
|
167
|
+
stdout=subprocess.PIPE,
|
|
168
|
+
stderr=subprocess.PIPE,
|
|
169
|
+
text=True,
|
|
170
|
+
bufsize=1,
|
|
171
|
+
cwd=PROJECT_ROOT,
|
|
172
|
+
)
|
|
173
|
+
self.worker_process = new_worker
|
|
174
|
+
self.last_active_time = time.time()
|
|
175
|
+
|
|
176
|
+
return SupervisorActionResponse(
|
|
177
|
+
success=True,
|
|
178
|
+
action="restart_worker",
|
|
179
|
+
message=f"Cascading kill completed for PID {old_pid}. Fresh Worker spawned (PID: {new_worker.pid}).",
|
|
180
|
+
worker_pid=new_worker.pid,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def terminate_worker(self) -> None:
|
|
184
|
+
"""Forcefully kills the Layer 2 Worker process tree."""
|
|
185
|
+
proc = self.worker_process
|
|
186
|
+
self.worker_process = None
|
|
187
|
+
if proc and proc.poll() is None:
|
|
188
|
+
try:
|
|
189
|
+
if sys.platform == "win32":
|
|
190
|
+
subprocess.run(
|
|
191
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
192
|
+
stdout=subprocess.DEVNULL,
|
|
193
|
+
stderr=subprocess.DEVNULL,
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
proc.kill()
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
def get_status(self) -> Dict[str, Any]:
|
|
201
|
+
"""Returns telemetry for Supervisor and Worker."""
|
|
202
|
+
worker_alive = self.worker_process is not None and self.worker_process.poll() is None
|
|
203
|
+
worker_pid = self.worker_process.pid if worker_alive else None
|
|
204
|
+
return {
|
|
205
|
+
"supervisor_pid": os.getpid(),
|
|
206
|
+
"supervisor_port": self.port,
|
|
207
|
+
"worker_alive": worker_alive,
|
|
208
|
+
"worker_pid": worker_pid,
|
|
209
|
+
"idle_seconds": round(time.time() - self.last_active_time, 1),
|
|
210
|
+
"inactivity_timeout_seconds": self.inactivity_timeout_seconds,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def _watchdog_loop(self) -> None:
|
|
214
|
+
"""Periodically checks inactivity. Kills idle Worker to release 100% of browser RAM."""
|
|
215
|
+
while self._is_running:
|
|
216
|
+
time.sleep(15)
|
|
217
|
+
if self.worker_process and self.worker_process.poll() is None:
|
|
218
|
+
idle = time.time() - self.last_active_time
|
|
219
|
+
if idle > self.inactivity_timeout_seconds:
|
|
220
|
+
print(f"[*] Inactivity watchdog triggered ({idle:.0f}s idle). Terminating Worker to release RAM.")
|
|
221
|
+
self.terminate_worker()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def create_supervisor_handler(supervisor: MasterSupervisor):
|
|
225
|
+
"""Factory creating an HTTP Request Handler bound to the supervisor instance."""
|
|
226
|
+
|
|
227
|
+
class SupervisorHTTPHandler(BaseHTTPRequestHandler):
|
|
228
|
+
def log_message(self, format, *args):
|
|
229
|
+
# Suppress default stdout HTTP access logs
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
def _send_json(self, status_code: int, data: Dict[str, Any]):
|
|
233
|
+
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
234
|
+
self.send_response(status_code)
|
|
235
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
236
|
+
self.send_header("Content-Length", str(len(body)))
|
|
237
|
+
self.end_headers()
|
|
238
|
+
self.wfile.write(body)
|
|
239
|
+
|
|
240
|
+
def do_GET(self):
|
|
241
|
+
if self.path == "/ping":
|
|
242
|
+
self._send_json(200, {"status": "ok", "supervisor_pid": os.getpid()})
|
|
243
|
+
elif self.path == "/status":
|
|
244
|
+
status = supervisor.get_status()
|
|
245
|
+
self._send_json(200, status)
|
|
246
|
+
else:
|
|
247
|
+
self._send_json(404, {"error": "Not Found"})
|
|
248
|
+
|
|
249
|
+
def do_POST(self):
|
|
250
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
251
|
+
raw_body = self.rfile.read(content_length).decode("utf-8") if content_length > 0 else "{}"
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
data = json.loads(raw_body)
|
|
255
|
+
except Exception:
|
|
256
|
+
data = {}
|
|
257
|
+
|
|
258
|
+
if self.path == "/execute":
|
|
259
|
+
req = SupervisorActionRequest.from_dict(data)
|
|
260
|
+
res = supervisor.execute_action(req)
|
|
261
|
+
self._send_json(200, res.to_dict())
|
|
262
|
+
elif self.path == "/restart":
|
|
263
|
+
res = supervisor.restart_worker()
|
|
264
|
+
self._send_json(200, res.to_dict())
|
|
265
|
+
elif self.path == "/stop":
|
|
266
|
+
req = SupervisorActionRequest(action="stop")
|
|
267
|
+
res = supervisor.execute_action(req)
|
|
268
|
+
self._send_json(200, res.to_dict())
|
|
269
|
+
# Schedule daemon shutdown in background thread
|
|
270
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
271
|
+
else:
|
|
272
|
+
self._send_json(404, {"error": "Not Found"})
|
|
273
|
+
|
|
274
|
+
return SupervisorHTTPHandler
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def run_master_daemon(port: int = DEFAULT_SUPERVISOR_PORT):
|
|
278
|
+
"""Starts the Master Supervisor HTTP service."""
|
|
279
|
+
sys.stdout = LogWriter(LOG_FILE)
|
|
280
|
+
sys.stderr = LogWriter(LOG_FILE)
|
|
281
|
+
log_daemon(f"run_master_daemon starting on port {port} (PID: {os.getpid()})")
|
|
282
|
+
try:
|
|
283
|
+
supervisor = MasterSupervisor(port=port)
|
|
284
|
+
handler_class = create_supervisor_handler(supervisor)
|
|
285
|
+
server = HTTPServer((DEFAULT_SUPERVISOR_HOST, port), handler_class)
|
|
286
|
+
log_daemon(f"Master Supervisor listening on http://{DEFAULT_SUPERVISOR_HOST}:{port}")
|
|
287
|
+
print(f"[*] Master Supervisor (Layer 1) started on http://{DEFAULT_SUPERVISOR_HOST}:{port} (PID: {os.getpid()})")
|
|
288
|
+
server.serve_forever()
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
log_daemon(f"Master Supervisor fatal exception: {exc}")
|
|
291
|
+
raise
|
|
292
|
+
finally:
|
|
293
|
+
try:
|
|
294
|
+
supervisor.terminate_worker()
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
log_daemon("Master Supervisor stopped.")
|
|
298
|
+
print("[*] Master Supervisor stopped.")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_SUPERVISOR_PORT
|
|
303
|
+
run_master_daemon(port=port)
|