ownapi 1.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 (62) hide show
  1. cli.py +1017 -0
  2. compiler.py +948 -0
  3. core/__init__.py +19 -0
  4. core/api_keys.py +523 -0
  5. core/auto_repair.py +682 -0
  6. core/base_client.py +165 -0
  7. core/consent.py +169 -0
  8. core/form_automation.py +1044 -0
  9. core/hitl_queue.py +886 -0
  10. core/onboarding.py +1457 -0
  11. core/token_store.py +223 -0
  12. healer.py +2074 -0
  13. master_mcp.py +193 -0
  14. ownapi/__init__.py +94 -0
  15. ownapi/cli.py +309 -0
  16. ownapi/sdk.py +436 -0
  17. ownapi-1.0.0.dist-info/METADATA +287 -0
  18. ownapi-1.0.0.dist-info/RECORD +62 -0
  19. ownapi-1.0.0.dist-info/WHEEL +5 -0
  20. ownapi-1.0.0.dist-info/entry_points.txt +4 -0
  21. ownapi-1.0.0.dist-info/top_level.txt +10 -0
  22. recorder.py +486 -0
  23. registry/coingecko/__init__.py +5 -0
  24. registry/coingecko/client.py +84 -0
  25. registry/coingecko/schema.json +23 -0
  26. registry/edevlet/__init__.py +5 -0
  27. registry/edevlet/client.py +54 -0
  28. registry/edevlet/schema.json +14 -0
  29. registry/enuygun/__init__.py +5 -0
  30. registry/enuygun/client.py +78 -0
  31. registry/enuygun/schema.json +13 -0
  32. registry/github_trending/__init__.py +5 -0
  33. registry/github_trending/client.py +90 -0
  34. registry/github_trending/schema.json +31 -0
  35. registry/hackernews/__init__.py +5 -0
  36. registry/hackernews/client.py +90 -0
  37. registry/hackernews/schema.json +38 -0
  38. registry/hepsiburada/__init__.py +5 -0
  39. registry/hepsiburada/client.py +32 -0
  40. registry/hepsiburada/schema.json +23 -0
  41. registry/linkedin_jobs/__init__.py +5 -0
  42. registry/linkedin_jobs/client.py +241 -0
  43. registry/linkedin_jobs/schema.json +46 -0
  44. registry/pegasus/__init__.py +5 -0
  45. registry/pegasus/client.py +141 -0
  46. registry/pegasus/schema.json +76 -0
  47. registry/thy/__init__.py +5 -0
  48. registry/thy/client.py +1796 -0
  49. registry/thy/schema.json +46 -0
  50. registry/trendyol/__init__.py +5 -0
  51. registry/trendyol/client.py +228 -0
  52. registry/trendyol/schema.json +30 -0
  53. registry/ucak_bileti/client.py +94 -0
  54. registry/ucak_bileti/schema.json +47 -0
  55. registry/wikipedia/__init__.py +5 -0
  56. registry/wikipedia/client.py +64 -0
  57. registry/wikipedia/schema.json +31 -0
  58. registry/yahoo_finance/__init__.py +5 -0
  59. registry/yahoo_finance/client.py +98 -0
  60. registry/yahoo_finance/schema.json +22 -0
  61. server.py +891 -0
  62. static/index.html +2401 -0
