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.
Files changed (47) hide show
  1. adapters/__init__.py +14 -0
  2. adapters/base.py +34 -0
  3. adapters/browser_adapter.py +300 -0
  4. adapters/dom_scripts.py +518 -0
  5. adapters/envelope_builder.py +108 -0
  6. adapters/live_session_manager.py +326 -0
  7. adapters/upload_adapter.py +77 -0
  8. builder.py +83 -0
  9. cli.py +341 -0
  10. config/__init__.py +23 -0
  11. config/settings.py +339 -0
  12. constants/__init__.py +165 -0
  13. constants/contracts.py +95 -0
  14. constants/selectors.py +27 -0
  15. constants/timeouts.py +25 -0
  16. core/__init__.py +45 -0
  17. core/exceptions.py +33 -0
  18. core/models.py +273 -0
  19. engine.py +199 -0
  20. exceptions.py +23 -0
  21. mcp_server.py +347 -0
  22. models.py +29 -0
  23. orchestration/__init__.py +19 -0
  24. orchestration/context.py +71 -0
  25. orchestration/flow_orchestrator.py +358 -0
  26. orchestration/session_coordinator.py +88 -0
  27. services/__init__.py +17 -0
  28. services/auth_navigation_service.py +129 -0
  29. services/field_interaction_service.py +174 -0
  30. services/form_filler_service.py +106 -0
  31. services/inspection_formatter_service.py +68 -0
  32. services/inspection_service.py +40 -0
  33. services/reactive_interaction_service.py +174 -0
  34. supervisor/__init__.py +13 -0
  35. supervisor/client.py +171 -0
  36. supervisor/contracts.py +62 -0
  37. supervisor/master_daemon.py +303 -0
  38. supervisor/shell.py +194 -0
  39. supervisor/worker_process.py +400 -0
  40. validators/__init__.py +9 -0
  41. validators/form_validator.py +28 -0
  42. validators/payload_validator.py +25 -0
  43. webpilot_engine-2.0.0.dist-info/METADATA +299 -0
  44. webpilot_engine-2.0.0.dist-info/RECORD +47 -0
  45. webpilot_engine-2.0.0.dist-info/WHEEL +5 -0
  46. webpilot_engine-2.0.0.dist-info/entry_points.txt +6 -0
  47. webpilot_engine-2.0.0.dist-info/top_level.txt +14 -0
