answer42 0.2.30__tar.gz → 0.2.31__tar.gz
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.
- {answer42-0.2.30 → answer42-0.2.31}/PKG-INFO +1 -1
- {answer42-0.2.30 → answer42-0.2.31}/pyproject.toml +1 -1
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/server.py +406 -8
- {answer42-0.2.30 → answer42-0.2.31}/.gitignore +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/LICENSE +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/README.md +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/credentials.example.json +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/docs/agent-installation.md +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/docs/architecture.md +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/docs/assets/answer42-logo.png +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/docs/assets/platform42-logo.svg +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/docs/installation.md +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/scripts/build_cf.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/scripts/build_pages.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/scripts/e2e_stable.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/scripts/openclaw_mcp_autoreload.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/scripts/rag_cli.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/cf/ConfigDumpInfo.xml +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/cf/Configuration.xml +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form/Module.bsl" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form.xml" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260.xml" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager.xml +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/cf/Ext/ManagedApplicationModule.bsl +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Catalogs//320/237/320/241_/320/222/320/273/320/260/320/264/320/265/320/273/320/265/321/206.xml" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Catalogs//320/237/320/241_/320/241/320/277/321/200/320/260/320/262/320/276/321/207/320/275/320/270/320/2721.xml" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/ConfigDumpInfo.xml +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Configuration.xml +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Ext/ManagerModule.bsl" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Ext/ObjectModule.bsl" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Templates//320/236/321/201/320/275/320/276/320/262/320/275/320/260/321/217/320/241/321/205/320/265/320/274/320/260/320/232/320/276/320/274/320/277/320/276/320/275/320/276/320/262/320/272/320/270/320/224/320/260/320/275/320/275/321/213/321/205/Ext/Template.xml" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Templates//320/236/321/201/320/275/320/276/320/262/320/275/320/260/321/217/320/241/321/205/320/265/320/274/320/260/320/232/320/276/320/274/320/277/320/276/320/275/320/276/320/262/320/272/320/270/320/224/320/260/320/275/320/275/321/213/321/205.xml" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262.xml" +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/__init__.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/assets/MCPTestClient.cf +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/assets/MCPTestManager.cf +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/assets/__init__.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/assets/skills/answer42/SKILL.md +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/assets/skills/answer42-rag/SKILL.md +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/bridge.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/credentials.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/os_support.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/platform.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/protocol.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/__init__.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/detect.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/model.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/parsers.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/service.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/rag/store.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/recorder.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/release_helper.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/runtime.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/skill_installer.py +0 -0
- {answer42-0.2.30 → answer42-0.2.31}/src/mcp_1c/window_control.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: answer42
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.31
|
|
4
4
|
Summary: Answer42 — The Answer to Life, Universe, and 1C — UI Driver. MCP-powered 1C:Enterprise UI automation: click, fill, navigate, test, and introspect managed forms through the test-client API
|
|
5
5
|
Author: Marvin (AI Assistant), 42Clouds, and contributors
|
|
6
6
|
Author-email: "Kosolapov Stanislav (proDOOMman)" <prodoomman@gmail.com>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "answer42"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.31"
|
|
4
4
|
description = "Answer42 — The Answer to Life, Universe, and 1C — UI Driver. MCP-powered 1C:Enterprise UI automation: click, fill, navigate, test, and introspect managed forms through the test-client API"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -45,7 +45,14 @@ _RAG_ENABLED = os.getenv("ONEC_MCP_DISABLE_RAG", "").strip().lower() not in {"1"
|
|
|
45
45
|
_CREDENTIAL_STORE_ENABLED = os.getenv("ONEC_MCP_DISABLE_CREDENTIAL_STORE", "").strip().lower() not in {"1", "true", "yes", "on"}
|
|
46
46
|
_DEV_TOOLS_ENABLED = os.getenv("ONEC_MCP_DEV_TOOLS", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
47
47
|
DEFAULT_SESSION = os.getenv("ONEC_MCP_DEFAULT_SESSION", f"default-{os.getpid()}")
|
|
48
|
+
SESSION_SCOPE_ID = os.getenv("ONEC_MCP_SESSION_SCOPE_ID") or os.getenv("ONEC_MCP_SESSION_SCOPE") or ""
|
|
48
49
|
STATE_FILE = os_support.runtime_path("ONEC_MCP_SESSION_STATE", "sessions.json")
|
|
50
|
+
SCOPE_DIR: Path | None = None
|
|
51
|
+
SCOPE_LOCK_FILE: Path | None = None
|
|
52
|
+
_SCOPE_LOCK_HANDLE: Any | None = None
|
|
53
|
+
GLOBAL_REGISTRY_FILE = os_support.runtime_path("ONEC_MCP_GLOBAL_SESSION_REGISTRY", "global-sessions.json")
|
|
54
|
+
GLOBAL_REGISTRY_LOCK = os_support.runtime_path("ONEC_MCP_GLOBAL_SESSION_REGISTRY_LOCK", "global-sessions.lock")
|
|
55
|
+
GLOBAL_ORPHAN_TTL_SECONDS = int(os.getenv("ONEC_MCP_ORPHAN_TTL_SECONDS", "1800"))
|
|
49
56
|
AUTORELOAD_PID_FILE = os_support.runtime_path("ONEC_MCP_AUTORELOAD_PID_FILE", "answer42-autoreload.pid")
|
|
50
57
|
AUTORELOAD_LOG_FILE = os_support.runtime_path("ONEC_MCP_AUTORELOAD_LOG_FILE", "answer42-autoreload.log")
|
|
51
58
|
RAG_BINDINGS_FILE = os_support.runtime_path("ONEC_MCP_RAG_BINDINGS", "rag-bindings.json")
|
|
@@ -387,6 +394,11 @@ class SessionState:
|
|
|
387
394
|
self.client_xvfb_process: subprocess.Popen | None = None
|
|
388
395
|
self.runtime: runtime.ManagerRuntime | None = None
|
|
389
396
|
self.test_client_process: subprocess.Popen | None = None
|
|
397
|
+
self.manager_pid: int = 0
|
|
398
|
+
self.ibsrv_pid: int = 0
|
|
399
|
+
self.test_client_pid: int = 0
|
|
400
|
+
self.xvfb_pid: int = 0
|
|
401
|
+
self.client_xvfb_pid: int = 0
|
|
390
402
|
self.bridge = OneCBridgeServer(
|
|
391
403
|
host="127.0.0.1",
|
|
392
404
|
port=ws_port,
|
|
@@ -418,6 +430,11 @@ class SessionState:
|
|
|
418
430
|
"rag_snapshot": self.rag_snapshot,
|
|
419
431
|
"rag_enabled": self.rag_enabled,
|
|
420
432
|
"idle_timeout_seconds": self.idle_timeout_seconds,
|
|
433
|
+
"manager_pid": self.runtime.manager_process.pid if self.runtime and self.runtime.manager_process and self.runtime.manager_process.poll() is None else self.manager_pid,
|
|
434
|
+
"ibsrv_pid": self.runtime.server_process.pid if self.runtime and self.runtime.server_process and self.runtime.server_process.poll() is None else self.ibsrv_pid,
|
|
435
|
+
"test_client_pid": self.test_client_process.pid if self.test_client_process and self.test_client_process.poll() is None else self.test_client_pid,
|
|
436
|
+
"xvfb_pid": self.xvfb_process.pid if self.xvfb_process and self.xvfb_process.poll() is None else self.xvfb_pid,
|
|
437
|
+
"client_xvfb_pid": self.client_xvfb_process.pid if self.client_xvfb_process and self.client_xvfb_process.poll() is None else self.client_xvfb_pid,
|
|
421
438
|
}
|
|
422
439
|
|
|
423
440
|
@classmethod
|
|
@@ -441,6 +458,11 @@ class SessionState:
|
|
|
441
458
|
sess.client_display = str(data.get("client_display", ""))
|
|
442
459
|
sess.rag_enabled = bool(data.get("rag_enabled", True))
|
|
443
460
|
sess.idle_timeout_seconds = int(data.get("idle_timeout_seconds") or 60 * 60)
|
|
461
|
+
sess.manager_pid = int(data.get("manager_pid") or 0)
|
|
462
|
+
sess.ibsrv_pid = int(data.get("ibsrv_pid") or 0)
|
|
463
|
+
sess.test_client_pid = int(data.get("test_client_pid") or 0)
|
|
464
|
+
sess.xvfb_pid = int(data.get("xvfb_pid") or 0)
|
|
465
|
+
sess.client_xvfb_pid = int(data.get("client_xvfb_pid") or 0)
|
|
444
466
|
return sess
|
|
445
467
|
|
|
446
468
|
@property
|
|
@@ -503,6 +525,211 @@ def _pid_is_running(pid: int) -> bool:
|
|
|
503
525
|
return True
|
|
504
526
|
|
|
505
527
|
|
|
528
|
+
def _sanitize_scope_id(scope_id: str) -> str:
|
|
529
|
+
value = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(scope_id or "").strip())
|
|
530
|
+
value = value.strip(".-")
|
|
531
|
+
if not value:
|
|
532
|
+
raise ValueError("Answer42 session scope id must contain at least one safe character")
|
|
533
|
+
return value[:128]
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _state_file_for_scope(scope_id: str) -> tuple[Path, Path, Path]:
|
|
537
|
+
safe = _sanitize_scope_id(scope_id)
|
|
538
|
+
root = Path(os.getenv("ONEC_MCP_SESSION_SCOPES_DIR", str(os_support.default_data_dir() / "session-scopes")))
|
|
539
|
+
scope_dir = root / safe
|
|
540
|
+
return scope_dir, scope_dir / "sessions.json", scope_dir / "answer42.lock"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _configure_session_scope(scope_id: str) -> None:
|
|
544
|
+
"""Switch Answer42 from legacy global state to scoped lock/reconnect mode."""
|
|
545
|
+
global SESSION_SCOPE_ID, STATE_FILE, SCOPE_DIR, SCOPE_LOCK_FILE
|
|
546
|
+
SESSION_SCOPE_ID = str(scope_id or "").strip()
|
|
547
|
+
if not SESSION_SCOPE_ID:
|
|
548
|
+
SCOPE_DIR = None
|
|
549
|
+
SCOPE_LOCK_FILE = None
|
|
550
|
+
STATE_FILE = os_support.runtime_path("ONEC_MCP_SESSION_STATE", "sessions.json")
|
|
551
|
+
return
|
|
552
|
+
SCOPE_DIR, STATE_FILE, SCOPE_LOCK_FILE = _state_file_for_scope(SESSION_SCOPE_ID)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _acquire_scope_lock_if_configured() -> bool:
|
|
556
|
+
"""Acquire the configured scope lock. Return True when a stale lock existed."""
|
|
557
|
+
global _SCOPE_LOCK_HANDLE
|
|
558
|
+
if not SCOPE_LOCK_FILE:
|
|
559
|
+
return False
|
|
560
|
+
SCOPE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
561
|
+
stale = SCOPE_LOCK_FILE.exists()
|
|
562
|
+
handle = SCOPE_LOCK_FILE.open("a+", encoding="utf-8")
|
|
563
|
+
if os_support.IS_WINDOWS:
|
|
564
|
+
import msvcrt # type: ignore
|
|
565
|
+
|
|
566
|
+
try:
|
|
567
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
568
|
+
except OSError as exc:
|
|
569
|
+
handle.close()
|
|
570
|
+
raise RuntimeError(f"Answer42 session scope {SESSION_SCOPE_ID!r} is locked by another live process") from exc
|
|
571
|
+
else:
|
|
572
|
+
import fcntl # type: ignore
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
576
|
+
except BlockingIOError as exc:
|
|
577
|
+
handle.close()
|
|
578
|
+
raise RuntimeError(f"Answer42 session scope {SESSION_SCOPE_ID!r} is locked by another live process") from exc
|
|
579
|
+
handle.seek(0)
|
|
580
|
+
handle.truncate()
|
|
581
|
+
handle.write(json.dumps({"pid": os.getpid(), "scope_id": SESSION_SCOPE_ID, "started_at": datetime.utcnow().isoformat() + "Z"}))
|
|
582
|
+
handle.flush()
|
|
583
|
+
_SCOPE_LOCK_HANDLE = handle
|
|
584
|
+
return stale
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _release_scope_lock() -> None:
|
|
588
|
+
global _SCOPE_LOCK_HANDLE
|
|
589
|
+
if _SCOPE_LOCK_HANDLE is None:
|
|
590
|
+
return
|
|
591
|
+
handle = _SCOPE_LOCK_HANDLE
|
|
592
|
+
_SCOPE_LOCK_HANDLE = None
|
|
593
|
+
try:
|
|
594
|
+
if os_support.IS_WINDOWS:
|
|
595
|
+
import msvcrt # type: ignore
|
|
596
|
+
|
|
597
|
+
handle.seek(0)
|
|
598
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
599
|
+
else:
|
|
600
|
+
import fcntl # type: ignore
|
|
601
|
+
|
|
602
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
603
|
+
finally:
|
|
604
|
+
handle.close()
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _load_global_registry_unlocked() -> dict[str, Any]:
|
|
608
|
+
if not GLOBAL_REGISTRY_FILE.exists():
|
|
609
|
+
return {"sessions": {}}
|
|
610
|
+
try:
|
|
611
|
+
raw = json.loads(GLOBAL_REGISTRY_FILE.read_text(encoding="utf-8"))
|
|
612
|
+
if isinstance(raw, dict):
|
|
613
|
+
raw.setdefault("sessions", {})
|
|
614
|
+
if isinstance(raw["sessions"], dict):
|
|
615
|
+
return raw
|
|
616
|
+
except Exception as exc:
|
|
617
|
+
logging.warning("Could not read Answer42 global session registry %s: %s", GLOBAL_REGISTRY_FILE, exc)
|
|
618
|
+
return {"sessions": {}}
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _write_global_registry_unlocked(data: dict[str, Any]) -> None:
|
|
622
|
+
GLOBAL_REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
623
|
+
tmp = GLOBAL_REGISTRY_FILE.with_suffix(GLOBAL_REGISTRY_FILE.suffix + ".tmp")
|
|
624
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
|
|
625
|
+
tmp.replace(GLOBAL_REGISTRY_FILE)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _session_registry_key(sess: SessionState) -> str:
|
|
629
|
+
scope = _sanitize_scope_id(SESSION_SCOPE_ID) if SESSION_SCOPE_ID else "legacy"
|
|
630
|
+
return f"{scope}:{sess.session_id}"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _session_global_registry_record(sess: SessionState, *, status: str = "active") -> dict[str, Any]:
|
|
634
|
+
pids = {
|
|
635
|
+
"manager_pid": sess.runtime.manager_process.pid if sess.runtime and sess.runtime.manager_process and sess.runtime.manager_process.poll() is None else sess.manager_pid,
|
|
636
|
+
"ibsrv_pid": sess.runtime.server_process.pid if sess.runtime and sess.runtime.server_process and sess.runtime.server_process.poll() is None else sess.ibsrv_pid,
|
|
637
|
+
"test_client_pid": sess.test_client_process.pid if sess.test_client_process and sess.test_client_process.poll() is None else sess.test_client_pid,
|
|
638
|
+
"xvfb_pid": sess.xvfb_process.pid if sess.xvfb_process and sess.xvfb_process.poll() is None else sess.xvfb_pid,
|
|
639
|
+
"client_xvfb_pid": sess.client_xvfb_process.pid if sess.client_xvfb_process and sess.client_xvfb_process.poll() is None else sess.client_xvfb_pid,
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
"scope_id": SESSION_SCOPE_ID,
|
|
643
|
+
"session_id": sess.session_id,
|
|
644
|
+
"owner_pid": os.getpid(),
|
|
645
|
+
"status": status,
|
|
646
|
+
"updated_at": time.time(),
|
|
647
|
+
"base_url": sess.base_url,
|
|
648
|
+
"state_file": str(STATE_FILE),
|
|
649
|
+
"data_dir": str(sess.data_dir),
|
|
650
|
+
**pids,
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _update_global_session_registry(sess: SessionState, *, status: str = "active") -> None:
|
|
655
|
+
with os_support.file_lock(GLOBAL_REGISTRY_LOCK):
|
|
656
|
+
data = _load_global_registry_unlocked()
|
|
657
|
+
data["sessions"][_session_registry_key(sess)] = _session_global_registry_record(sess, status=status)
|
|
658
|
+
_write_global_registry_unlocked(data)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _remove_global_session_registry(sess: SessionState) -> None:
|
|
662
|
+
with os_support.file_lock(GLOBAL_REGISTRY_LOCK):
|
|
663
|
+
data = _load_global_registry_unlocked()
|
|
664
|
+
data.get("sessions", {}).pop(_session_registry_key(sess), None)
|
|
665
|
+
_write_global_registry_unlocked(data)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _mark_global_sessions_orphaned_for_owner(owner_pid: int | None = None) -> None:
|
|
669
|
+
owner = int(owner_pid or os.getpid())
|
|
670
|
+
now = time.time()
|
|
671
|
+
with os_support.file_lock(GLOBAL_REGISTRY_LOCK):
|
|
672
|
+
data = _load_global_registry_unlocked()
|
|
673
|
+
changed = False
|
|
674
|
+
for rec in data.get("sessions", {}).values():
|
|
675
|
+
if int(rec.get("owner_pid") or 0) != owner:
|
|
676
|
+
continue
|
|
677
|
+
rec["status"] = "orphaned"
|
|
678
|
+
rec["orphaned_at"] = now
|
|
679
|
+
rec["updated_at"] = now
|
|
680
|
+
changed = True
|
|
681
|
+
if changed:
|
|
682
|
+
_write_global_registry_unlocked(data)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _registry_process_pids(rec: dict[str, Any]) -> list[int]:
|
|
686
|
+
result: list[int] = []
|
|
687
|
+
for name in ("manager_pid", "ibsrv_pid", "test_client_pid", "xvfb_pid", "client_xvfb_pid"):
|
|
688
|
+
try:
|
|
689
|
+
pid = int(rec.get(name) or 0)
|
|
690
|
+
except Exception:
|
|
691
|
+
pid = 0
|
|
692
|
+
if pid > 0 and pid not in result:
|
|
693
|
+
result.append(pid)
|
|
694
|
+
return result
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _reap_global_orphan_sessions(ttl_seconds: int = GLOBAL_ORPHAN_TTL_SECONDS) -> dict[str, Any]:
|
|
698
|
+
"""Kill 1C processes recorded for dead Answer42 owners after the TTL."""
|
|
699
|
+
now = time.time()
|
|
700
|
+
reaped: list[str] = []
|
|
701
|
+
orphaned: list[str] = []
|
|
702
|
+
with os_support.file_lock(GLOBAL_REGISTRY_LOCK):
|
|
703
|
+
data = _load_global_registry_unlocked()
|
|
704
|
+
sessions = data.get("sessions", {})
|
|
705
|
+
for key, rec in list(sessions.items()):
|
|
706
|
+
owner_pid = int(rec.get("owner_pid") or 0)
|
|
707
|
+
owner_alive = _pid_is_running(owner_pid)
|
|
708
|
+
if owner_alive and rec.get("status") == "active":
|
|
709
|
+
continue
|
|
710
|
+
if not owner_alive and rec.get("status") == "active":
|
|
711
|
+
rec["status"] = "orphaned"
|
|
712
|
+
rec["orphaned_at"] = now
|
|
713
|
+
rec["updated_at"] = now
|
|
714
|
+
orphaned.append(key)
|
|
715
|
+
continue
|
|
716
|
+
if rec.get("status") != "orphaned":
|
|
717
|
+
continue
|
|
718
|
+
orphaned_at = float(rec.get("orphaned_at") or rec.get("updated_at") or now)
|
|
719
|
+
if now - orphaned_at < ttl_seconds:
|
|
720
|
+
continue
|
|
721
|
+
for pid in _registry_process_pids(rec):
|
|
722
|
+
_kill_pid_group(pid, f"orphaned Answer42 session {key}")
|
|
723
|
+
sessions.pop(key, None)
|
|
724
|
+
reaped.append(key)
|
|
725
|
+
_write_global_registry_unlocked(data)
|
|
726
|
+
return {"orphaned": orphaned, "reaped": reaped, "ttl_seconds": ttl_seconds}
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
if SESSION_SCOPE_ID:
|
|
730
|
+
_configure_session_scope(SESSION_SCOPE_ID)
|
|
731
|
+
|
|
732
|
+
|
|
506
733
|
def _start_autoreload_watcher(dev: str = "", dev_restart_timeout: float = 30.0) -> None:
|
|
507
734
|
"""Start a detached dev autoreload watcher once per host.
|
|
508
735
|
|
|
@@ -568,6 +795,8 @@ def _persist_sessions() -> None:
|
|
|
568
795
|
json.dumps({sid: sess.to_json() for sid, sess in _SESSIONS.items()}, ensure_ascii=False, indent=2),
|
|
569
796
|
encoding="utf-8",
|
|
570
797
|
)
|
|
798
|
+
for sess in _SESSIONS.values():
|
|
799
|
+
_update_global_session_registry(sess)
|
|
571
800
|
|
|
572
801
|
|
|
573
802
|
def _source_newer_than(target: Path, src_dir: Path) -> bool:
|
|
@@ -800,6 +1029,132 @@ def _cleanup_stale_session_processes(sess: SessionState, reason: str) -> None:
|
|
|
800
1029
|
)
|
|
801
1030
|
|
|
802
1031
|
|
|
1032
|
+
def _kill_persisted_session_processes(sess: SessionState, reason: str, *, include_test_client: bool) -> None:
|
|
1033
|
+
logging.info("Cleaning persisted session %s processes: %s", sess.session_id, reason)
|
|
1034
|
+
for pid, label in (
|
|
1035
|
+
(sess.manager_pid, "test-manager"),
|
|
1036
|
+
(sess.ibsrv_pid, "ibsrv"),
|
|
1037
|
+
(sess.xvfb_pid, "xvfb"),
|
|
1038
|
+
(sess.client_xvfb_pid, "client-xvfb"),
|
|
1039
|
+
):
|
|
1040
|
+
_kill_pid_group(pid, f"{label} for Answer42 session {sess.session_id}")
|
|
1041
|
+
if include_test_client:
|
|
1042
|
+
_kill_pid_group(sess.test_client_pid, f"test-client for Answer42 session {sess.session_id}")
|
|
1043
|
+
_cleanup_stale_session_processes(sess, reason)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
async def _reconnect_persisted_session(sess: SessionState) -> bool:
|
|
1047
|
+
"""Reconnect a scoped session after MCP process restart.
|
|
1048
|
+
|
|
1049
|
+
If the test client is gone, the session is considered finished. If the
|
|
1050
|
+
client is alive and the manager ibsrv is still reachable, restart only the
|
|
1051
|
+
test-manager process and attach it to the existing test client port.
|
|
1052
|
+
"""
|
|
1053
|
+
if not _pid_is_running(sess.test_client_pid):
|
|
1054
|
+
_kill_persisted_session_processes(sess, "test client is not running during scoped reconnect", include_test_client=False)
|
|
1055
|
+
return False
|
|
1056
|
+
if sess.ibsrv_pid and not _pid_is_running(sess.ibsrv_pid):
|
|
1057
|
+
_kill_persisted_session_processes(sess, "manager ibsrv is not running during scoped reconnect", include_test_client=True)
|
|
1058
|
+
return False
|
|
1059
|
+
if sess.ibsrv_port and not runtime.is_port_listening("127.0.0.1", sess.ibsrv_port):
|
|
1060
|
+
_kill_persisted_session_processes(sess, "manager ibsrv port is not listening during scoped reconnect", include_test_client=True)
|
|
1061
|
+
return False
|
|
1062
|
+
|
|
1063
|
+
# Old manager was connected to a dead websocket URL. Kill it before
|
|
1064
|
+
# starting a manager that connects to this fresh MCP process' bridge.
|
|
1065
|
+
_kill_pid_group(sess.manager_pid, f"old test-manager for Answer42 session {sess.session_id}")
|
|
1066
|
+
|
|
1067
|
+
await sess.bridge.start()
|
|
1068
|
+
log_dir = sess.log_dir
|
|
1069
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
1070
|
+
rt = _resolve_runtime(
|
|
1071
|
+
platform_dir=sess.platform_dir,
|
|
1072
|
+
db_path=sess.db_path,
|
|
1073
|
+
ibsrv_address="127.0.0.1",
|
|
1074
|
+
ibsrv_port=sess.ibsrv_port,
|
|
1075
|
+
ibsrv_name="mcp",
|
|
1076
|
+
server_data_dir=sess.server_data_dir,
|
|
1077
|
+
)
|
|
1078
|
+
rt.log_dir = log_dir
|
|
1079
|
+
sess.runtime = rt
|
|
1080
|
+
try:
|
|
1081
|
+
if sess.ibsrv_port:
|
|
1082
|
+
ws_url = runtime.build_thin_client_ws_url("127.0.0.1", sess.ibsrv_port)
|
|
1083
|
+
manager_proc = runtime.start_thin_test_manager(
|
|
1084
|
+
onec_path=sess.onec_thin,
|
|
1085
|
+
ws_url=ws_url,
|
|
1086
|
+
websocket_url=sess.bridge.url,
|
|
1087
|
+
log_dir=log_dir,
|
|
1088
|
+
display=sess.display,
|
|
1089
|
+
)
|
|
1090
|
+
else:
|
|
1091
|
+
manager_proc = runtime.start_thin_test_manager_file_direct(
|
|
1092
|
+
onec_path=sess.onec_thin,
|
|
1093
|
+
db_path=sess.db_path,
|
|
1094
|
+
websocket_url=sess.bridge.url,
|
|
1095
|
+
log_dir=log_dir,
|
|
1096
|
+
display=sess.display,
|
|
1097
|
+
)
|
|
1098
|
+
rt.manager_process = manager_proc
|
|
1099
|
+
sess.manager_pid = manager_proc.pid
|
|
1100
|
+
await _wait_for_bridge_connected(sess, timeout=60.0)
|
|
1101
|
+
params = {
|
|
1102
|
+
"base_url": sess.base_url,
|
|
1103
|
+
"username": sess.username,
|
|
1104
|
+
"password": sess.password,
|
|
1105
|
+
"port": sess.testclient_port,
|
|
1106
|
+
"host": "localhost",
|
|
1107
|
+
"client_pid": sess.test_client_pid,
|
|
1108
|
+
"connection_mode": "reconnect",
|
|
1109
|
+
"connection_value": sess.base_url,
|
|
1110
|
+
"target_connection_mode": "reconnect",
|
|
1111
|
+
"expected_manager_protocol_version": MANAGER_PROTOCOL_VERSION,
|
|
1112
|
+
}
|
|
1113
|
+
await sess.bridge.call("attach_test_client", params)
|
|
1114
|
+
_SESSIONS[sess.session_id] = sess
|
|
1115
|
+
logging.info("Reconnected scoped Answer42 session %s", sess.session_id)
|
|
1116
|
+
return True
|
|
1117
|
+
except Exception as exc:
|
|
1118
|
+
logging.warning("Could not reconnect scoped Answer42 session %s: %s", sess.session_id, exc)
|
|
1119
|
+
try:
|
|
1120
|
+
await sess.bridge.stop()
|
|
1121
|
+
except Exception:
|
|
1122
|
+
pass
|
|
1123
|
+
_kill_persisted_session_processes(sess, "scoped reconnect failed", include_test_client=True)
|
|
1124
|
+
return False
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
async def _recover_scoped_sessions_if_needed(stale_lock_existed: bool) -> None:
|
|
1128
|
+
if not SESSION_SCOPE_ID:
|
|
1129
|
+
return
|
|
1130
|
+
persisted = _load_persisted_sessions()
|
|
1131
|
+
if not persisted:
|
|
1132
|
+
return
|
|
1133
|
+
if not stale_lock_existed:
|
|
1134
|
+
# Normal fresh start in scoped mode: do not attach to old sessions unless
|
|
1135
|
+
# we know the previous scoped MCP process died while owning the folder.
|
|
1136
|
+
return
|
|
1137
|
+
recovered: dict[str, SessionState] = {}
|
|
1138
|
+
for sid, sess in persisted.items():
|
|
1139
|
+
if await _reconnect_persisted_session(sess):
|
|
1140
|
+
recovered[sid] = sess
|
|
1141
|
+
else:
|
|
1142
|
+
shutil.rmtree(sess.data_dir, ignore_errors=True)
|
|
1143
|
+
_SESSIONS.update(recovered)
|
|
1144
|
+
_persist_sessions()
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
async def _global_orphan_reaper_loop(interval_seconds: float = 60.0) -> None:
|
|
1148
|
+
while True:
|
|
1149
|
+
try:
|
|
1150
|
+
_reap_global_orphan_sessions()
|
|
1151
|
+
except asyncio.CancelledError:
|
|
1152
|
+
raise
|
|
1153
|
+
except Exception as exc:
|
|
1154
|
+
logging.warning("Answer42 global orphan reaper failed: %s", exc)
|
|
1155
|
+
await asyncio.sleep(interval_seconds)
|
|
1156
|
+
|
|
1157
|
+
|
|
803
1158
|
async def _cleanup_failed_start_session(sess: SessionState, reason: str) -> None:
|
|
804
1159
|
logging.info("Cleaning failed start_session %s: %s", sess.session_id, reason)
|
|
805
1160
|
try:
|
|
@@ -826,6 +1181,7 @@ async def _cleanup_failed_start_session(sess: SessionState, reason: str) -> None
|
|
|
826
1181
|
if sess.data_dir.exists():
|
|
827
1182
|
shutil.rmtree(sess.data_dir, ignore_errors=True)
|
|
828
1183
|
_SESSIONS.pop(sess.session_id, None)
|
|
1184
|
+
_remove_global_session_registry(sess)
|
|
829
1185
|
_persist_sessions()
|
|
830
1186
|
|
|
831
1187
|
|
|
@@ -1627,9 +1983,10 @@ def sessions_list() -> dict[str, Any]:
|
|
|
1627
1983
|
"client_display": sess.client_display,
|
|
1628
1984
|
"ws_port": sess.ws_port,
|
|
1629
1985
|
"bridge_connected": bool(bridge_status.get("connected")),
|
|
1630
|
-
"manager_alive": bool(rt and rt.manager_process and rt.manager_process.poll() is None),
|
|
1631
|
-
"ibsrv_alive": bool(rt and rt.server_process and rt.server_process.poll() is None),
|
|
1986
|
+
"manager_alive": bool((rt and rt.manager_process and rt.manager_process.poll() is None) or _pid_is_running(sess.manager_pid)),
|
|
1987
|
+
"ibsrv_alive": bool((rt and rt.server_process and rt.server_process.poll() is None) or _pid_is_running(sess.ibsrv_pid)),
|
|
1632
1988
|
"testclient_port": sess.testclient_port,
|
|
1989
|
+
"testclient_alive": bool((sess.test_client_process and sess.test_client_process.poll() is None) or _pid_is_running(sess.test_client_pid)),
|
|
1633
1990
|
"client_ibsrv_port": sess.client_ibsrv_port,
|
|
1634
1991
|
"client_ibsrv_alive": bool(client_rt and (
|
|
1635
1992
|
(client_rt.server_process and client_rt.server_process.poll() is None)
|
|
@@ -1682,15 +2039,17 @@ def session_status(*,
|
|
|
1682
2039
|
"client_display": sess.client_display,
|
|
1683
2040
|
"bridge": bridge,
|
|
1684
2041
|
"manager": None if rt is None else {
|
|
1685
|
-
"pid": rt.manager_process.pid if rt.manager_process else None,
|
|
1686
|
-
"alive": rt.manager_process.poll() is None if rt.manager_process else
|
|
1687
|
-
"server_pid": rt.server_process.pid if rt.server_process else None,
|
|
1688
|
-
"server_alive": rt.server_process.poll() is None if rt.server_process else
|
|
2042
|
+
"pid": rt.manager_process.pid if rt.manager_process else (sess.manager_pid or None),
|
|
2043
|
+
"alive": (rt.manager_process.poll() is None if rt.manager_process else _pid_is_running(sess.manager_pid)),
|
|
2044
|
+
"server_pid": rt.server_process.pid if rt.server_process else (sess.ibsrv_pid or None),
|
|
2045
|
+
"server_alive": (rt.server_process.poll() is None if rt.server_process else _pid_is_running(sess.ibsrv_pid)),
|
|
1689
2046
|
"port": rt.server.port,
|
|
1690
2047
|
"log_dir": str(rt.log_dir),
|
|
1691
2048
|
},
|
|
1692
2049
|
"test_client": {
|
|
1693
2050
|
"port": sess.testclient_port,
|
|
2051
|
+
"pid": sess.test_client_process.pid if sess.test_client_process else (sess.test_client_pid or None),
|
|
2052
|
+
"alive": (sess.test_client_process.poll() is None if sess.test_client_process else _pid_is_running(sess.test_client_pid)),
|
|
1694
2053
|
"base_url": sess.base_url,
|
|
1695
2054
|
"client_ibsrv_port": sess.client_ibsrv_port,
|
|
1696
2055
|
"server_alive": client_server_alive,
|
|
@@ -1781,11 +2140,13 @@ async def start_session(
|
|
|
1781
2140
|
sess.rag_snapshot = bound_snapshot
|
|
1782
2141
|
if display:
|
|
1783
2142
|
sess.xvfb_process = runtime.start_xvfb(display)
|
|
2143
|
+
sess.xvfb_pid = sess.xvfb_process.pid
|
|
1784
2144
|
# Separate display for the test client so screenshots capture only the client window
|
|
1785
2145
|
client_display = "" if os_support.IS_WINDOWS else runtime.find_free_display(runtime.DEFAULT_DISPLAY_START + display_offset + 1)
|
|
1786
2146
|
sess.client_display = client_display
|
|
1787
2147
|
if client_display:
|
|
1788
2148
|
sess.client_xvfb_process = runtime.start_xvfb(client_display)
|
|
2149
|
+
sess.client_xvfb_pid = sess.client_xvfb_process.pid
|
|
1789
2150
|
await sess.bridge.start()
|
|
1790
2151
|
_SESSIONS[sid] = sess
|
|
1791
2152
|
_persist_sessions()
|
|
@@ -1829,6 +2190,7 @@ async def start_session(
|
|
|
1829
2190
|
direct_range=_direct_range_from_regport(ibsrv_direct_regport),
|
|
1830
2191
|
)
|
|
1831
2192
|
runtime_state.server_process = server_proc
|
|
2193
|
+
sess.ibsrv_pid = server_proc.pid
|
|
1832
2194
|
ws_url = runtime.build_thin_client_ws_url(ibsrv_address, ibsrv_port)
|
|
1833
2195
|
manager_proc = runtime.start_thin_test_manager(
|
|
1834
2196
|
onec_path=sess.onec_thin, ws_url=ws_url,
|
|
@@ -1846,6 +2208,7 @@ async def start_session(
|
|
|
1846
2208
|
display=display,
|
|
1847
2209
|
)
|
|
1848
2210
|
runtime_state.manager_process = manager_proc
|
|
2211
|
+
sess.manager_pid = manager_proc.pid
|
|
1849
2212
|
await _wait_for_bridge_connected(sess, timeout=60.0)
|
|
1850
2213
|
except Exception as exc:
|
|
1851
2214
|
diagnostic_error = _manager_failure_diagnostics(sess, exc)
|
|
@@ -2662,6 +3025,7 @@ async def _connect_test_client_impl(
|
|
|
2662
3025
|
display=sess.client_display or sess.display,
|
|
2663
3026
|
log_name=client_stdout_log,
|
|
2664
3027
|
)
|
|
3028
|
+
sess.test_client_pid = sess.test_client_process.pid
|
|
2665
3029
|
params = {"base_url": r_base, "username": r_user, "password": r_pass,
|
|
2666
3030
|
"port": r_port, "host": host, "client_pid": sess.test_client_process.pid,
|
|
2667
3031
|
"client_log": str(client_out_log), "client_stdout_log": str(sess.client_log_dir / client_stdout_log),
|
|
@@ -4637,11 +5001,27 @@ def screenshot(
|
|
|
4637
5001
|
return result
|
|
4638
5002
|
|
|
4639
5003
|
|
|
4640
|
-
async def _run() -> None:
|
|
5004
|
+
async def _run(stale_scope_lock_existed: bool = False) -> None:
|
|
5005
|
+
reaper_task: asyncio.Task[Any] | None = None
|
|
4641
5006
|
try:
|
|
5007
|
+
await _recover_scoped_sessions_if_needed(stale_scope_lock_existed)
|
|
5008
|
+
reaper_task = asyncio.create_task(_global_orphan_reaper_loop())
|
|
4642
5009
|
await mcp.run_stdio_async()
|
|
4643
5010
|
finally:
|
|
5011
|
+
if reaper_task is not None:
|
|
5012
|
+
reaper_task.cancel()
|
|
5013
|
+
with suppress(asyncio.CancelledError):
|
|
5014
|
+
await reaper_task
|
|
4644
5015
|
for sid, sess in list(_SESSIONS.items()):
|
|
5016
|
+
if SESSION_SCOPE_ID:
|
|
5017
|
+
if sess.runtime is not None:
|
|
5018
|
+
runtime.kill_process(sess.runtime.manager_process, "test-manager")
|
|
5019
|
+
sess.manager_pid = 0
|
|
5020
|
+
try:
|
|
5021
|
+
await sess.bridge.stop()
|
|
5022
|
+
except Exception:
|
|
5023
|
+
pass
|
|
5024
|
+
continue
|
|
4645
5025
|
if sess.runtime is not None:
|
|
4646
5026
|
runtime.kill_process(sess.runtime.manager_process, "test-manager")
|
|
4647
5027
|
runtime.kill_process(sess.runtime.server_process, "ibsrv")
|
|
@@ -4661,7 +5041,14 @@ async def _run() -> None:
|
|
|
4661
5041
|
pass
|
|
4662
5042
|
runtime.stop_xvfb(sess.client_xvfb_process)
|
|
4663
5043
|
runtime.stop_xvfb(sess.xvfb_process)
|
|
5044
|
+
if SESSION_SCOPE_ID:
|
|
5045
|
+
_persist_sessions()
|
|
5046
|
+
_mark_global_sessions_orphaned_for_owner()
|
|
5047
|
+
else:
|
|
5048
|
+
for sess in list(_SESSIONS.values()):
|
|
5049
|
+
_remove_global_session_registry(sess)
|
|
4664
5050
|
_SESSIONS.clear()
|
|
5051
|
+
_release_scope_lock()
|
|
4665
5052
|
|
|
4666
5053
|
|
|
4667
5054
|
def _register_dev_tools(enabled: bool) -> None:
|
|
@@ -4742,6 +5129,11 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
4742
5129
|
default=float(os.getenv("ONEC_MCP_DEV_RESTART_TIMEOUT", "30")),
|
|
4743
5130
|
help="seconds of no file changes before the OpenClaw MCP autoreload watchdog triggers reload",
|
|
4744
5131
|
)
|
|
5132
|
+
parser.add_argument(
|
|
5133
|
+
"--session-scope",
|
|
5134
|
+
default=os.getenv("ONEC_MCP_SESSION_SCOPE_ID") or os.getenv("ONEC_MCP_SESSION_SCOPE") or "",
|
|
5135
|
+
help="enable scoped lock/reconnect mode for the given session scope id; without this flag Answer42 uses legacy no-lock global state",
|
|
5136
|
+
)
|
|
4745
5137
|
args = parser.parse_args(argv)
|
|
4746
5138
|
|
|
4747
5139
|
_RAG_ENABLED = not args.disable_rag
|
|
@@ -4754,7 +5146,13 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
4754
5146
|
else:
|
|
4755
5147
|
logging.info("Built-in Answer42 RAG is disabled")
|
|
4756
5148
|
_start_autoreload_watcher(args.dev, args.dev_restart_timeout)
|
|
4757
|
-
|
|
5149
|
+
if args.session_scope:
|
|
5150
|
+
_configure_session_scope(args.session_scope)
|
|
5151
|
+
stale = _acquire_scope_lock_if_configured()
|
|
5152
|
+
else:
|
|
5153
|
+
stale = False
|
|
5154
|
+
_reap_global_orphan_sessions()
|
|
5155
|
+
asyncio.run(_run(stale))
|
|
4758
5156
|
|
|
4759
5157
|
|
|
4760
5158
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{answer42-0.2.30 → answer42-0.2.31}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|