core/hitl_queue.py ADDED
@@ -0,0 +1,886 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import html
6
+ import json
7
+ import os
8
+ import time
9
+ import urllib.request
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from dotenv import load_dotenv
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+
20
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
21
+ HITL_STATE_FILE = PROJECT_ROOT / ".sessions" / "hitl_state.json"
22
+ PENDING_HITL_STATUSES = {
23
+ "PAUSED",
24
+ "PENDING_USER_ACTION",
25
+ "PENDING_USER_APPROVAL",
26
+ "APPROVED_TO_RESUME",
27
+ "RESUMING",
28
+ }
29
+
30
+ console = Console(stderr=True, legacy_windows=False)
31
+
32
+
33
+ class HITLQueueManager:
34
+ """
35
+ Human-in-the-Loop (HITL) Kalıcı Disk Tabanlı Onay ve Pause/Resume Yöneticisi.
36
+ WAF / Captcha / PerimeterX / Akamai / 2FA engeline takılan ajanların görevlerini
37
+ öldürmek yerine 'PAUSED' durumuna alır ve proje kök dizinindeki
38
+ `.sessions/hitl_state.json` dosyasına kalıcı olarak kaydeder.
39
+ Sayfa yenilense (F5) veya sunucu yeniden başlasa dahi bekleyen onaylar
40
+ `.sessions/hitl_state.json` üzerinden okunur; kullanıcı arayüzünden onay verildiğinde
41
+ ajan kaldığı yerden ('resume') çalışmaya devam eder.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ sessions_root: Path | str = PROJECT_ROOT / ".sessions",
47
+ webhook_url: str | None = None,
48
+ ) -> None:
49
+ load_dotenv(override=False)
50
+ self.sessions_root = Path(sessions_root)
51
+ self.sessions_root.mkdir(parents=True, exist_ok=True)
52
+ self.state_file = self.sessions_root / "hitl_state.json"
53
+ self.webhook_url = (webhook_url or os.getenv("OWNAPI_WEBHOOK_URL", "")).strip() or None
54
+ self._ensure_state_file()
55
+
56
+ @staticmethod
57
+ def _sanitize(value: str) -> str:
58
+ clean = "".join(c if (c.isalnum() or c in ("-", "_")) else "_" for c in str(value or "").strip())
59
+ return clean or "default"
60
+
61
+ @staticmethod
62
+ def is_pending_status(status: str | None) -> bool:
63
+ return str(status or "").strip().upper() in PENDING_HITL_STATUSES
64
+
65
+ def _queue_file_path(self, tenant_id: str = "default") -> Path:
66
+ t_dir = self.sessions_root / self._sanitize(tenant_id)
67
+ t_dir.mkdir(parents=True, exist_ok=True)
68
+ return t_dir / "hitl_queue.json"
69
+
70
+ def _ensure_state_file(self) -> None:
71
+ """
72
+ `.sessions/hitl_state.json` dosyası henüz yoksa mevcut kiracı kuyruklarını
73
+ (`.sessions/*/hitl_queue.json`) tarayarak kalıcı state dosyasını oluşturur.
74
+ """
75
+ if self.state_file.exists():
76
+ return
77
+ migrated_tasks: list[dict[str, Any]] = []
78
+ seen_ids: set[str] = set()
79
+ try:
80
+ for t_dir in sorted(self.sessions_root.iterdir()):
81
+ if not t_dir.is_dir():
82
+ continue
83
+ q_file = t_dir / "hitl_queue.json"
84
+ if not q_file.exists():
85
+ continue
86
+ try:
87
+ raw = json.loads(q_file.read_text(encoding="utf-8"))
88
+ for item in raw.get("tasks", []):
89
+ tid = str(item.get("task_id", "")).strip()
90
+ if tid and tid not in seen_ids:
91
+ seen_ids.add(tid)
92
+ migrated_tasks.append(item)
93
+ except Exception:
94
+ continue
95
+ except Exception:
96
+ pass
97
+ self._save_global_state(migrated_tasks)
98
+
99
+ def _load_global_state(self) -> dict[str, Any]:
100
+ """
101
+ Proje kök dizinindeki `.sessions/hitl_state.json` dosyasından tüm HITL görevlerini okur.
102
+ Kiracı klasörlerindeki `.sessions/{tenant_id}/hitl_queue.json` dosyalarında olup
103
+ henüz ana state dosyasına yazılmamış görevler varsa onları da birleştirir.
104
+ """
105
+ tasks_by_id: dict[str, dict[str, Any]] = {}
106
+ ordered_ids: list[str] = []
107
+
108
+ if self.state_file.exists():
109
+ try:
110
+ data = json.loads(self.state_file.read_text(encoding="utf-8"))
111
+ if isinstance(data, dict) and isinstance(data.get("tasks"), list):
112
+ for t in data["tasks"]:
113
+ if isinstance(t, dict) and t.get("task_id"):
114
+ tid = str(t["task_id"])
115
+ if tid not in tasks_by_id:
116
+ ordered_ids.append(tid)
117
+ tasks_by_id[tid] = t
118
+ except Exception:
119
+ pass
120
+
121
+ # Kiracı bazlı dosyalardaki görevleri de senkronize et
122
+ try:
123
+ for t_dir in sorted(self.sessions_root.iterdir()):
124
+ if not t_dir.is_dir():
125
+ continue
126
+ q_file = t_dir / "hitl_queue.json"
127
+ if not q_file.exists():
128
+ continue
129
+ try:
130
+ q_data = json.loads(q_file.read_text(encoding="utf-8"))
131
+ for t in q_data.get("tasks", []):
132
+ if isinstance(t, dict) and t.get("task_id"):
133
+ tid = str(t["task_id"])
134
+ if tid not in tasks_by_id:
135
+ ordered_ids.append(tid)
136
+ tasks_by_id[tid] = t
137
+ except Exception:
138
+ continue
139
+ except Exception:
140
+ pass
141
+
142
+ tasks = [self._normalize_task_fields(tasks_by_id[tid]) for tid in ordered_ids if tid in tasks_by_id]
143
+ pending_count = sum(1 for t in tasks if self.is_pending_status(t.get("status")))
144
+ completed_count = sum(1 for t in tasks if str(t.get("status", "")).upper() == "COMPLETED")
145
+ return {
146
+ "state_file": str(self.state_file),
147
+ "updated_at": datetime.now(timezone.utc).isoformat(),
148
+ "total_tasks": len(tasks),
149
+ "pending_count": pending_count,
150
+ "paused_count": pending_count,
151
+ "completed_count": completed_count,
152
+ "tasks": tasks,
153
+ }
154
+
155
+ def _resolve_execution_mode(self, tenant_id: str, explicit_mode: str | None = None) -> str:
156
+ """Görevin çalışma modunu ('local' veya 'cloud') belirler."""
157
+ cand = str(explicit_mode or "").strip().lower()
158
+ if cand in ("local", "cloud"):
159
+ return cand
160
+ env_mode = os.getenv("OWNAPI_BROWSER_MODE", "").strip().lower()
161
+ if env_mode in ("local", "cloud"):
162
+ return env_mode
163
+ try:
164
+ from core.api_keys import get_tenant_info
165
+ info = get_tenant_info(tenant_id)
166
+ t_mode = str(info.get("mode", "")).strip().lower()
167
+ if t_mode in ("local", "cloud"):
168
+ return t_mode
169
+ except Exception:
170
+ pass
171
+ return "local"
172
+
173
+ def _normalize_task_fields(self, task: dict[str, Any]) -> dict[str, Any]:
174
+ """Mevcut alanları bozmadan Cloud HITL önizleme ve OTP alanlarının varlığını garanti eder."""
175
+ tid = str(task.get("task_id", "")).strip()
176
+ exec_mode = str(task.get("execution_mode") or task.get("mode") or "local").strip().lower()
177
+ if exec_mode not in ("local", "cloud"):
178
+ exec_mode = "local"
179
+ task.setdefault("execution_mode", exec_mode)
180
+ task.setdefault("mode", exec_mode)
181
+ task.setdefault("screenshot_b64", None)
182
+ task.setdefault("cloud_preview_url", f"/api/hitl/preview/{tid}" if tid else None)
183
+ task.setdefault("otp_code", None)
184
+ return task
185
+
186
+ @staticmethod
187
+ async def capture_page_screenshot_b64(page: Any) -> str | None:
188
+ """
189
+ Açık olan Playwright `page` nesnesinden otomatik olarak ekran görüntüsü
190
+ (`await page.screenshot()`) alır ve base64 (`screenshot_b64`) olarak döndürür.
191
+ """
192
+ if page is None:
193
+ return None
194
+ try:
195
+ raw_bytes = await page.screenshot()
196
+ if isinstance(raw_bytes, (bytes, bytearray)) and raw_bytes:
197
+ return base64.b64encode(bytes(raw_bytes)).decode("ascii")
198
+ except Exception:
199
+ pass
200
+ return None
201
+
202
+ def _persist_screenshot_file(self, tenant_id: str, task_id: str, screenshot_b64: str | None) -> None:
203
+ """Varsa base64 ekran görüntüsünü `.sessions/{tenant_id}/{task_id}.png` olarak diske de kaydeder."""
204
+ if not screenshot_b64:
205
+ return
206
+ try:
207
+ clean_b64 = str(screenshot_b64).strip()
208
+ if "," in clean_b64 and clean_b64.startswith("data:image/"):
209
+ clean_b64 = clean_b64.split(",", 1)[1]
210
+ raw_bytes = base64.b64decode(clean_b64)
211
+ t_dir = self.sessions_root / self._sanitize(tenant_id)
212
+ t_dir.mkdir(parents=True, exist_ok=True)
213
+ (t_dir / f"{task_id}.png").write_bytes(raw_bytes)
214
+ except Exception:
215
+ pass
216
+
217
+ @staticmethod
218
+ def _build_default_preview_b64(task: dict[str, Any]) -> str:
219
+ """
220
+ Eğer görevde ham PNG ekran görüntüsü yoksa, uzak bulut tarayıcı durumunu
221
+ gösteren yüksek çözünürlüklü bir SVG ekran önizlemesini base64 olarak üretir.
222
+ """
223
+ task_id = html.escape(str(task.get("task_id") or "hitl_task"))
224
+ service = html.escape(str(task.get("service") or "service").upper())
225
+ exec_mode = html.escape(str(task.get("execution_mode") or task.get("mode") or "cloud").upper())
226
+ status = html.escape(str(task.get("status") or "PAUSED").upper())
227
+ target_url = html.escape(str(task.get("target_url") or task.get("url") or "https://")[0:78])
228
+ reason = html.escape(str(task.get("reason") or "WAF / Captcha / 2FA doğrulaması bekleniyor")[0:85])
229
+ updated = html.escape(str(task.get("updated_at") or task.get("created_at") or "")[:19].replace("T", " "))
230
+
231
+ svg = (
232
+ '<svg xmlns="http://www.w3.org/2000/svg" width="840" height="360" viewBox="0 0 840 360">'
233
+ '<defs>'
234
+ '<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">'
235
+ '<stop offset="0%" stop-color="#0b1020"/>'
236
+ '<stop offset="100%" stop-color="#111827"/>'
237
+ '</linearGradient>'
238
+ '</defs>'
239
+ '<rect width="840" height="360" rx="14" fill="url(#bg)" stroke="#334155" stroke-width="2"/>'
240
+ '<rect x="0" y="0" width="840" height="46" rx="14" fill="#1e293b"/>'
241
+ '<circle cx="24" cy="23" r="6" fill="#f43f5e"/>'
242
+ '<circle cx="44" cy="23" r="6" fill="#f59e0b"/>'
243
+ '<circle cx="64" cy="23" r="6" fill="#10b981"/>'
244
+ '<rect x="92" y="10" width="580" height="26" rx="6" fill="#0f172a" stroke="#334155"/>'
245
+ f'<text x="106" y="27" fill="#38bdf8" font-family="monospace" font-size="12">🔒 {target_url}</text>'
246
+ f'<rect x="688" y="10" width="134" height="26" rx="6" fill="#0e7490"/>'
247
+ f'<text x="702" y="27" fill="#ffffff" font-family="monospace" font-weight="bold" font-size="11">☁️ {exec_mode} CDP</text>'
248
+ '<rect x="48" y="74" width="744" height="248" rx="12" fill="#0f172a" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="6,4"/>'
249
+ f'<text x="80" y="116" fill="#fbbf24" font-family="sans-serif" font-weight="bold" font-size="18">🛡️ Uzak Bulut Tarayıcı Güvenlik Doğrulaması ({service})</text>'
250
+ f'<text x="80" y="148" fill="#e2e8f0" font-family="monospace" font-size="13">Görev ID: {task_id} | Durum: {status} | Zaman: {updated} UTC</text>'
251
+ f'<text x="80" y="182" fill="#cbd5e1" font-family="sans-serif" font-size="14">Neden: {reason}</text>'
252
+ '<rect x="80" y="210" width="360" height="64" rx="10" fill="#1e293b" stroke="#475569"/>'
253
+ '<rect x="100" y="230" width="24" height="24" rx="5" fill="#10b981"/>'
254
+ '<text x="106" y="247" fill="#ffffff" font-family="sans-serif" font-weight="bold" font-size="15">✓</text>'
255
+ '<text x="138" y="247" fill="#f8fafc" font-family="sans-serif" font-weight="bold" font-size="14">Ben insanım (Cloud Interactive Session)</text>'
256
+ '<text x="470" y="247" fill="#38bdf8" font-family="monospace" font-size="12">Web Dashboard uzerinden Resume bekleniyor...</text>'
257
+ '</svg>'
258
+ )
259
+ return base64.b64encode(svg.encode("utf-8")).decode("ascii")
260
+
261
+ def _save_global_state(self, tasks: list[dict[str, Any]]) -> None:
262
+ """
263
+ Tüm görevleri hem `.sessions/hitl_state.json` dosyasına hem de ilgili kiracıların
264
+ `.sessions/{tenant_id}/hitl_queue.json` dosyalarına kalıcı olarak yazar.
265
+ """
266
+ self.sessions_root.mkdir(parents=True, exist_ok=True)
267
+ now_iso = datetime.now(timezone.utc).isoformat()
268
+ pending_count = sum(1 for t in tasks if self.is_pending_status(t.get("status")))
269
+ completed_count = sum(1 for t in tasks if str(t.get("status", "")).upper() == "COMPLETED")
270
+
271
+ global_payload = {
272
+ "updated_at": now_iso,
273
+ "total_tasks": len(tasks),
274
+ "pending_count": pending_count,
275
+ "paused_count": pending_count,
276
+ "completed_count": completed_count,
277
+ "tasks": tasks,
278
+ }
279
+ tmp_file = self.state_file.with_suffix(".json.tmp")
280
+ try:
281
+ tmp_file.write_text(json.dumps(global_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
282
+ tmp_file.replace(self.state_file)
283
+ except Exception:
284
+ self.state_file.write_text(json.dumps(global_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
285
+
286
+ # Kiracı bazlı dosyaları da senkronize güncelle
287
+ by_tenant: dict[str, list[dict[str, Any]]] = {}
288
+ for t in tasks:
289
+ ten = self._sanitize(str(t.get("tenant_id", "default")))
290
+ by_tenant.setdefault(ten, []).append(t)
291
+
292
+ for ten, t_list in by_tenant.items():
293
+ q_file = self._queue_file_path(ten)
294
+ t_payload = {
295
+ "tenant_id": ten,
296
+ "updated_at": now_iso,
297
+ "total_tasks": len(t_list),
298
+ "pending_count": sum(1 for x in t_list if self.is_pending_status(x.get("status"))),
299
+ "tasks": t_list,
300
+ }
301
+ try:
302
+ q_file.write_text(json.dumps(t_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
303
+ except Exception:
304
+ pass
305
+
306
+ def _load_queue_data(self, tenant_id: str = "default") -> dict[str, Any]:
307
+ clean_tenant = self._sanitize(tenant_id)
308
+ global_data = self._load_global_state()
309
+ tenant_tasks = [
310
+ t for t in global_data.get("tasks", [])
311
+ if self._sanitize(str(t.get("tenant_id", "default"))) == clean_tenant
312
+ ]
313
+ return {"tenant_id": clean_tenant, "tasks": tenant_tasks}
314
+
315
+ def _save_queue_data(self, tenant_id: str, tasks: list[dict[str, Any]]) -> None:
316
+ clean_tenant = self._sanitize(tenant_id)
317
+ global_data = self._load_global_state()
318
+ other_tasks = [
319
+ t for t in global_data.get("tasks", [])
320
+ if self._sanitize(str(t.get("tenant_id", "default"))) != clean_tenant
321
+ ]
322
+ merged = other_tasks + tasks
323
+ self._save_global_state(merged)
324
+
325
+ def _resolve_session_target_url(self, service: str, url: str, target_url: str | None = None) -> str:
326
+ """
327
+ Tarayıcının direkt boş/404 API uç noktasına (/api/...) gitmesini önlemek için
328
+ her zaman servisin ana oturum sayfasını (örn. THY için https://www.turkishairlines.com/tr-tr/) döndürür.
329
+ """
330
+ clean_svc = self._sanitize(service).lower()
331
+ if clean_svc == "thy" or "turkishairlines.com" in (url or "").lower():
332
+ return "https://www.turkishairlines.com/tr-tr/"
333
+
334
+ from urllib.parse import urlparse
335
+
336
+ if target_url and target_url.rstrip("/") != (url or "").rstrip("/"):
337
+ parsed_t = urlparse(target_url)
338
+ if "/api/" not in parsed_t.path.lower():
339
+ return target_url
340
+
341
+ schema_file = PROJECT_ROOT / "registry" / clean_svc / "schema.json"
342
+ if schema_file.exists():
343
+ try:
344
+ s_data = json.loads(schema_file.read_text(encoding="utf-8"))
345
+ cand = str(s_data.get("target_url", "")).strip()
346
+ if cand and "/api/" not in urlparse(cand).path.lower():
347
+ return cand
348
+ except Exception:
349
+ pass
350
+
351
+ parsed_u = urlparse(url or "")
352
+ if parsed_u.scheme and parsed_u.netloc:
353
+ return f"{parsed_u.scheme}://{parsed_u.netloc}/"
354
+ return target_url or url
355
+
356
+ def enqueue_task(
357
+ self,
358
+ tenant_id: str,
359
+ service: str,
360
+ method: str,
361
+ url: str,
362
+ params: dict[str, Any] | None = None,
363
+ headers: dict[str, str] | None = None,
364
+ json_payload: Any = None,
365
+ target_url: str | None = None,
366
+ reason: str = "Captcha / WAF / 2FA doğrulaması gerekiyor",
367
+ in_place_paused: bool = False,
368
+ execution_mode: str | None = None,
369
+ mode: str | None = None,
370
+ screenshot_b64: str | None = None,
371
+ cloud_preview_url: str | None = None,
372
+ ) -> dict[str, Any]:
373
+ """
374
+ WAF/Captcha doğrulaması gerektiren ajan görevini öldürmek yerine 'PAUSED' (`PENDING_USER_ACTION`)
375
+ durumuna alarak `.sessions/hitl_state.json` dosyasına kalıcı olarak kaydeder.
376
+ """
377
+ clean_tenant = self._sanitize(tenant_id)
378
+ clean_svc = self._sanitize(service)
379
+ task_id = f"hitl_{uuid.uuid4().hex[:8]}"
380
+ now_iso = datetime.now(timezone.utc).isoformat()
381
+ resolved_mode = self._resolve_execution_mode(clean_tenant, execution_mode or mode)
382
+ resolved_preview_url = (cloud_preview_url or "").strip() or f"/api/hitl/preview/{task_id}"
383
+
384
+ resolved_target_url = self._resolve_session_target_url(clean_svc, url, target_url)
385
+ req_headers = dict(headers or {})
386
+ req_payload = json_payload
387
+
388
+ if clean_svc.lower() == "thy" or "turkishairlines.com" in (url or "").lower():
389
+ req_headers["Origin"] = "https://www.turkishairlines.com"
390
+ if not isinstance(req_payload, dict) or not req_payload.get("originDestinationInformationList"):
391
+ try:
392
+ from registry.thy.client import build_thy_availability_payload
393
+ req_payload = build_thy_availability_payload()
394
+ except Exception:
395
+ pass
396
+ try:
397
+ from registry.thy.client import build_thy_search_url_from_payload
398
+ cid_val = req_headers.get("x-client-id") or req_headers.get("x-clientid")
399
+ resolved_target_url = build_thy_search_url_from_payload(req_payload, cid=cid_val)
400
+ req_headers["Referer"] = resolved_target_url
401
+ req_headers["referer"] = resolved_target_url
402
+ except Exception:
403
+ req_headers["Referer"] = "https://www.turkishairlines.com/"
404
+
405
+ task_record: dict[str, Any] = {
406
+ "task_id": task_id,
407
+ "tenant_id": clean_tenant,
408
+ "service": clean_svc,
409
+ "method": method.upper(),
410
+ "url": url,
411
+ "target_url": resolved_target_url,
412
+ "params": params or {},
413
+ "headers": req_headers,
414
+ "json_payload": req_payload,
415
+ "reason": reason,
416
+ "status": "PAUSED",
417
+ "legacy_status": "PENDING_USER_ACTION",
418
+ "agent_state": "paused",
419
+ "in_place_paused": bool(in_place_paused),
420
+ "execution_mode": resolved_mode,
421
+ "mode": resolved_mode,
422
+ "screenshot_b64": screenshot_b64,
423
+ "cloud_preview_url": resolved_preview_url,
424
+ "otp_code": None,
425
+ "created_at": now_iso,
426
+ "paused_at": now_iso,
427
+ "updated_at": now_iso,
428
+ "approved_at": None,
429
+ "completed_at": None,
430
+ "webhook_notified": False,
431
+ "response": None,
432
+ }
433
+
434
+ state = self._load_global_state()
435
+ tasks: list[dict[str, Any]] = state.get("tasks", [])
436
+ tasks.append(task_record)
437
+
438
+ if self.webhook_url:
439
+ task_record["webhook_notified"] = self._send_webhook_notification(task_record)
440
+
441
+ self._persist_screenshot_file(clean_tenant, task_id, screenshot_b64)
442
+ self._save_global_state(tasks)
443
+
444
+ console.print(
445
+ Panel.fit(
446
+ f"[bold yellow]⏸️ Ajan Görevi PAUSED Durumuna Alındı (.sessions/hitl_state.json)![/bold yellow]\n"
447
+ f"[white]Görev ID (task_id):[/white] [bold cyan]{task_id}[/bold cyan]\n"
448
+ f"[white]Servis / Tenant:[/white] [magenta]{service}[/magenta] ([cyan]{clean_tenant}[/cyan])\n"
449
+ f"[white]Çalışma Modu:[/white] [bold cyan]{resolved_mode.upper()}[/bold cyan]\n"
450
+ f"[white]Neden:[/white] [yellow]{reason}[/yellow]\n"
451
+ f"[white]Durum:[/white] [bold red]PAUSED (PENDING_USER_ACTION)[/bold red]\n"
452
+ f"[dim]Web Dashboard üzerinden veya CLI ile onaylandığında kaldığı yerden devam eder:[/dim] [bold green]own-api resume {task_id}[/bold green]",
453
+ title="OwnAPI Human-in-the-Loop (HITL) Persistent State Store",
454
+ border_style="yellow",
455
+ )
456
+ )
457
+ return task_record
458
+
459
+ def update_task_state(
460
+ self,
461
+ task_id: str,
462
+ status: str,
463
+ response: Any = None,
464
+ extra_fields: dict[str, Any] | None = None,
465
+ screenshot_b64: str | None = None,
466
+ cloud_preview_url: str | None = None,
467
+ execution_mode: str | None = None,
468
+ otp_code: str | None = None,
469
+ ) -> dict[str, Any] | None:
470
+ """`.sessions/hitl_state.json` içindeki görevin durumunu ve yanıtını günceller."""
471
+ clean_id = task_id.strip()
472
+ state = self._load_global_state()
473
+ tasks: list[dict[str, Any]] = state.get("tasks", [])
474
+ now_iso = datetime.now(timezone.utc).isoformat()
475
+ updated_task: dict[str, Any] | None = None
476
+
477
+ for item in tasks:
478
+ if item.get("task_id") == clean_id:
479
+ item["status"] = status
480
+ item["updated_at"] = now_iso
481
+ if status.upper() == "COMPLETED":
482
+ item["agent_state"] = "resumed"
483
+ item["completed_at"] = now_iso
484
+ item["in_place_paused"] = False
485
+ elif status.upper() in ("APPROVED_TO_RESUME", "RESUMING"):
486
+ item["agent_state"] = "resuming"
487
+ item["approved_at"] = item.get("approved_at") or now_iso
488
+ elif self.is_pending_status(status):
489
+ item["agent_state"] = "paused"
490
+
491
+ if response is not None:
492
+ item["response"] = response
493
+ if isinstance(response, dict):
494
+ item["response_keys"] = list(response.keys())[:10]
495
+
496
+ if screenshot_b64 is not None:
497
+ item["screenshot_b64"] = screenshot_b64
498
+ self._persist_screenshot_file(str(item.get("tenant_id", "default")), clean_id, screenshot_b64)
499
+ if cloud_preview_url is not None:
500
+ item["cloud_preview_url"] = cloud_preview_url
501
+ if execution_mode is not None:
502
+ clean_exec = str(execution_mode).strip().lower()
503
+ if clean_exec in ("local", "cloud"):
504
+ item["execution_mode"] = clean_exec
505
+ item["mode"] = clean_exec
506
+ if otp_code is not None:
507
+ item["otp_code"] = str(otp_code).strip() or None
508
+
509
+ if extra_fields:
510
+ item.update(extra_fields)
511
+
512
+ self._normalize_task_fields(item)
513
+ updated_task = dict(item)
514
+ break
515
+
516
+ if updated_task is not None:
517
+ self._save_global_state(tasks)
518
+ return updated_task
519
+
520
+ def get_task_preview(self, task_id: str, tenant_id: str | None = None) -> dict[str, Any] | None:
521
+ """
522
+ Aktif veya duraklatılmış bir HITL görevi için bulut tarayıcı ekran görüntüsünü (`screenshot_b64`)
523
+ ve canlı durum önizleme verisini döndürür.
524
+ """
525
+ task, found_tenant = self.get_task(task_id, tenant_id=tenant_id)
526
+ if not task:
527
+ return None
528
+
529
+ normalized = self._normalize_task_fields(dict(task))
530
+ raw_b64 = normalized.get("screenshot_b64")
531
+ mime_type = "image/png"
532
+
533
+ # Diskte bu göreve ait kaydedilmiş bir ekran görüntüsü varsa oku
534
+ if not raw_b64:
535
+ shot_file = self.sessions_root / found_tenant / f"{normalized['task_id']}.png"
536
+ if shot_file.exists():
537
+ try:
538
+ raw_b64 = base64.b64encode(shot_file.read_bytes()).decode("ascii")
539
+ mime_type = "image/png"
540
+ except Exception:
541
+ raw_b64 = None
542
+
543
+ if raw_b64:
544
+ clean_b64 = str(raw_b64).strip()
545
+ if clean_b64.startswith("data:image/"):
546
+ data_url = clean_b64
547
+ if "," in clean_b64:
548
+ clean_b64 = clean_b64.split(",", 1)[1]
549
+ else:
550
+ # SVG mi PNG mi kontrol et
551
+ if clean_b64.startswith("PHN2Zy"):
552
+ mime_type = "image/svg+xml"
553
+ data_url = f"data:{mime_type};base64,{clean_b64}"
554
+ else:
555
+ clean_b64 = self._build_default_preview_b64(normalized)
556
+ mime_type = "image/svg+xml"
557
+ data_url = f"data:{mime_type};base64,{clean_b64}"
558
+
559
+ return {
560
+ "success": True,
561
+ "task_id": normalized["task_id"],
562
+ "tenant_id": found_tenant,
563
+ "service": normalized.get("service"),
564
+ "status": normalized.get("status", "PAUSED"),
565
+ "agent_state": normalized.get("agent_state", "paused"),
566
+ "execution_mode": normalized.get("execution_mode", "local"),
567
+ "mode": normalized.get("execution_mode", "local"),
568
+ "method": normalized.get("method", "GET"),
569
+ "url": normalized.get("url"),
570
+ "target_url": normalized.get("target_url") or normalized.get("url"),
571
+ "reason": normalized.get("reason"),
572
+ "screenshot_b64": clean_b64,
573
+ "screenshot_mime": mime_type,
574
+ "screenshot_data_url": data_url,
575
+ "cloud_preview_url": normalized.get("cloud_preview_url") or f"/api/hitl/preview/{normalized['task_id']}",
576
+ "has_live_screenshot": bool(normalized.get("screenshot_b64")),
577
+ "otp_code": normalized.get("otp_code"),
578
+ "created_at": normalized.get("created_at"),
579
+ "paused_at": normalized.get("paused_at"),
580
+ "updated_at": normalized.get("updated_at"),
581
+ }
582
+
583
+ async def await_task_resume(
584
+ self,
585
+ task_id: str,
586
+ poll_interval: float = 0.5,
587
+ timeout_sec: float = 300.0,
588
+ ) -> dict[str, Any] | None:
589
+ """
590
+ PAUSED durumuna alınan bir ajanın öldürülmeden `.sessions/hitl_state.json` dosyasını
591
+ dinleyerek kullanıcı onayını (`APPROVED_TO_RESUME` veya `COMPLETED`) beklemesini sağlar.
592
+ """
593
+ deadline = time.monotonic() + max(1.0, timeout_sec)
594
+ while time.monotonic() < deadline:
595
+ task, _ = self.get_task(task_id, tenant_id=None)
596
+ if not task:
597
+ return None
598
+ st = str(task.get("status", "")).upper()
599
+ if st in ("APPROVED_TO_RESUME", "RESUMING", "COMPLETED", "RESUMED"):
600
+ return task
601
+ await asyncio.sleep(poll_interval)
602
+ return None
603
+
604
+ def _send_webhook_notification(self, task_record: dict[str, Any]) -> bool:
605
+ """OWNAPI_WEBHOOK_URL tanımlıysa görevi JSON POST olarak bildirir."""
606
+ if not self.webhook_url:
607
+ return False
608
+ try:
609
+ webhook_payload = {
610
+ "event": "OWNAPI_HITL_PAUSED",
611
+ "task_id": task_record["task_id"],
612
+ "tenant_id": task_record["tenant_id"],
613
+ "service": task_record["service"],
614
+ "method": task_record["method"],
615
+ "url": task_record["url"],
616
+ "reason": task_record["reason"],
617
+ "status": task_record["status"],
618
+ "created_at": task_record["created_at"],
619
+ "resume_command": f"own-api resume {task_record['task_id']}",
620
+ }
621
+ data_bytes = json.dumps(webhook_payload, ensure_ascii=False).encode("utf-8")
622
+ req = urllib.request.Request(
623
+ self.webhook_url,
624
+ data=data_bytes,
625
+ headers={"Content-Type": "application/json", "User-Agent": "OwnAPI-HITL-Webhook/1.0"},
626
+ method="POST",
627
+ )
628
+ with urllib.request.urlopen(req, timeout=5.0) as resp:
629
+ return 200 <= resp.status < 300
630
+ except Exception as exc:
631
+ console.print(f"[dim yellow]⚠️ HITL Webhook bildirimi gönderilemedi ({exc})[/dim yellow]")
632
+ return False
633
+
634
+ def list_tasks(
635
+ self,
636
+ tenant_id: str | None = None,
637
+ status_filter: str | None = None,
638
+ ) -> list[dict[str, Any]]:
639
+ """
640
+ `.sessions/hitl_state.json` üzerinden bekleyen (`PAUSED` / `PENDING_USER_ACTION`)
641
+ ve tamamlanan (`COMPLETED`) görevleri listeler.
642
+ """
643
+ state = self._load_global_state()
644
+ all_tasks: list[dict[str, Any]] = list(state.get("tasks", []))
645
+
646
+ if tenant_id:
647
+ clean_tenant = self._sanitize(tenant_id)
648
+ all_tasks = [
649
+ t for t in all_tasks
650
+ if self._sanitize(str(t.get("tenant_id", "default"))) == clean_tenant
651
+ ]
652
+
653
+ if status_filter:
654
+ sf = status_filter.strip().upper()
655
+ if sf in ("PENDING", "PAUSED", "PENDING_USER_ACTION", "PENDING_USER_APPROVAL"):
656
+ all_tasks = [t for t in all_tasks if self.is_pending_status(t.get("status"))]
657
+ else:
658
+ all_tasks = [t for t in all_tasks if str(t.get("status", "")).upper() == sf]
659
+ return all_tasks
660
+
661
+ def get_pending_tasks(self, tenant_id: str | None = None) -> list[dict[str, Any]]:
662
+ """`.sessions/hitl_state.json` içindeki onay bekleyen (`PAUSED` / `PENDING_USER_ACTION`) görevleri döndürür."""
663
+ return self.list_tasks(tenant_id=tenant_id, status_filter="PAUSED")
664
+
665
+ def get_task(self, task_id: str, tenant_id: str | None = None) -> tuple[dict[str, Any] | None, str]:
666
+ """task_id ile görevi ve ait olduğu tenant_id'yi `.sessions/hitl_state.json` üzerinden bulur."""
667
+ clean_id = task_id.strip()
668
+ if tenant_id:
669
+ for t in self.list_tasks(tenant_id=tenant_id):
670
+ if t.get("task_id") == clean_id:
671
+ return t, self._sanitize(str(t.get("tenant_id", tenant_id)))
672
+ for t in self.list_tasks(tenant_id=None):
673
+ if t.get("task_id") == clean_id:
674
+ return t, self._sanitize(str(t.get("tenant_id", "default")))
675
+ return None, self._sanitize(tenant_id or "default")
676
+
677
+ def resume_task(
678
+ self,
679
+ task_id: str,
680
+ tenant_id: str | None = None,
681
+ otp_code: str | None = None,
682
+ ) -> dict[str, Any]:
683
+ """
684
+ Bekleyen (`PAUSED` / `PENDING_USER_ACTION`) bir görevi onaylar ve kaldığı yerden (`resume`) devam ettirir:
685
+ 1. Eğer ajan şu an bellekte/arka planda `in_place_paused` olarak `.sessions/hitl_state.json` dosyasını
686
+ dinliyorsa, durumu `APPROVED_TO_RESUME` yaparak (ve varsa `otp_code` bilgisini ileterek) ajanın kendi
687
+ oturumu içinde kaldığı yerden devam etmesini tetikler.
688
+ 2. Eğer ajan süreci kapanmış veya sayfa/sunucu yeniden başlatılmışsa, isteği `.sessions/hitl_state.json`
689
+ kaydından yeniden çalıştırarak tamamlar ve durumu `COMPLETED` olarak kaydeder.
690
+ """
691
+ task, found_tenant = self.get_task(task_id, tenant_id=tenant_id)
692
+ if not task:
693
+ raise ValueError(f"Görev bulunamadı: '{task_id}'")
694
+
695
+ clean_otp = str(otp_code or "").strip() or None
696
+
697
+ if str(task.get("status", "")).upper() == "COMPLETED" and task.get("response") is not None:
698
+ return {
699
+ "status": "COMPLETED",
700
+ "agent_state": "resumed",
701
+ "task_id": task["task_id"],
702
+ "service": task["service"],
703
+ "tenant_id": found_tenant,
704
+ "otp_code": clean_otp or task.get("otp_code"),
705
+ "completed_at": task.get("completed_at"),
706
+ "response": task.get("response"),
707
+ }
708
+
709
+ otp_info_line = (
710
+ f"\n[white]SMS / E-Posta Onay Kodu (OTP):[/white] [bold green]{clean_otp}[/bold green]"
711
+ if clean_otp
712
+ else ""
713
+ )
714
+ console.print(
715
+ Panel.fit(
716
+ f"[bold cyan]▶️ HITL Görevi Onaylandı — Kaldığı Yerden Devam Ettiriliyor (Resume)[/bold cyan]\n"
717
+ f"[white]Görev ID:[/white] [bold yellow]{task['task_id']}[/bold yellow]\n"
718
+ f"[white]Servis / Tenant:[/white] [magenta]{task['service']}[/magenta] ([cyan]{found_tenant}[/cyan])\n"
719
+ f"[white]Uç Nokta:[/white] [green]{task['method']} {task['url']}[/green]\n"
720
+ f"[white]Bekleme Nedeni:[/white] [yellow]{task.get('reason')}[/yellow]{otp_info_line}",
721
+ title="OwnAPI HITL Task Resumer",
722
+ border_style="cyan",
723
+ )
724
+ )
725
+
726
+ # 1. Durumu APPROVED_TO_RESUME olarak işaretle ve varsa otp_code'u kaydet (canlı bekleyen ajan varsa anında uyanır)
727
+ self.update_task_state(
728
+ task["task_id"],
729
+ status="APPROVED_TO_RESUME",
730
+ otp_code=clean_otp,
731
+ )
732
+
733
+ if task.get("in_place_paused"):
734
+ wait_deadline = time.monotonic() + 25.0
735
+ while time.monotonic() < wait_deadline:
736
+ fresh_task, _ = self.get_task(task["task_id"], tenant_id=found_tenant)
737
+ if fresh_task and str(fresh_task.get("status", "")).upper() == "COMPLETED":
738
+ console.print(
739
+ f"[bold green]✔ Duraklatılmış ajan '{task['task_id']}' onay sinyalini aldı ve kaldığı yerden tamamlandı![/bold green]"
740
+ )
741
+ return {
742
+ "status": "COMPLETED",
743
+ "agent_state": "resumed",
744
+ "task_id": fresh_task["task_id"],
745
+ "service": fresh_task["service"],
746
+ "tenant_id": found_tenant,
747
+ "otp_code": clean_otp or fresh_task.get("otp_code"),
748
+ "completed_at": fresh_task.get("completed_at"),
749
+ "response": fresh_task.get("response"),
750
+ }
751
+ time.sleep(0.4)
752
+
753
+ # 2. Ajan canlı beklemiyorsa veya devralınması gerekiyorsa doğrudan yürüt
754
+ from healer import SessionHealer
755
+
756
+ resolved_target_url = self._resolve_session_target_url(
757
+ task["service"],
758
+ task["url"],
759
+ task.get("target_url"),
760
+ )
761
+ req_headers = dict(task.get("headers") or {})
762
+ req_payload = task.get("json_payload")
763
+
764
+ if str(task.get("service", "")).lower() == "thy" or "turkishairlines.com" in str(task.get("url", "")).lower():
765
+ req_headers["Origin"] = "https://www.turkishairlines.com"
766
+ if not isinstance(req_payload, dict) or not req_payload.get("originDestinationInformationList"):
767
+ try:
768
+ from registry.thy.client import build_thy_availability_payload
769
+ req_payload = build_thy_availability_payload()
770
+ except Exception:
771
+ pass
772
+ try:
773
+ from registry.thy.client import build_thy_search_url_from_payload
774
+ cid_val = req_headers.get("x-client-id") or req_headers.get("x-clientid")
775
+ resolved_target_url = build_thy_search_url_from_payload(req_payload, cid=cid_val)
776
+ req_headers["Referer"] = resolved_target_url
777
+ req_headers["referer"] = resolved_target_url
778
+ except Exception:
779
+ req_headers["Referer"] = "https://www.turkishairlines.com/"
780
+
781
+ self.update_task_state(task["task_id"], status="RESUMING", otp_code=clean_otp)
782
+
783
+ prev_resuming = os.environ.get("OWNAPI_HITL_RESUMING")
784
+ prev_otp = os.environ.get("OWNAPI_HITL_OTP_CODE")
785
+ os.environ["OWNAPI_HITL_RESUMING"] = "1"
786
+ if clean_otp:
787
+ os.environ["OWNAPI_HITL_OTP_CODE"] = clean_otp
788
+ else:
789
+ os.environ.pop("OWNAPI_HITL_OTP_CODE", None)
790
+ try:
791
+ healer = SessionHealer(
792
+ target_url=resolved_target_url,
793
+ tenant_id=found_tenant,
794
+ service=task["service"],
795
+ mode=str(task.get("mode") or "local"),
796
+ non_interactive=False,
797
+ )
798
+
799
+ response = healer.execute_in_browser(
800
+ method=task["method"],
801
+ url=task["url"],
802
+ headers=req_headers,
803
+ params=task.get("params") or {},
804
+ json_payload=req_payload,
805
+ target_url=resolved_target_url,
806
+ tenant_id=found_tenant,
807
+ mode=str(task.get("mode") or "local"),
808
+ non_interactive=False,
809
+ )
810
+ finally:
811
+ if prev_resuming is None:
812
+ os.environ.pop("OWNAPI_HITL_RESUMING", None)
813
+ else:
814
+ os.environ["OWNAPI_HITL_RESUMING"] = prev_resuming
815
+ if prev_otp is None:
816
+ os.environ.pop("OWNAPI_HITL_OTP_CODE", None)
817
+ else:
818
+ os.environ["OWNAPI_HITL_OTP_CODE"] = prev_otp
819
+
820
+ updated = self.update_task_state(
821
+ task["task_id"],
822
+ status="COMPLETED",
823
+ response=response,
824
+ otp_code=clean_otp,
825
+ )
826
+ now_iso = (updated or {}).get("completed_at") or datetime.now(timezone.utc).isoformat()
827
+
828
+ console.print(
829
+ f"[bold green]✔ Görev '{task['task_id']}' başarıyla onaylandı ve kaldığı yerden tamamlandı (COMPLETED)![/bold green]"
830
+ )
831
+ return {
832
+ "status": "COMPLETED",
833
+ "agent_state": "resumed",
834
+ "task_id": task["task_id"],
835
+ "service": task["service"],
836
+ "tenant_id": found_tenant,
837
+ "otp_code": clean_otp or (updated or {}).get("otp_code"),
838
+ "completed_at": now_iso,
839
+ "response": response,
840
+ }
841
+
842
+ def print_queue_table(self, tenant_id: str | None = None, status_filter: str | None = None) -> None:
843
+ """Kuyruktaki bekleyen ve tamamlanan görevleri Rich tablosu olarak ekrana basar."""
844
+ tasks = self.list_tasks(tenant_id=tenant_id, status_filter=status_filter)
845
+ if not tasks:
846
+ console.print("[yellow]HITL onay kuyruğunda (.sessions/hitl_state.json) kayıtlı görev bulunmuyor.[/yellow]")
847
+ return
848
+
849
+ table = Table(
850
+ title=f"🛡️ OwnAPI Human-in-the-Loop (HITL) Onay Kuyruğu ({len(tasks)} Görev)",
851
+ show_header=True,
852
+ header_style="bold magenta",
853
+ show_lines=True,
854
+ )
855
+ table.add_column("Task ID", style="bold cyan", no_wrap=True)
856
+ table.add_column("Tenant / Servis", style="bold yellow")
857
+ table.add_column("Metot & URL", style="white", overflow="fold", ratio=2)
858
+ table.add_column("Neden", style="yellow", overflow="fold", ratio=1)
859
+ table.add_column("Durum", justify="center")
860
+ table.add_column("Oluşturulma / Tamamlanma", style="dim")
861
+
862
+ for t in tasks:
863
+ st = str(t.get("status", "PAUSED")).upper()
864
+ if self.is_pending_status(st):
865
+ badge = f"[bold red]⏸️ {st}[/bold red]"
866
+ elif st == "COMPLETED":
867
+ badge = "[bold green]✔ COMPLETED[/bold green]"
868
+ else:
869
+ badge = f"[yellow]{st}[/yellow]"
870
+
871
+ c_at = str(t.get("created_at", ""))[:19].replace("T", " ")
872
+ d_at = str(t.get("completed_at") or "")[:19].replace("T", " ")
873
+ time_str = f"Oluş: {c_at}" + (f"\nBitiş: {d_at}" if d_at else "")
874
+
875
+ table.add_row(
876
+ str(t.get("task_id")),
877
+ f"{t.get('tenant_id', 'default')} / [bold]{t.get('service')}[/bold]",
878
+ f"[cyan]{t.get('method')}[/cyan] {t.get('url')}",
879
+ str(t.get("reason", "-")),
880
+ badge,
881
+ time_str,
882
+ )
883
+
884
+ console.print()
885
+ console.print(table)
886
+ console.print()