supervisor/shell.py ADDED
@@ -0,0 +1,194 @@
1
+ """Interactive REPL Shell Mode (Thin Client).
2
+
3
+ Provides an interactive console prompt for step-by-step browser automation,
4
+ communicating directly with the Master Supervisor (Layer 1).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from typing import List
12
+
13
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
14
+ if PROJECT_ROOT not in sys.path:
15
+ sys.path.insert(0, PROJECT_ROOT)
16
+
17
+ from services.inspection_formatter_service import InspectionFormatterService
18
+ from supervisor.client import SupervisorClient
19
+ from supervisor.contracts import SupervisorActionRequest, SupervisorActionResponse
20
+
21
+
22
+ def run_interactive_shell(host: str = "127.0.0.1", port: int = 9333):
23
+ """Runs the interactive REPL session."""
24
+ client = SupervisorClient(base_url=f"http://{host}:{port}")
25
+ try:
26
+ client.ensure_running()
27
+ except Exception as exc:
28
+ print(f"[!] Failed to connect to Master Supervisor: {exc}")
29
+ return
30
+
31
+ print("\n" + "═" * 70)
32
+ print(" 🚀 WEBPILOT — INTERACTIVE AUTONOMOUS REPL SHELL")
33
+ print("═" * 70)
34
+ print(" Commands:")
35
+ print(" • open <url> : Navigate to target URL")
36
+ print(" • fill <k>=<v> [<k>=<v>] : Populate one or more fields")
37
+ print(" • press <button_text> : Click button with reactive mutation tracking")
38
+ print(" • upload <key>=<path> : Upload document/CV")
39
+ print(" • inspect : Inspect current page form schema")
40
+ print(" • tabs : List all open tabs")
41
+ print(" • tab <index> : Switch to specific tab index (0-based)")
42
+ print(" • cookies <path> : Save refreshed session cookies")
43
+ print(" • screenshot <path> : Capture visual screenshot")
44
+ print(" • status : View runtime PIDs and tier health")
45
+ print(" • restart : Cascading kill of Layer 2 & 3 with instant restart")
46
+ print(" • exit / quit : Exit shell")
47
+ print("═" * 70 + "\n")
48
+
49
+ current_url = ""
50
+
51
+ while True:
52
+ try:
53
+ line = input("webpilot> ").strip()
54
+ except (KeyboardInterrupt, EOFError):
55
+ print("\nExiting interactive shell.")
56
+ break
57
+
58
+ if not line:
59
+ continue
60
+
61
+ parts = line.split(maxsplit=1)
62
+ cmd = parts[0].lower()
63
+ arg = parts[1] if len(parts) > 1 else ""
64
+
65
+ if cmd in ("exit", "quit", "q"):
66
+ print("Exiting interactive shell. Background supervisor remains active.")
67
+ break
68
+
69
+ elif cmd == "status":
70
+ st = client.get_status()
71
+ print("\n[📊 Supervisor & Worker Telemetry]:")
72
+ for k, v in st.items():
73
+ print(f" • {k:<25}: {v}")
74
+ print()
75
+
76
+ elif cmd == "restart":
77
+ print("[*] Initiating cascading kill of Layer 2 & 3...")
78
+ res = client.restart_worker()
79
+ print(f"[✓] {res.message}\n")
80
+
81
+ elif cmd in ("open", "goto"):
82
+ if not arg:
83
+ print("[!] Error: URL required, e.g. open https://example.com")
84
+ continue
85
+ current_url = arg.strip()
86
+ print(f"[*] Navigating to: {current_url}...")
87
+ req = SupervisorActionRequest(action="apply", url=current_url)
88
+ res = client.execute(req)
89
+ _print_apply_response(res)
90
+
91
+ elif cmd == "fill":
92
+ if not arg:
93
+ print("[!] Error: field mapping required, e.g. fill username=user@mail.com")
94
+ continue
95
+ fill_args = [a.strip() for a in arg.split(" ") if "=" in a]
96
+ if not fill_args:
97
+ fill_args = [arg.strip()]
98
+ req = SupervisorActionRequest(action="apply", fill_arguments=fill_args)
99
+ res = client.execute(req)
100
+ _print_apply_response(res)
101
+
102
+ elif cmd == "press":
103
+ if not arg:
104
+ print("[!] Error: button label required, e.g. press 'Sign In'")
105
+ continue
106
+ btn = arg.strip().strip("'\"")
107
+ print(f"[*] Pressing button: '{btn}'...")
108
+ req = SupervisorActionRequest(action="apply", press_buttons=[btn])
109
+ res = client.execute(req)
110
+ _print_apply_response(res)
111
+
112
+ elif cmd == "upload":
113
+ if not arg:
114
+ print("[!] Error: upload path required, e.g. upload resume=C:/cv.pdf")
115
+ continue
116
+ req = SupervisorActionRequest(action="apply", upload_files=[arg.strip()])
117
+ res = client.execute(req)
118
+ _print_apply_response(res)
119
+
120
+ elif cmd == "inspect":
121
+ print("[*] Inspecting active page schema...")
122
+ req = SupervisorActionRequest(action="inspect")
123
+ res = client.execute(req)
124
+ if res.schema:
125
+ print("\n" + InspectionFormatterService.format_summary(res.schema))
126
+ else:
127
+ print(f"[!] Inspection failed: {res.message}")
128
+
129
+ elif cmd == "tabs":
130
+ req = SupervisorActionRequest(action="list_tabs")
131
+ res = client.execute(req)
132
+ print("\n" + "═" * 70)
133
+ print(" 📑 OPEN BROWSER TABS")
134
+ print("═" * 70)
135
+ if not res.tabs:
136
+ print(" [!] No open tabs detected.")
137
+ else:
138
+ for t in res.tabs:
139
+ act = " [*ACTIVE*]" if t.get("is_active") else ""
140
+ print(f" • Tab #{t['index']}{act}: '{t['title']}'\n URL: {t['url']}")
141
+ print("═" * 70 + "\n")
142
+
143
+ elif cmd == "tab":
144
+ if not arg or not arg.isdigit():
145
+ print("[!] Error: Tab index required, e.g. tab 0")
146
+ continue
147
+ idx = int(arg)
148
+ req = SupervisorActionRequest(action="switch_tab", tab_index=idx)
149
+ res = client.execute(req)
150
+ print(f"[✓] {res.message}\n")
151
+
152
+ elif cmd == "cookies":
153
+ save_path = arg.strip() or "session_cookies.json"
154
+ req = SupervisorActionRequest(action="apply", cookies_path=save_path)
155
+ res = client.execute(req)
156
+ print(f"[✓] Cookies requested for save: {save_path}\n")
157
+
158
+ elif cmd == "screenshot":
159
+ save_path = arg.strip() or "screenshot.png"
160
+ req = SupervisorActionRequest(action="apply", screenshot_path=save_path)
161
+ res = client.execute(req)
162
+ print(f"[✓] Screenshot saved: {save_path}\n")
163
+
164
+ else:
165
+ print(f"[!] Unknown command '{cmd}'. Type 'open', 'fill', 'press', 'inspect', 'tabs', 'restart', or 'exit'.")
166
+
167
+
168
+ def _print_apply_response(res: SupervisorActionResponse):
169
+ """Formats output lines and reactive events from worker."""
170
+ if not res.success:
171
+ print(f"[!] Action failed: {res.message}")
172
+ return
173
+
174
+ print(" 📄 ACTIVE PAGE TELEMETRY")
175
+ print("─" * 70)
176
+ print(f" • Tab #{res.active_tab_index} (Total: {res.total_tabs}) | Title: '{res.active_title}'")
177
+ print(f" • URL: {res.active_url}")
178
+ print("═" * 70)
179
+
180
+ for line in res.output_lines:
181
+ print(f" {line}")
182
+
183
+ if res.reactive_event:
184
+ rev = res.reactive_event
185
+ if rev.get("redirected"):
186
+ print(f"\n[🔀 Redirect / Navigation]: {rev.get('new_url')}")
187
+ if rev.get("new_window"):
188
+ print(f"\n[🌐 New Window Opened]: {rev.get('new_url')}")
189
+ if rev.get("modal_opened"):
190
+ print(f"\n[🪟 Modal Appeared]: {rev.get('modal_text')}")
191
+ if res.schema:
192
+ print(InspectionFormatterService.format_summary(res.schema))
193
+
194
+ print()
@@ -0,0 +1,400 @@
1
+ """Layer 2: Operational Worker Process.
2
+
3
+ Runs as a supervised subprocess of Master Supervisor (Layer 1).
4
+ Holds Playwright, Chromium browser, contexts, pages, and active session state.
5
+ Communicates via stdin/stdout JSON-RPC.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ # Ensure project root is on sys.path
17
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
+ if PROJECT_ROOT not in sys.path:
19
+ sys.path.insert(0, PROJECT_ROOT)
20
+
21
+ from adapters.browser_adapter import BrowserAdapter
22
+ from adapters.upload_adapter import UploadAdapter
23
+ from common.cookies import save_cookies
24
+ from builder import FormPayloadBuilder
25
+ from config.settings import get_settings, load_device_profile
26
+ from core.models import DeviceProfile
27
+ from orchestration.context import SessionContext
28
+ from orchestration.session_coordinator import SessionCoordinator
29
+ from services.auth_navigation_service import AuthNavigationService
30
+ from services.field_interaction_service import FieldInteractionService
31
+ from services.inspection_formatter_service import InspectionFormatterService
32
+ from services.inspection_service import InspectionService
33
+ from services.reactive_interaction_service import ReactiveInteractionService
34
+ from supervisor.contracts import SupervisorActionRequest, SupervisorActionResponse
35
+ from validators.form_validator import FormValidator
36
+
37
+
38
+ class OperationalWorker:
39
+ """Manages active Playwright runtime and executes form/browser automation tasks."""
40
+
41
+ def __init__(self, headed: bool = False):
42
+ self.settings = get_settings()
43
+ self.headed = headed
44
+ self.coordinator: Optional[SessionCoordinator] = None
45
+
46
+ def _ensure_session(self, req: SupervisorActionRequest) -> SessionCoordinator:
47
+ """Initializes or returns active SessionCoordinator."""
48
+ if (
49
+ self.coordinator is None
50
+ or self.coordinator.page is None
51
+ or self.coordinator.page.is_closed()
52
+ ):
53
+ effective_cookies = req.cookies_path or self.settings.cookies_path
54
+ session_ctx = SessionContext(
55
+ settings=self.settings,
56
+ device_profile=load_device_profile(),
57
+ headless=not req.headed if req.headed else not self.headed,
58
+ cookies_path=effective_cookies,
59
+ keep_alive=False,
60
+ close_session=False,
61
+ timeout_minutes=req.timeout_minutes,
62
+ tab_index=req.tab_index,
63
+ )
64
+ self.coordinator = SessionCoordinator(session_ctx)
65
+ self.coordinator.start()
66
+ return self.coordinator
67
+
68
+ def handle_request(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
69
+ """Dispatches action to internal services and returns structured response."""
70
+ action = req.action.lower().strip()
71
+ lines: List[str] = []
72
+
73
+ try:
74
+ if action in ("ping", "status"):
75
+ return self._handle_status(req)
76
+
77
+ elif action == "list_tabs":
78
+ return self._handle_list_tabs(req)
79
+
80
+ elif action == "switch_tab":
81
+ return self._handle_switch_tab(req)
82
+
83
+ elif action == "inspect":
84
+ return self._handle_inspect(req)
85
+
86
+ elif action == "apply":
87
+ return self._handle_apply(req)
88
+
89
+ elif action in ("stop", "shutdown"):
90
+ self.shutdown()
91
+ return SupervisorActionResponse(
92
+ success=True,
93
+ action=action,
94
+ message="Operational Worker shut down cleanly.",
95
+ )
96
+
97
+ else:
98
+ return SupervisorActionResponse(
99
+ success=False,
100
+ action=action,
101
+ message=f"Unknown action: '{action}'",
102
+ )
103
+
104
+ except Exception as exc:
105
+ return SupervisorActionResponse(
106
+ success=False,
107
+ action=action,
108
+ message=f"Error executing {action}: {exc}",
109
+ output_lines=[f"[!] Worker exception: {exc}"],
110
+ )
111
+
112
+ def _handle_status(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
113
+ coord = self.coordinator
114
+ if not coord or not coord.page:
115
+ return SupervisorActionResponse(
116
+ success=True,
117
+ action="status",
118
+ message="Worker is idle (no active browser yet).",
119
+ worker_pid=os.getpid(),
120
+ total_tabs=0,
121
+ )
122
+
123
+ adapter = coord.adapter
124
+ return SupervisorActionResponse(
125
+ success=True,
126
+ action="status",
127
+ message="Worker active with running browser.",
128
+ browser_pid=adapter.browser_pid,
129
+ worker_pid=os.getpid(),
130
+ active_tab_index=adapter.get_active_tab_index(),
131
+ active_url=coord.page.url if coord.page else "",
132
+ active_title=coord.page.title() if coord.page else "",
133
+ total_tabs=len(coord.context.pages) if coord.context else 0,
134
+ tabs=adapter.list_open_tabs(),
135
+ )
136
+
137
+ def _handle_list_tabs(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
138
+ coord = self._ensure_session(req)
139
+ adapter = coord.adapter
140
+ tabs = adapter.list_open_tabs()
141
+ return SupervisorActionResponse(
142
+ success=True,
143
+ action="list_tabs",
144
+ message=f"Retrieved {len(tabs)} tabs.",
145
+ browser_pid=adapter.browser_pid,
146
+ worker_pid=os.getpid(),
147
+ total_tabs=len(tabs),
148
+ tabs=tabs,
149
+ active_tab_index=adapter.get_active_tab_index(),
150
+ active_url=coord.page.url if coord.page else "",
151
+ )
152
+
153
+ def _handle_switch_tab(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
154
+ coord = self._ensure_session(req)
155
+ if req.tab_index is None:
156
+ return SupervisorActionResponse(
157
+ success=False,
158
+ action="switch_tab",
159
+ message="tab_index is required for switch_tab action.",
160
+ )
161
+ page = coord.switch_to_tab(req.tab_index)
162
+ return SupervisorActionResponse(
163
+ success=True,
164
+ action="switch_tab",
165
+ message=f"Switched to Tab #{req.tab_index}: '{page.title()}'",
166
+ active_tab_index=req.tab_index,
167
+ active_url=page.url,
168
+ active_title=page.title(),
169
+ total_tabs=len(coord.context.pages) if coord.context else 0,
170
+ )
171
+
172
+ def _handle_inspect(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
173
+ coord = self._ensure_session(req)
174
+ page = coord.page
175
+ assert page is not None
176
+
177
+ if req.url:
178
+ coord.adapter.navigate(req.url)
179
+ page = coord.page
180
+
181
+ # Landing portal transition if needed
182
+ auth_svc = AuthNavigationService(page, coord.context)
183
+ if req.url and ("jobs." in page.url or page.locator("button.dropdown-toggle, .dialogApplyBtn").count() > 0):
184
+ auth_svc.bypass_job_landing_portal()
185
+ page = coord.switch_to_latest_page()
186
+
187
+ inspection_svc = InspectionService(page)
188
+ schema = inspection_svc.inspect(unpack_options=req.unpack_options)
189
+
190
+ if req.screenshot_path:
191
+ coord.adapter.capture_screenshot(req.screenshot_path, full_page=True)
192
+
193
+ return SupervisorActionResponse(
194
+ success=True,
195
+ action="inspect",
196
+ message=f"Inspected page: '{page.title()}'",
197
+ browser_pid=coord.adapter.browser_pid,
198
+ worker_pid=os.getpid(),
199
+ active_tab_index=coord.adapter.get_active_tab_index(),
200
+ active_url=page.url,
201
+ active_title=page.title(),
202
+ total_tabs=len(coord.context.pages) if coord.context else 0,
203
+ schema=schema,
204
+ )
205
+
206
+ def _handle_apply(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
207
+ coord = self._ensure_session(req)
208
+ page = coord.page
209
+ ctx = coord.context
210
+ assert page is not None and ctx is not None
211
+
212
+ lines: List[str] = []
213
+
214
+ if req.tab_index is not None:
215
+ page = coord.switch_to_tab(req.tab_index)
216
+
217
+ if req.url:
218
+ lines.append(f"[*] Navigating to target URL: {req.url}")
219
+ coord.adapter.navigate(req.url)
220
+ page = coord.page
221
+ else:
222
+ if page.url and page.url != "about:blank":
223
+ lines.append(f"[*] Working on active page in existing session: {page.url}")
224
+ else:
225
+ return SupervisorActionResponse(
226
+ success=False,
227
+ action="apply",
228
+ message="Target URL (--url) is required when starting a new session without an active page.",
229
+ )
230
+
231
+ auth_svc = AuthNavigationService(page, ctx)
232
+
233
+ # 1. Landing portal bypass
234
+ if req.url and ("jobs." in page.url or page.locator("button.dropdown-toggle, .dialogApplyBtn").count() > 0):
235
+ lines.append("[*] Bypassing landing page to active application...")
236
+ auth_svc.bypass_job_landing_portal()
237
+ page = coord.switch_to_latest_page()
238
+
239
+ # 2. Wait for form readiness & expand accordions
240
+ auth_svc.wait_for_form_ready(self.settings.form_ready_timeout_ms)
241
+ lines.append("[*] Ensuring all form sections are expanded...")
242
+ try:
243
+ auth_svc.expand_all_sections(self.settings.expand_section_wait_ms)
244
+ except Exception:
245
+ pass
246
+
247
+ # 3. Assemble fields to set
248
+ fields_to_set = []
249
+ if req.data_path and os.path.isfile(req.data_path):
250
+ fields_to_set.extend(FormPayloadBuilder.load_from_file(req.data_path))
251
+ if req.fill_arguments:
252
+ fields_to_set.extend(FormPayloadBuilder.parse_cli_fill_arguments(req.fill_arguments))
253
+
254
+ field_svc = FieldInteractionService(page)
255
+ confirmed = []
256
+ failed = []
257
+
258
+ if fields_to_set:
259
+ lines.append(f"[*] Populating {len(fields_to_set)} field(s)...")
260
+ for key, val in fields_to_set:
261
+ success = field_svc.set_field(key, val)
262
+ if success:
263
+ confirmed.append((key, val))
264
+ lines.append(f" [✓] Set '{key}' -> '{val}'")
265
+ else:
266
+ failed.append((key, val))
267
+ lines.append(f" [~] Unmatched/Failed '{key}' -> '{val}'")
268
+ lines.append(f"[*] Fields summary: {len(confirmed)} set | {len(failed)} skipped.")
269
+
270
+ # 4. Handle uploads
271
+ if req.upload_files:
272
+ upload_adapter = UploadAdapter(page)
273
+ for up in req.upload_files:
274
+ if "=" in up:
275
+ kw, path = up.split("=", 1)
276
+ kw, path = kw.strip(), path.strip()
277
+ else:
278
+ kw, path = "", up.strip()
279
+ if os.path.isfile(path):
280
+ lines.append(f"[*] Uploading '{path}' (Keyword: '{kw}')...")
281
+ upload_adapter.upload_document(path, kw)
282
+
283
+ # 5. Handle buttons with reactive observation
284
+ inspection_svc = InspectionService(page)
285
+ validator = FormValidator(page)
286
+ reactive_svc = ReactiveInteractionService(
287
+ page=page,
288
+ context=ctx,
289
+ inspection_service=inspection_svc,
290
+ form_validator=validator,
291
+ on_active_page_change=lambda p: coord.switch_to_latest_page(),
292
+ )
293
+
294
+ last_reactive_event: Optional[Dict[str, Any]] = None
295
+ if req.press_buttons:
296
+ for btn in req.press_buttons:
297
+ lines.append(f"[*] Pressing button: '{btn}'...")
298
+ res = reactive_svc.click_button(btn)
299
+ if not res.get("success"):
300
+ lines.append(f" [!] Failed to find or click button: '{btn}'")
301
+ continue
302
+ lines.append(f" [✓] Successfully clicked '{btn}'.")
303
+ page = coord.switch_to_latest_page()
304
+ last_reactive_event = res
305
+
306
+ if res.get("new_window"):
307
+ lines.append(f"\n[🌐 New Window / Tab Opened]: {res['new_url']}")
308
+ elif res.get("redirected"):
309
+ lines.append(f"\n[🔀 Redirect / Navigation Detected]: {res['new_url']}")
310
+ elif res.get("modal_opened"):
311
+ lines.append(f"\n[🪟 New Modal / Dialog Appeared]:\n{res['modal_text']}")
312
+ elif res.get("new_inputs_count", 0) > 0:
313
+ lines.append(f"\n[📋 Dynamic Form Expansion (+{res['new_inputs_count']} new inputs)]")
314
+
315
+ # 6. Capture screenshot if requested
316
+ if req.screenshot_path:
317
+ coord.adapter.capture_screenshot(req.screenshot_path, full_page=True)
318
+ lines.append(f"[✓] Form state captured: {req.screenshot_path}")
319
+
320
+ # 7. Save refreshed cookies if requested
321
+ if req.cookies_path and ctx:
322
+ try:
323
+ cookies = ctx.cookies()
324
+ save_cookies(req.cookies_path, cookies)
325
+ lines.append(f"[✓] Persisted {len(cookies)} cookies to: {req.cookies_path}")
326
+ except Exception as exc:
327
+ lines.append(f"[!] Warning saving cookies: {exc}")
328
+
329
+ # 8. Validation checks
330
+ validation_errors = validator.get_validation_errors()
331
+ if validation_errors:
332
+ lines.append(f"[!] Validation Notices: {validation_errors}")
333
+ else:
334
+ lines.append("[✓] Zero validation errors detected!")
335
+
336
+ schema = None
337
+ if req.auto_inspect:
338
+ schema = inspection_svc.inspect(unpack_options=False)
339
+
340
+ return SupervisorActionResponse(
341
+ success=True,
342
+ action="apply",
343
+ message="Form action executed successfully.",
344
+ browser_pid=coord.adapter.browser_pid,
345
+ worker_pid=os.getpid(),
346
+ active_tab_index=coord.adapter.get_active_tab_index(),
347
+ active_url=page.url,
348
+ active_title=page.title(),
349
+ total_tabs=len(ctx.pages) if ctx else 0,
350
+ tabs=coord.adapter.list_open_tabs(),
351
+ confirmed_fields=confirmed,
352
+ failed_fields=failed,
353
+ validation_errors=validation_errors,
354
+ schema=schema,
355
+ reactive_event=last_reactive_event,
356
+ output_lines=lines,
357
+ )
358
+
359
+ def shutdown(self) -> None:
360
+ """Terminates Playwright coordinator and all child browser tabs."""
361
+ if self.coordinator:
362
+ try:
363
+ self.coordinator.close()
364
+ except Exception:
365
+ pass
366
+ self.coordinator = None
367
+
368
+
369
+ def run_worker_loop():
370
+ """Reads JSON requests from stdin, executes, writes JSON response to stdout."""
371
+ rpc_out = sys.stdout
372
+ # Redirect all print/logging to stderr to keep JSON-RPC channel strictly pure
373
+ sys.stdout = sys.stderr
374
+
375
+ worker = OperationalWorker()
376
+ for raw_line in sys.stdin:
377
+ line = raw_line.strip()
378
+ if not line:
379
+ continue
380
+ try:
381
+ data = json.loads(line)
382
+ req = SupervisorActionRequest.from_dict(data)
383
+ res = worker.handle_request(req)
384
+ out = json.dumps(res.to_dict(), ensure_ascii=False)
385
+ rpc_out.write(out + "\n")
386
+ rpc_out.flush()
387
+ if req.action in ("stop", "shutdown"):
388
+ break
389
+ except Exception as exc:
390
+ err_res = SupervisorActionResponse(
391
+ success=False,
392
+ action="unknown",
393
+ message=f"Fatal worker parse error: {exc}",
394
+ )
395
+ rpc_out.write(json.dumps(err_res.to_dict(), ensure_ascii=False) + "\n")
396
+ rpc_out.flush()
397
+
398
+
399
+ if __name__ == "__main__":
400
+ run_worker_loop()
validators/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Validators package for universal web applier."""
2
+
3
+ from validators.payload_validator import PayloadValidator
4
+ from validators.form_validator import FormValidator
5
+
6
+ __all__ = [
7
+ "PayloadValidator",
8
+ "FormValidator",
9
+ ]
@@ -0,0 +1,28 @@
1
+ """Form validation and error banner inspection."""
2
+
3
+ from typing import List
4
+ from playwright.sync_api import Page
5
+ from constants.selectors import SELECTOR_VALIDATION_ERRORS
6
+
7
+ class FormValidator:
8
+ """Inspects the active DOM for validation notices, errors, and alerts."""
9
+
10
+ def __init__(self, page: Page):
11
+ self.page = page
12
+
13
+ def get_validation_errors(self) -> List[str]:
14
+ """Collects error alerts, validation messages, or red warning texts currently shown."""
15
+ if self.page is None:
16
+ return []
17
+
18
+ errors = self.page.evaluate("""(selector) => {
19
+ const errorNodes = Array.from(document.querySelectorAll(selector));
20
+ const texts = errorNodes.map(e => e.innerText.trim()).filter(t => t.length > 0);
21
+
22
+ // Also inspect lines starting with error glyphs
23
+ const bodyLines = document.body.innerText.split('\\n').map(l => l.trim());
24
+ const errLines = bodyLines.filter(l => l.includes('is required') || l.includes('Please correct the errors'));
25
+ return Array.from(new Set([...texts, ...errLines]));
26
+ }""", SELECTOR_VALIDATION_ERRORS)
27
+
28
+ return list(errors)
@@ -0,0 +1,25 @@
1
+ """Validation logic for form payloads and CLI inputs."""
2
+
3
+ from typing import Any, Dict, List, Tuple, Union
4
+ from core.exceptions import ValidationError
5
+
6
+ class PayloadValidator:
7
+ """Validates candidate payloads and CLI key-value arguments."""
8
+
9
+ @staticmethod
10
+ def validate_key_value_pair(key: Any, val: Any) -> Tuple[str, Any]:
11
+ """Validates that a field key is a non-empty string."""
12
+ clean_key = str(key).strip()
13
+ if not clean_key:
14
+ raise ValidationError("Field key cannot be empty or whitespace")
15
+ return clean_key, val
16
+
17
+ @staticmethod
18
+ def validate_payload_list(items: List[Tuple[str, Any]]) -> List[Tuple[str, Any]]:
19
+ """Ensures all items in payload are valid (key, value) tuples."""
20
+ validated = []
21
+ for item in items:
22
+ if not isinstance(item, (tuple, list)) or len(item) != 2:
23
+ raise ValidationError(f"Invalid payload item structure: {item}")
24
+ validated.append(PayloadValidator.validate_key_value_pair(item[0], item[1]))
25
+ return validated