mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 04:00:44 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
247
runtime/hooks/script_handlers.py
Normal file
247
runtime/hooks/script_handlers.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .internal_hooks import HookEvent, HookHandler
|
||||
|
||||
log = logging.getLogger("oclaw.hooks")
|
||||
|
||||
# Max wall-clock for external hook child processes
|
||||
_HOOK_SUBPROCESS_TIMEOUT_S = 120.0
|
||||
|
||||
|
||||
def _runner_ts_path() -> Path:
|
||||
return Path(__file__).resolve().parent / "ts_hook_runner.ts"
|
||||
|
||||
|
||||
def _runner_js_path() -> Path:
|
||||
return Path(__file__).resolve().parent / "js_hook_runner.mjs"
|
||||
|
||||
|
||||
def _is_script_mode(oclaw: dict[str, Any] | None) -> bool:
|
||||
o = oclaw or {}
|
||||
if o.get("hookMode") == "script":
|
||||
return True
|
||||
if o.get("nodeScript") is True:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _event_payload(event: HookEvent) -> dict[str, Any]:
|
||||
return {
|
||||
"type": event.type,
|
||||
"action": event.action,
|
||||
"sessionKey": event.sessionKey,
|
||||
"context": dict(event.context) if isinstance(event.context, dict) else {},
|
||||
"messages": list(getattr(event, "messages", []) or []),
|
||||
"timestamp": event.timestamp.isoformat() if getattr(event, "timestamp", None) else None,
|
||||
}
|
||||
|
||||
|
||||
def _merge_stdout_into_context(event: HookEvent, raw: str) -> None:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
out = json.loads(text)
|
||||
except Exception:
|
||||
log.warning("Hook subprocess stdout is not valid JSON: %r", text[:200])
|
||||
return
|
||||
if not isinstance(out, dict):
|
||||
return
|
||||
ctx = out.get("context")
|
||||
if isinstance(ctx, dict) and isinstance(event.context, dict):
|
||||
event.context.update(ctx)
|
||||
|
||||
|
||||
def _sh_command(script: Path) -> list[str] | None:
|
||||
p = str(script)
|
||||
if not script.is_file():
|
||||
return None
|
||||
if os.name == "nt":
|
||||
bash = shutil.which("bash")
|
||||
if bash:
|
||||
return [bash, p]
|
||||
wsl = shutil.which("wsl")
|
||||
if wsl:
|
||||
return [wsl, "bash", p]
|
||||
log.warning("Hook .sh on Windows needs bash in PATH (Git for Windows) or wsl. Skipping %s", p)
|
||||
return None
|
||||
try:
|
||||
if script.stat().st_mode & 0o111 and os.access(p, os.X_OK):
|
||||
return [p]
|
||||
except OSError:
|
||||
pass
|
||||
sh = shutil.which("sh") or "/bin/sh"
|
||||
return [sh, p]
|
||||
|
||||
|
||||
def _tsx_invocation() -> str | None:
|
||||
return shutil.which("tsx") or None
|
||||
|
||||
|
||||
def _ts_command(*, script: Path, export_name: str) -> list[str] | None:
|
||||
runner = _runner_ts_path()
|
||||
if not runner.is_file():
|
||||
log.error("oclaw: missing ts hook runner: %s", runner)
|
||||
return None
|
||||
hp = str(script.resolve())
|
||||
rts = str(runner.resolve())
|
||||
ex = export_name.strip() or "default"
|
||||
tx = _tsx_invocation()
|
||||
if tx:
|
||||
return [tx, rts, hp, ex]
|
||||
npx = shutil.which("npx")
|
||||
if npx:
|
||||
return [npx, "--yes", "tsx", rts, hp, ex]
|
||||
log.warning("Hook .ts needs `tsx` or `npx` (for `npx tsx`) on PATH. Skipping %s", hp)
|
||||
return None
|
||||
|
||||
|
||||
def _ts_script_command(*, script: Path) -> list[str] | None:
|
||||
"""Run .ts as a free script: stdin JSON / stdout JSON (no import runner)."""
|
||||
hp = str(script.resolve())
|
||||
tx = _tsx_invocation()
|
||||
if tx:
|
||||
return [tx, hp]
|
||||
npx = shutil.which("npx")
|
||||
if npx:
|
||||
return [npx, "--yes", "tsx", hp]
|
||||
log.warning("Hook .ts in script mode needs `tsx` or `npx` on PATH. Skipping %s", hp)
|
||||
return None
|
||||
|
||||
|
||||
def _node_path() -> str | None:
|
||||
return shutil.which("node") or None
|
||||
|
||||
|
||||
def _js_module_command(*, script: Path, export_name: str) -> list[str] | None:
|
||||
node = _node_path()
|
||||
if not node:
|
||||
log.warning("Hook .mjs / .cjs needs `node` on PATH. Skipping %s", script)
|
||||
return None
|
||||
runner = _runner_js_path()
|
||||
if not runner.is_file():
|
||||
log.error("oclaw: missing js hook runner: %s", runner)
|
||||
return None
|
||||
hp = str(script.resolve())
|
||||
rjs = str(runner.resolve())
|
||||
ex = export_name.strip() or "default"
|
||||
return [node, rjs, hp, ex]
|
||||
|
||||
|
||||
def _js_script_command(*, script: Path) -> list[str] | None:
|
||||
node = _node_path()
|
||||
if not node:
|
||||
return None
|
||||
return [node, str(script.resolve())]
|
||||
|
||||
|
||||
async def _run_cmd_handler(
|
||||
*,
|
||||
cmd: list[str],
|
||||
event: HookEvent,
|
||||
base_dir: str,
|
||||
script: Path,
|
||||
log_label: str,
|
||||
) -> None:
|
||||
data = json.dumps(_event_payload(event), default=str)
|
||||
env = {**os.environ, "OCLAW_HOOK_DIR": str(Path(base_dir).resolve()), "OCLAW_HOOK_HANDLER": str(script.resolve())}
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path(base_dir).resolve()),
|
||||
env=env,
|
||||
)
|
||||
out_b, err_b = await asyncio.wait_for(
|
||||
proc.communicate(input=data.encode("utf-8")),
|
||||
timeout=_HOOK_SUBPROCESS_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.error("Hook %s timed out after %ss: %s", log_label, int(_HOOK_SUBPROCESS_TIMEOUT_S), script)
|
||||
return
|
||||
except Exception:
|
||||
log.exception("Hook %s failed to spawn: %s", log_label, script)
|
||||
return
|
||||
if proc.returncode != 0:
|
||||
log.error(
|
||||
"Hook %s exit %s: %s",
|
||||
log_label,
|
||||
proc.returncode,
|
||||
err_b.decode("utf-8", errors="replace")[:4000],
|
||||
)
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("Hook %s full stderr: %s", log_label, err_b)
|
||||
return
|
||||
out_t = out_b.decode("utf-8", errors="replace")
|
||||
if not (out_t or "").strip() and err_b:
|
||||
log.warning(
|
||||
"Hook %s empty stdout, stderr: %s",
|
||||
log_label,
|
||||
err_b.decode("utf-8", errors="replace")[:2000],
|
||||
)
|
||||
_merge_stdout_into_context(event, out_t)
|
||||
|
||||
|
||||
def _build_cmd_handler(
|
||||
cmd: list[str] | None,
|
||||
*,
|
||||
base_dir: str,
|
||||
script: Path,
|
||||
log_label: str,
|
||||
) -> Optional[HookHandler]:
|
||||
if not cmd:
|
||||
return None
|
||||
|
||||
async def _handler(event: HookEvent) -> None:
|
||||
await _run_cmd_handler(cmd=cmd, event=event, base_dir=base_dir, script=script, log_label=log_label)
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
def build_script_hook_handler(
|
||||
*,
|
||||
handler_path: str,
|
||||
base_dir: str,
|
||||
suffix: str,
|
||||
export_name: str,
|
||||
oclaw: dict[str, Any] | None = None,
|
||||
) -> Optional[HookHandler]:
|
||||
p = Path(handler_path)
|
||||
if not p.is_file():
|
||||
return None
|
||||
sfx = (suffix or "").lower()
|
||||
o = oclaw or {}
|
||||
script_mode = _is_script_mode(o)
|
||||
|
||||
# Shell: always JSON stdin/stdout; never use TS/JS import runners
|
||||
if sfx in {".sh", ".bash"}:
|
||||
sc = _sh_command(p)
|
||||
return _build_cmd_handler(sc, base_dir=base_dir, script=p, log_label="sh")
|
||||
|
||||
if script_mode:
|
||||
if sfx in {".ts", ".mts", ".cts"}:
|
||||
cmd = _ts_script_command(script=p)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="ts:script")
|
||||
if sfx in {".mjs", ".cjs"}:
|
||||
cmd = _js_script_command(script=p)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="js:script")
|
||||
|
||||
# Module / import path (default for ts and js)
|
||||
if sfx in {".ts", ".mts", ".cts"}:
|
||||
cmd = _ts_command(script=p, export_name=export_name)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="ts:module")
|
||||
if sfx in {".mjs", ".cjs"}:
|
||||
cmd = _js_module_command(script=p, export_name=export_name)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="js:module")
|
||||
|
||||
return None
|
||||
Loading…
Add table
Add a link
Reference in a new issue