feat(channel/hooks): 实现完整的钩子系统模块
新增了完整的钩子系统,包括元数据定义、扫描器、解析器、生命周期注册表和加载器,支持从目录扫描HOOK.md配置、验证依赖和系统兼容性,以及通过沙箱加载钩子脚本,实现了事件驱动的钩子回调机制。
This commit is contained in:
parent
ecd3c90e80
commit
a81279cf61
43
backend/package/yuxi/channel/hooks/__init__.py
Normal file
43
backend/package/yuxi/channel/hooks/__init__.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from yuxi.channel.hooks.hook_loader import (
|
||||||
|
HookLoadEntry,
|
||||||
|
HookLoadResult,
|
||||||
|
load_all_hook_sources,
|
||||||
|
load_hooks_from_dir,
|
||||||
|
load_hooks_from_dirs,
|
||||||
|
)
|
||||||
|
from yuxi.channel.hooks.hook_parser import (
|
||||||
|
HookParseError,
|
||||||
|
HookValidationError,
|
||||||
|
clear_bin_cache,
|
||||||
|
parse_hook_md,
|
||||||
|
validate_dependencies,
|
||||||
|
validate_os_compatibility,
|
||||||
|
)
|
||||||
|
from yuxi.channel.hooks.hook_scanner import DiscoveredHook, ScanResult, scan_directory
|
||||||
|
from yuxi.channel.hooks.lifecycle import HookCallback, HookEvent, HookRegistration, LifecycleHookRegistry
|
||||||
|
from yuxi.channel.hooks.types import HookConfigField, HookDependencies, HookMetadata, SkipReason
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HookCallback",
|
||||||
|
"HookEvent",
|
||||||
|
"HookRegistration",
|
||||||
|
"LifecycleHookRegistry",
|
||||||
|
"HookMetadata",
|
||||||
|
"HookConfigField",
|
||||||
|
"HookDependencies",
|
||||||
|
"HookParseError",
|
||||||
|
"HookValidationError",
|
||||||
|
"clear_bin_cache",
|
||||||
|
"parse_hook_md",
|
||||||
|
"validate_os_compatibility",
|
||||||
|
"validate_dependencies",
|
||||||
|
"scan_directory",
|
||||||
|
"ScanResult",
|
||||||
|
"DiscoveredHook",
|
||||||
|
"load_hooks_from_dir",
|
||||||
|
"load_hooks_from_dirs",
|
||||||
|
"load_all_hook_sources",
|
||||||
|
"HookLoadResult",
|
||||||
|
"HookLoadEntry",
|
||||||
|
"SkipReason",
|
||||||
|
]
|
||||||
281
backend/package/yuxi/channel/hooks/hook_loader.py
Normal file
281
backend/package/yuxi/channel/hooks/hook_loader.py
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
import logging
|
||||||
|
import sys as _sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from yuxi.channel.hooks.hook_parser import _current_os, validate_dependencies, validate_os_compatibility
|
||||||
|
from yuxi.channel.hooks.hook_scanner import DiscoveredHook, scan_directory
|
||||||
|
from yuxi.channel.hooks.lifecycle import HookEvent, LifecycleHookRegistry
|
||||||
|
from yuxi.channel.hooks.types import SkipReason
|
||||||
|
from yuxi.channel.plugins.sandbox import PluginSandbox, SandboxViolationError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookLoadEntry:
|
||||||
|
hook_id: str
|
||||||
|
status: str
|
||||||
|
events_loaded: int = 0
|
||||||
|
reason: str = ""
|
||||||
|
detail: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookLoadResult:
|
||||||
|
loaded: list[HookLoadEntry] = field(default_factory=list)
|
||||||
|
skipped: list[HookLoadEntry] = field(default_factory=list)
|
||||||
|
failed: list[HookLoadEntry] = field(default_factory=list)
|
||||||
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return len(self.loaded) + len(self.skipped) + len(self.failed)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loaded_count(self) -> int:
|
||||||
|
return len(self.loaded)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def skipped_count(self) -> int:
|
||||||
|
return len(self.skipped)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed_count(self) -> int:
|
||||||
|
return len(self.failed)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"total": self.total,
|
||||||
|
"loaded": self.loaded_count,
|
||||||
|
"skipped": self.skipped_count,
|
||||||
|
"failed": self.failed_count,
|
||||||
|
"entries": {
|
||||||
|
"loaded": [{"hook_id": e.hook_id, "events": e.events_loaded} for e in self.loaded],
|
||||||
|
"skipped": [{"hook_id": e.hook_id, "reason": e.reason, "detail": e.detail} for e in self.skipped],
|
||||||
|
"failed": [{"hook_id": e.hook_id, "reason": e.reason} for e in self.failed],
|
||||||
|
},
|
||||||
|
"errors": self.errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def load_hooks_from_dir(
|
||||||
|
directory: str,
|
||||||
|
registry: LifecycleHookRegistry,
|
||||||
|
*,
|
||||||
|
callbacks: dict[str, dict] | None = None,
|
||||||
|
) -> HookLoadResult:
|
||||||
|
result = HookLoadResult()
|
||||||
|
|
||||||
|
scan_result = scan_directory(directory)
|
||||||
|
result.errors.extend(scan_result.errors)
|
||||||
|
|
||||||
|
if not scan_result.hooks:
|
||||||
|
logger.debug("No hooks discovered in %s", directory)
|
||||||
|
return result
|
||||||
|
|
||||||
|
callback_map = callbacks or {}
|
||||||
|
|
||||||
|
for discovered in scan_result.hooks:
|
||||||
|
await _load_single_hook(discovered, registry, callback_map, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_single_hook(
|
||||||
|
discovered: DiscoveredHook,
|
||||||
|
registry: LifecycleHookRegistry,
|
||||||
|
callback_map: dict[str, dict],
|
||||||
|
result: HookLoadResult,
|
||||||
|
) -> None:
|
||||||
|
metadata = discovered.metadata
|
||||||
|
hook_id = metadata.hook_id
|
||||||
|
|
||||||
|
current_os = _current_os()
|
||||||
|
|
||||||
|
if not validate_os_compatibility(metadata):
|
||||||
|
entry = HookLoadEntry(
|
||||||
|
hook_id=hook_id,
|
||||||
|
status=SkipReason.OS_INCOMPATIBLE,
|
||||||
|
reason=f"requires {metadata.os}, current: {current_os}",
|
||||||
|
detail={"required_os": metadata.os, "current_os": current_os},
|
||||||
|
)
|
||||||
|
result.skipped.append(entry)
|
||||||
|
logger.warning(
|
||||||
|
"Hook %s skipped: OS incompatible (requires %s, current: %s)",
|
||||||
|
hook_id,
|
||||||
|
metadata.os,
|
||||||
|
current_os,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
deps_ok, dep_failures = validate_dependencies(metadata)
|
||||||
|
if not deps_ok:
|
||||||
|
entry = HookLoadEntry(
|
||||||
|
hook_id=hook_id,
|
||||||
|
status=SkipReason.DEPENDENCY_MISSING,
|
||||||
|
reason="; ".join(dep_failures),
|
||||||
|
detail={"failures": dep_failures},
|
||||||
|
)
|
||||||
|
result.skipped.append(entry)
|
||||||
|
logger.warning("Hook %s skipped: dependencies not met: %s", hook_id, dep_failures)
|
||||||
|
return
|
||||||
|
|
||||||
|
callbacks_for_hook = callback_map.get(hook_id, {})
|
||||||
|
|
||||||
|
registered = 0
|
||||||
|
for event_name in metadata.events:
|
||||||
|
callback = callbacks_for_hook.get(event_name)
|
||||||
|
if callback is None or not callable(callback):
|
||||||
|
entry = HookLoadEntry(
|
||||||
|
hook_id=hook_id,
|
||||||
|
status=SkipReason.NO_CALLBACK,
|
||||||
|
reason=f"no callback for event {event_name}",
|
||||||
|
)
|
||||||
|
result.failed.append(entry)
|
||||||
|
logger.warning("Hook %s: no callback registered for event %s", hook_id, event_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
await registry.register(event_name, callback, key=hook_id)
|
||||||
|
registered += 1
|
||||||
|
|
||||||
|
script_loaded = await _try_load_hook_script(discovered, registry, hook_id)
|
||||||
|
if registered > 0 or script_loaded > 0:
|
||||||
|
entry = HookLoadEntry(hook_id=hook_id, status="loaded", events_loaded=registered)
|
||||||
|
result.loaded.append(entry)
|
||||||
|
logger.info(
|
||||||
|
"Hook loaded: %s (%s) — %d events from %s",
|
||||||
|
hook_id,
|
||||||
|
metadata.name,
|
||||||
|
registered,
|
||||||
|
discovered.hook_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _try_load_hook_script(discovered: DiscoveredHook, registry: LifecycleHookRegistry, hook_id: str) -> int:
|
||||||
|
hook_py = discovered.hook_dir / "hook.py"
|
||||||
|
if not hook_py.is_file():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
module_name = f"_hook_{hook_id.replace('-', '_')}"
|
||||||
|
|
||||||
|
sandbox = PluginSandbox()
|
||||||
|
try:
|
||||||
|
module = sandbox.load_module_from_file(hook_py, module_name)
|
||||||
|
except SandboxViolationError as e:
|
||||||
|
logger.error("Sandbox violation in hook script %s: %s", hook_py, e)
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load hook script from %s", hook_py)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
_sys.modules[module_name] = module
|
||||||
|
|
||||||
|
registered = 0
|
||||||
|
valid_event_values = {e.value for e in HookEvent}
|
||||||
|
for attr_name in dir(module):
|
||||||
|
if attr_name not in valid_event_values:
|
||||||
|
continue
|
||||||
|
callback = getattr(module, attr_name)
|
||||||
|
if not callable(callback):
|
||||||
|
continue
|
||||||
|
await registry.register(attr_name, callback, key=hook_id)
|
||||||
|
registered += 1
|
||||||
|
logger.debug("Auto-loaded callback '%s' from %s for event %s", attr_name, hook_py, attr_name)
|
||||||
|
|
||||||
|
if registered > 0:
|
||||||
|
logger.info("Hook script loaded: %s — %d callbacks registered", hook_py, registered)
|
||||||
|
return registered
|
||||||
|
|
||||||
|
|
||||||
|
async def load_hooks_from_dirs(
|
||||||
|
directories: list[str],
|
||||||
|
registry: LifecycleHookRegistry,
|
||||||
|
*,
|
||||||
|
callbacks: dict[str, dict] | None = None,
|
||||||
|
) -> HookLoadResult:
|
||||||
|
combined = HookLoadResult()
|
||||||
|
|
||||||
|
for directory in directories:
|
||||||
|
result = await load_hooks_from_dir(directory, registry, callbacks=callbacks)
|
||||||
|
combined.loaded.extend(result.loaded)
|
||||||
|
combined.skipped.extend(result.skipped)
|
||||||
|
combined.failed.extend(result.failed)
|
||||||
|
combined.errors.extend(result.errors)
|
||||||
|
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def _default_hook_sources(base_dir: str | None = None) -> list[tuple[str, str]]:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
cwd = Path(base_dir) if base_dir else Path.cwd()
|
||||||
|
sources: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
bundled = Path(__file__).resolve().parent.parent / "bundled_hooks"
|
||||||
|
if bundled.is_dir():
|
||||||
|
sources.append(("bundled", str(bundled)))
|
||||||
|
|
||||||
|
managed = cwd / ".forcepilot" / "managed_hooks"
|
||||||
|
if managed.is_dir():
|
||||||
|
sources.append(("managed", str(managed)))
|
||||||
|
|
||||||
|
workspace = cwd / "hooks"
|
||||||
|
if workspace.is_dir():
|
||||||
|
sources.append(("workspace", str(workspace)))
|
||||||
|
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
async def load_all_hook_sources(
|
||||||
|
registry: LifecycleHookRegistry,
|
||||||
|
*,
|
||||||
|
callbacks: dict[str, dict] | None = None,
|
||||||
|
extra_sources: list[tuple[str, str]] | None = None,
|
||||||
|
base_dir: str | None = None,
|
||||||
|
) -> HookLoadResult:
|
||||||
|
sources = _default_hook_sources(base_dir)
|
||||||
|
if extra_sources:
|
||||||
|
priority_order = {"bundled": 0, "managed": 1, "workspace": 2}
|
||||||
|
existing = {s[0] for s in sources}
|
||||||
|
for label, path in extra_sources:
|
||||||
|
if label not in existing:
|
||||||
|
sources.append((label, path))
|
||||||
|
sources.sort(key=lambda x: priority_order.get(x[0], 99))
|
||||||
|
|
||||||
|
combined = HookLoadResult()
|
||||||
|
seen_hook_ids: set[str] = set()
|
||||||
|
|
||||||
|
for source_label, source_path in sources:
|
||||||
|
source_result = await load_hooks_from_dir(source_path, registry, callbacks=callbacks)
|
||||||
|
combined.errors.extend(source_result.errors)
|
||||||
|
|
||||||
|
for entry in source_result.loaded:
|
||||||
|
if entry.hook_id in seen_hook_ids:
|
||||||
|
logger.debug(
|
||||||
|
"Hook %s from [%s] overridden by higher-priority source",
|
||||||
|
entry.hook_id,
|
||||||
|
source_label,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
seen_hook_ids.add(entry.hook_id)
|
||||||
|
combined.loaded.append(entry)
|
||||||
|
|
||||||
|
for entry in source_result.skipped:
|
||||||
|
if entry.hook_id not in seen_hook_ids:
|
||||||
|
seen_hook_ids.add(entry.hook_id)
|
||||||
|
combined.skipped.append(entry)
|
||||||
|
|
||||||
|
for entry in source_result.failed:
|
||||||
|
if entry.hook_id not in seen_hook_ids:
|
||||||
|
seen_hook_ids.add(entry.hook_id)
|
||||||
|
combined.failed.append(entry)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Hook sources loaded: %d loaded, %d skipped, %d failed (sources: %s)",
|
||||||
|
combined.loaded_count,
|
||||||
|
combined.skipped_count,
|
||||||
|
combined.failed_count,
|
||||||
|
[s[0] for s in sources],
|
||||||
|
)
|
||||||
|
|
||||||
|
return combined
|
||||||
3
backend/package/yuxi/channel/hooks/hook_metadata.py
Normal file
3
backend/package/yuxi/channel/hooks/hook_metadata.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from yuxi.channel.hooks.types import HookConfigField, HookDependencies, HookMetadata
|
||||||
|
|
||||||
|
__all__ = ["HookConfigField", "HookDependencies", "HookMetadata"]
|
||||||
191
backend/package/yuxi/channel/hooks/hook_parser.py
Normal file
191
backend/package/yuxi/channel/hooks/hook_parser.py
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from yuxi.channel.hooks.lifecycle import HookEvent
|
||||||
|
from yuxi.channel.hooks.types import HookConfigField, HookDependencies, HookMetadata
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)", re.DOTALL)
|
||||||
|
|
||||||
|
_REQUIRED_FIELDS = {"hook_id", "name", "events"}
|
||||||
|
_VALID_CONFIG_TYPES = {"str", "int", "float", "bool", "list", "dict"}
|
||||||
|
|
||||||
|
_OS_ALIASES = {
|
||||||
|
"win32": "windows",
|
||||||
|
"cygwin": "windows",
|
||||||
|
"linux": "linux",
|
||||||
|
"darwin": "darwin",
|
||||||
|
"macos": "darwin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_os(name: str) -> str:
|
||||||
|
return _OS_ALIASES.get(name.lower(), name.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _current_os() -> str:
|
||||||
|
return _normalize_os(sys.platform)
|
||||||
|
|
||||||
|
|
||||||
|
class HookParseError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HookValidationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hook_md(content: str, source_path: Path | str = "<unknown>") -> HookMetadata:
|
||||||
|
m = _FRONTMATTER_RE.match(content)
|
||||||
|
if not m:
|
||||||
|
raise HookParseError(f"HOOK.md missing frontmatter: {source_path}")
|
||||||
|
|
||||||
|
raw_yaml = m.group(1)
|
||||||
|
body = m.group(2).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(raw_yaml)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise HookParseError(f"HOOK.md invalid YAML frontmatter: {source_path}: {e}") from e
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise HookParseError(f"HOOK.md frontmatter is not a dict: {source_path}")
|
||||||
|
|
||||||
|
return _build_metadata(data, body, str(source_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_metadata(data: dict, body: str, source_dir: str) -> HookMetadata:
|
||||||
|
missing = _REQUIRED_FIELDS - set(data.keys())
|
||||||
|
if missing:
|
||||||
|
raise HookValidationError(f"HOOK.md missing required fields: {sorted(missing)} (source: {source_dir})")
|
||||||
|
|
||||||
|
hook_id = data["hook_id"]
|
||||||
|
name = data["name"]
|
||||||
|
events = data["events"]
|
||||||
|
if not isinstance(hook_id, str):
|
||||||
|
raise HookValidationError(f"HOOK.md hook_id must be a string: {source_dir}")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise HookValidationError(f"HOOK.md name must be a string: {source_dir}")
|
||||||
|
if not isinstance(events, list):
|
||||||
|
raise HookValidationError(f"HOOK.md events must be a list: {source_dir}")
|
||||||
|
|
||||||
|
valid_event_values = {e.value for e in HookEvent}
|
||||||
|
invalid_events = [e for e in events if e not in valid_event_values]
|
||||||
|
if invalid_events:
|
||||||
|
raise HookValidationError(
|
||||||
|
f"HOOK.md contains invalid events: {invalid_events}. "
|
||||||
|
f"Valid events: {sorted(valid_event_values)}. (source: {source_dir})"
|
||||||
|
)
|
||||||
|
|
||||||
|
description = data.get("description", "")
|
||||||
|
if not isinstance(description, str):
|
||||||
|
description = str(description)
|
||||||
|
|
||||||
|
os_list = data.get("os", [])
|
||||||
|
if isinstance(os_list, list):
|
||||||
|
os_list = [_normalize_os(o) for o in os_list if isinstance(o, str)]
|
||||||
|
else:
|
||||||
|
os_list = []
|
||||||
|
|
||||||
|
depends_raw = data.get("depends", {})
|
||||||
|
if not isinstance(depends_raw, dict):
|
||||||
|
depends_raw = {}
|
||||||
|
depends = HookDependencies(
|
||||||
|
bin=list(depends_raw.get("bin", []) or []),
|
||||||
|
env=list(depends_raw.get("env", []) or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
config_raw = data.get("config", {})
|
||||||
|
if not isinstance(config_raw, dict):
|
||||||
|
config_raw = {}
|
||||||
|
config_fields: dict[str, HookConfigField] = {}
|
||||||
|
for key, val in config_raw.items():
|
||||||
|
if not isinstance(val, dict):
|
||||||
|
continue
|
||||||
|
field_type = val.get("type", "str")
|
||||||
|
if field_type not in _VALID_CONFIG_TYPES:
|
||||||
|
field_type = "str"
|
||||||
|
config_fields[key] = HookConfigField(
|
||||||
|
type=field_type,
|
||||||
|
default=val.get("default"),
|
||||||
|
description=val.get("description", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
return HookMetadata(
|
||||||
|
hook_id=hook_id,
|
||||||
|
name=name,
|
||||||
|
events=events,
|
||||||
|
description=description,
|
||||||
|
os=os_list,
|
||||||
|
depends=depends,
|
||||||
|
config=config_fields,
|
||||||
|
body=body,
|
||||||
|
source_dir=source_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_os_compatibility(metadata: HookMetadata) -> bool:
|
||||||
|
if not metadata.os:
|
||||||
|
return True
|
||||||
|
return _current_os() in metadata.os
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dependencies(metadata: HookMetadata) -> tuple[bool, list[str]]:
|
||||||
|
failures: list[str] = []
|
||||||
|
|
||||||
|
for bin_name in metadata.depends.bin:
|
||||||
|
if not _find_bin(bin_name):
|
||||||
|
failures.append(f"binary not found: {bin_name}")
|
||||||
|
|
||||||
|
for env_var in metadata.depends.env:
|
||||||
|
if env_var not in os.environ:
|
||||||
|
failures.append(f"env variable not set: {env_var}")
|
||||||
|
|
||||||
|
return len(failures) == 0, failures
|
||||||
|
|
||||||
|
|
||||||
|
_CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _BinCacheEntry:
|
||||||
|
value: bool
|
||||||
|
timestamp: float
|
||||||
|
|
||||||
|
|
||||||
|
_find_bin_cache: dict[str, _BinCacheEntry] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_bin_cache() -> None:
|
||||||
|
_find_bin_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_bin(name: str) -> bool:
|
||||||
|
entry = _find_bin_cache.get(name)
|
||||||
|
if entry is not None:
|
||||||
|
if time.monotonic() - entry.timestamp < _CACHE_TTL:
|
||||||
|
return entry.value
|
||||||
|
del _find_bin_cache[name]
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
names = [name, f"{name}.exe", f"{name}.cmd", f"{name}.bat"]
|
||||||
|
else:
|
||||||
|
names = [name]
|
||||||
|
|
||||||
|
for p in os.environ.get("PATH", "").split(os.pathsep):
|
||||||
|
for n in names:
|
||||||
|
candidate = os.path.join(p, n)
|
||||||
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||||
|
_find_bin_cache[name] = _BinCacheEntry(value=True, timestamp=time.monotonic())
|
||||||
|
return True
|
||||||
|
|
||||||
|
_find_bin_cache[name] = _BinCacheEntry(value=False, timestamp=time.monotonic())
|
||||||
|
return False
|
||||||
106
backend/package/yuxi/channel/hooks/hook_scanner.py
Normal file
106
backend/package/yuxi/channel/hooks/hook_scanner.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from yuxi.channel.hooks.hook_parser import HookParseError, HookValidationError, parse_hook_md
|
||||||
|
from yuxi.channel.hooks.types import HookMetadata
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SKIP_DIRS = frozenset(
|
||||||
|
{
|
||||||
|
"__pycache__",
|
||||||
|
".git",
|
||||||
|
".svn",
|
||||||
|
".hg",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
".tox",
|
||||||
|
".eggs",
|
||||||
|
".mypy_cache",
|
||||||
|
".pytest_cache",
|
||||||
|
".ruff_cache",
|
||||||
|
"node_modules",
|
||||||
|
".idea",
|
||||||
|
".vscode",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiscoveredHook:
|
||||||
|
hook_dir: Path
|
||||||
|
metadata: HookMetadata
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str:
|
||||||
|
return str(self.hook_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanResult:
|
||||||
|
hooks: list[DiscoveredHook] = field(default_factory=list)
|
||||||
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hook_count(self) -> int:
|
||||||
|
return len(self.hooks)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def succeeded(self) -> bool:
|
||||||
|
return len(self.errors) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def scan_directory(directory: Path | str, *, recursive: bool = True, max_depth: int = 10) -> ScanResult:
|
||||||
|
hook_dirs = _find_hook_dirs(Path(directory), recursive=recursive, max_depth=max_depth)
|
||||||
|
return _parse_hook_dirs(hook_dirs)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_hook_dirs(root: Path, *, recursive: bool = True, max_depth: int = 10, _depth: int = 0) -> list[Path]:
|
||||||
|
root = root.resolve()
|
||||||
|
if not root.is_dir():
|
||||||
|
return []
|
||||||
|
if _depth > max_depth:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result: list[Path] = []
|
||||||
|
|
||||||
|
candidate = root / "HOOK.md"
|
||||||
|
if candidate.is_file():
|
||||||
|
result.append(root)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for entry in sorted(root.iterdir()):
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
if entry.name in _SKIP_DIRS:
|
||||||
|
continue
|
||||||
|
candidate = entry / "HOOK.md"
|
||||||
|
if candidate.is_file():
|
||||||
|
result.append(entry)
|
||||||
|
elif recursive:
|
||||||
|
result.extend(_find_hook_dirs(entry, recursive=recursive, max_depth=max_depth, _depth=_depth + 1))
|
||||||
|
except PermissionError:
|
||||||
|
logger.warning("Permission denied scanning directory: %s", root)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hook_dirs(hook_dirs: list[Path]) -> ScanResult:
|
||||||
|
result = ScanResult()
|
||||||
|
|
||||||
|
for hook_dir in hook_dirs:
|
||||||
|
hook_md_path = hook_dir / "HOOK.md"
|
||||||
|
try:
|
||||||
|
content = hook_md_path.read_text(encoding="utf-8")
|
||||||
|
metadata = parse_hook_md(content, source_path=hook_md_path)
|
||||||
|
result.hooks.append(DiscoveredHook(hook_dir=hook_dir, metadata=metadata))
|
||||||
|
logger.debug("Discovered hook: %s from %s", metadata.hook_id, hook_dir)
|
||||||
|
except (HookParseError, HookValidationError) as e:
|
||||||
|
result.errors.append(str(e))
|
||||||
|
logger.warning("Hook discovery failed: %s", e)
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Cannot read {hook_md_path}: {e}")
|
||||||
|
logger.warning("Cannot read HOOK.md: %s", e)
|
||||||
|
|
||||||
|
return result
|
||||||
224
backend/package/yuxi/channel/hooks/lifecycle.py
Normal file
224
backend/package/yuxi/channel/hooks/lifecycle.py
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import asyncio
|
||||||
|
import bisect
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from yuxi.channel.events.bus import ChannelEventBus
|
||||||
|
|
||||||
|
_DEFAULT_HOOK_TIMEOUT = 30.0
|
||||||
|
_DEFAULT_FIRE_CONCURRENCY = 10
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HookCallback = Callable[..., Coroutine[Any, Any, None]]
|
||||||
|
|
||||||
|
|
||||||
|
class HookEvent(StrEnum):
|
||||||
|
CHANNEL_STARTING = "on_channel_starting"
|
||||||
|
CHANNEL_STOPPING = "on_channel_stopping"
|
||||||
|
CHANNEL_RUNNING = "on_channel_running"
|
||||||
|
CHANNEL_ERROR = "on_channel_error"
|
||||||
|
CHANNEL_FAILED = "on_channel_failed"
|
||||||
|
|
||||||
|
GATEWAY_START = "on_gateway_start"
|
||||||
|
GATEWAY_STOP = "on_gateway_stop"
|
||||||
|
|
||||||
|
MESSAGE_RECEIVED = "on_message_received"
|
||||||
|
MESSAGE_SENDING = "on_message_sending"
|
||||||
|
MESSAGE_SENT = "on_message_sent"
|
||||||
|
|
||||||
|
BEFORE_AGENT_RUN = "on_before_agent_run"
|
||||||
|
AFTER_AGENT_RUN = "on_after_agent_run"
|
||||||
|
AGENT_BOOTSTRAP = "on_agent_bootstrap"
|
||||||
|
|
||||||
|
SESSION_START = "on_session_start"
|
||||||
|
SESSION_END = "on_session_end"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookRegistration:
|
||||||
|
callback: HookCallback
|
||||||
|
priority: int = 100
|
||||||
|
enabled: bool = True
|
||||||
|
key: str = ""
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
return id(self.callback)
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleHookRegistry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
event_bus: "ChannelEventBus | None" = None,
|
||||||
|
*,
|
||||||
|
fire_concurrency: int = _DEFAULT_FIRE_CONCURRENCY,
|
||||||
|
) -> None:
|
||||||
|
self._hooks: dict[str, list[HookRegistration]] = {}
|
||||||
|
self._locks: dict[str, asyncio.Lock] = {}
|
||||||
|
self._event_bus = event_bus
|
||||||
|
self._fire_sem = asyncio.Semaphore(fire_concurrency)
|
||||||
|
|
||||||
|
def _get_lock(self, event: HookEvent | str) -> asyncio.Lock:
|
||||||
|
return self._locks.setdefault(event, asyncio.Lock())
|
||||||
|
|
||||||
|
async def register(
|
||||||
|
self,
|
||||||
|
event: HookEvent | str,
|
||||||
|
callback: HookCallback,
|
||||||
|
*,
|
||||||
|
priority: int = 100,
|
||||||
|
key: str = "",
|
||||||
|
) -> HookRegistration:
|
||||||
|
reg = HookRegistration(callback=callback, priority=priority, key=key)
|
||||||
|
async with self._get_lock(event):
|
||||||
|
hooks = self._hooks.setdefault(event, [])
|
||||||
|
idx = bisect.bisect_left([r.priority for r in hooks], priority)
|
||||||
|
hooks.insert(idx, reg)
|
||||||
|
return reg
|
||||||
|
|
||||||
|
async def unregister(self, event: HookEvent | str, callback: HookCallback) -> None:
|
||||||
|
async with self._get_lock(event):
|
||||||
|
registrations = self._hooks.get(event)
|
||||||
|
if registrations is None:
|
||||||
|
return
|
||||||
|
self._hooks[event] = [r for r in registrations if r.callback != callback]
|
||||||
|
if not self._hooks[event]:
|
||||||
|
del self._hooks[event]
|
||||||
|
|
||||||
|
async def enable(self, event: HookEvent | str, callback: HookCallback) -> bool:
|
||||||
|
async with self._get_lock(event):
|
||||||
|
for reg in self._hooks.get(event, []):
|
||||||
|
if reg.callback == callback:
|
||||||
|
reg.enabled = True
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def disable(self, event: HookEvent | str, callback: HookCallback) -> bool:
|
||||||
|
async with self._get_lock(event):
|
||||||
|
for reg in self._hooks.get(event, []):
|
||||||
|
if reg.callback == callback:
|
||||||
|
reg.enabled = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def set_priority(self, event: HookEvent | str, callback: HookCallback, priority: int) -> bool:
|
||||||
|
async with self._get_lock(event):
|
||||||
|
for reg in self._hooks.get(event, []):
|
||||||
|
if reg.callback == callback:
|
||||||
|
reg.priority = priority
|
||||||
|
self._hooks[event].sort(key=lambda r: r.priority)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def fire(self, event: HookEvent | str, *args: Any, timeout: float | None = None, **kwargs: Any) -> None:
|
||||||
|
async with self._get_lock(event):
|
||||||
|
registrations = self._hooks.get(event)
|
||||||
|
if not registrations:
|
||||||
|
return
|
||||||
|
enabled = [r for r in registrations if r.enabled]
|
||||||
|
if not enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
effective_timeout = timeout if timeout is not None else _DEFAULT_HOOK_TIMEOUT
|
||||||
|
|
||||||
|
async def _safe_invoke(reg: HookRegistration) -> None:
|
||||||
|
async with self._fire_sem:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(reg.callback(*args, **kwargs), timeout=effective_timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(
|
||||||
|
"Hook callback timed out after %.1fs: event=%s key=%s",
|
||||||
|
effective_timeout,
|
||||||
|
event,
|
||||||
|
reg.key,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Hook callback failed: event=%s key=%s", event, reg.key)
|
||||||
|
|
||||||
|
await asyncio.gather(*(_safe_invoke(r) for r in enabled))
|
||||||
|
|
||||||
|
if self._event_bus is not None:
|
||||||
|
await self._forward_to_bus(event, *args, **kwargs)
|
||||||
|
|
||||||
|
async def fire_sequential(self, event: HookEvent | str, initial: Any = None, *args: Any, timeout: float | None = None, **kwargs: Any) -> Any:
|
||||||
|
async with self._get_lock(event):
|
||||||
|
registrations = list(self._hooks.get(event, []))
|
||||||
|
if not registrations:
|
||||||
|
return initial
|
||||||
|
|
||||||
|
effective_timeout = timeout if timeout is not None else _DEFAULT_HOOK_TIMEOUT
|
||||||
|
result = initial
|
||||||
|
for reg in registrations:
|
||||||
|
if not reg.enabled:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
next_result = await asyncio.wait_for(reg.callback(result, *args, **kwargs), timeout=effective_timeout)
|
||||||
|
if next_result is not None:
|
||||||
|
result = next_result
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(
|
||||||
|
"Hook callback timed out after %.1fs: event=%s key=%s",
|
||||||
|
effective_timeout,
|
||||||
|
event,
|
||||||
|
reg.key,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Hook callback failed: event=%s key=%s", event, reg.key)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def fire_first(self, event: HookEvent | str, *args: Any, timeout: float | None = None, **kwargs: Any) -> Any:
|
||||||
|
async with self._get_lock(event):
|
||||||
|
registrations = list(self._hooks.get(event, []))
|
||||||
|
if not registrations:
|
||||||
|
return None
|
||||||
|
|
||||||
|
effective_timeout = timeout if timeout is not None else _DEFAULT_HOOK_TIMEOUT
|
||||||
|
|
||||||
|
for reg in registrations:
|
||||||
|
if not reg.enabled:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(reg.callback(*args, **kwargs), timeout=effective_timeout)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(
|
||||||
|
"Hook callback timed out after %.1fs: event=%s key=%s",
|
||||||
|
effective_timeout,
|
||||||
|
event,
|
||||||
|
reg.key,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Hook callback failed: event=%s key=%s", event, reg.key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def clear(self) -> None:
|
||||||
|
async with asyncio.Lock():
|
||||||
|
for lock in self._locks.values():
|
||||||
|
async with lock:
|
||||||
|
pass
|
||||||
|
self._hooks.clear()
|
||||||
|
self._locks.clear()
|
||||||
|
|
||||||
|
async def _forward_to_bus(self, event: str, *args: Any, **kwargs: Any) -> None:
|
||||||
|
if self._event_bus is None:
|
||||||
|
return
|
||||||
|
from yuxi.channel.events.types import HOOK_EVENT_TO_TOPIC
|
||||||
|
|
||||||
|
topic = HOOK_EVENT_TO_TOPIC.get(event)
|
||||||
|
if topic is None:
|
||||||
|
logger.debug("No event bus topic mapping for hook event: %s", event)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self._event_bus.publish(topic, *args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Event bus forward failed: hook=%s topic=%s", event, topic)
|
||||||
|
|
||||||
|
def list_hooks(self, event: HookEvent | str | None = None) -> dict[str, list[HookRegistration]]:
|
||||||
|
if event is not None:
|
||||||
|
return {event: list(self._hooks.get(event, []))}
|
||||||
|
return {k: list(v) for k, v in self._hooks.items()}
|
||||||
53
backend/package/yuxi/channel/hooks/types.py
Normal file
53
backend/package/yuxi/channel/hooks/types.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class SkipReason(StrEnum):
|
||||||
|
OS_INCOMPATIBLE = "os_incompatible"
|
||||||
|
DEPENDENCY_MISSING = "dependency_missing"
|
||||||
|
NO_CALLBACK = "no_callback"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookConfigField:
|
||||||
|
type: str = "str"
|
||||||
|
default: Any = None
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookDependencies:
|
||||||
|
bin: list[str] = field(default_factory=list)
|
||||||
|
env: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookMetadata:
|
||||||
|
hook_id: str
|
||||||
|
name: str
|
||||||
|
events: list[str]
|
||||||
|
description: str = ""
|
||||||
|
os: list[str] = field(default_factory=list)
|
||||||
|
depends: HookDependencies = field(default_factory=HookDependencies)
|
||||||
|
config: dict[str, HookConfigField] = field(default_factory=dict)
|
||||||
|
body: str = ""
|
||||||
|
source_dir: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"hook_id": self.hook_id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"events": self.events,
|
||||||
|
"os": self.os,
|
||||||
|
"depends": {
|
||||||
|
"bin": self.depends.bin,
|
||||||
|
"env": self.depends.env,
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
k: {"type": v.type, "default": v.default, "description": v.description}
|
||||||
|
for k, v in self.config.items()
|
||||||
|
},
|
||||||
|
"source_dir": self.source_dir,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user