mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-18 05:53:14 +08:00
refactor: root-package imports (svc/runtime/interfaces) and fix PYTHONPATH
- Rename platform/ to svc/ to avoid shadowing stdlib platform. - Replace from oclaw.* with from svc/runtime/interfaces; update -m CLI paths. - tests/conftest: prepend repo root to sys.path (no parent-folder package name). - CI: paths and offline_eval script under repo root. - Ops scripts: PYTHONPATH must be repo root for python -m runtime.* (fixes gateway/WhatsApp sidecar startup). - Fix default oclaw.json path in tabular/file attachment limits; stabilize attachment test config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7941b27f58
commit
420abac9f1
405 changed files with 1316 additions and 1345 deletions
0
svc/config/__init__.py
Normal file
0
svc/config/__init__.py
Normal file
90
svc/config/bootstrap_env.py
Normal file
90
svc/config/bootstrap_env.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Load optional env files before the rest of the app reads ``os.environ``.
|
||||
|
||||
Only ``_local/system.env`` (next to the committed template ``_local/system.env.example``) is read.
|
||||
Variables already set in the process environment are never overwritten (shell/export wins).
|
||||
|
||||
新增进程环境变量时:必须在 ``_local/system.env.example`` 用中文登记说明(见该文件顶部的仓库约定)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_LOADED = False
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _parse_env_file(path: Path) -> dict[str, str | None]:
|
||||
out: dict[str, str | None] = {}
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return out
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
if s.startswith("export "):
|
||||
s = s[7:].strip()
|
||||
if "=" not in s:
|
||||
continue
|
||||
k, _, rest = s.partition("=")
|
||||
key = k.strip()
|
||||
if not key:
|
||||
continue
|
||||
val = rest.strip()
|
||||
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
|
||||
val = val[1:-1]
|
||||
out[key] = val
|
||||
return out
|
||||
|
||||
|
||||
def load_system_env(*, force: bool = False) -> list[str]:
|
||||
"""Merge env files into ``os.environ`` for keys not already set.
|
||||
|
||||
Returns the list of existing files that contributed (merged order).
|
||||
"""
|
||||
global _LOADED
|
||||
if _LOADED and not force:
|
||||
return []
|
||||
|
||||
root = _project_root()
|
||||
candidates = [root / "_local" / "system.env"]
|
||||
|
||||
merged: dict[str, str | None] = {}
|
||||
dotenv_values = None
|
||||
try:
|
||||
from dotenv import dotenv_values as _dv # type: ignore
|
||||
|
||||
dotenv_values = _dv
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
loaded_paths: list[str] = []
|
||||
for p in candidates:
|
||||
if not p.is_file():
|
||||
continue
|
||||
loaded_paths.append(str(p.resolve()))
|
||||
if dotenv_values is not None:
|
||||
vals = dotenv_values(p)
|
||||
for k, v in vals.items():
|
||||
merged[str(k)] = v
|
||||
else:
|
||||
merged.update(_parse_env_file(p))
|
||||
|
||||
for key, val in merged.items():
|
||||
if not key or key in os.environ:
|
||||
continue
|
||||
if val is None:
|
||||
continue
|
||||
os.environ[str(key)] = str(val)
|
||||
|
||||
_LOADED = True
|
||||
return loaded_paths
|
||||
|
||||
|
||||
__all__ = ["load_system_env"]
|
||||
25
svc/config/passwords.py
Normal file
25
svc/config/passwords.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""访问密码解析(环境变量 + 数据库);Streamlit secrets 在认证模块中处理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
_DEFAULT_BOOTSTRAP_PASSWORD = "admin123"
|
||||
|
||||
|
||||
def load_expected_password(store: SqliteStore, *, extra_candidate: str | None = None) -> str | None:
|
||||
pwd = (os.getenv("AIA_ASSISTANT_PASSWORD") or "").strip()
|
||||
if not pwd:
|
||||
pwd = (os.getenv("OPS_ASSISTANT_PASSWORD") or "").strip()
|
||||
if not pwd and extra_candidate:
|
||||
pwd = extra_candidate.strip()
|
||||
if not pwd:
|
||||
pwd = (store.get_secret("auth_password") or "").strip()
|
||||
if not pwd:
|
||||
pwd = _DEFAULT_BOOTSTRAP_PASSWORD
|
||||
return pwd if pwd else None
|
||||
|
||||
|
||||
__all__ = ["load_expected_password"]
|
||||
200
svc/config/paths.py
Normal file
200
svc/config/paths.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""仓库与数据文件路径解析(与框架无关,供 ``db`` / UI 等共用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
# Source layout: <repo>/svc/config/paths.py
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
PROJECT_ROOT = _project_root()
|
||||
_DATA_MIGRATION_DONE = False
|
||||
|
||||
|
||||
def _canonical_data_root() -> Path:
|
||||
# Compatibility: some callers/tests set PROJECT_ROOT to repo parent while
|
||||
# data lives under "<root>/oclaw/data".
|
||||
nested = (PROJECT_ROOT / "oclaw" / "data").resolve()
|
||||
if nested.exists():
|
||||
return nested
|
||||
return (PROJECT_ROOT / "data").resolve()
|
||||
|
||||
|
||||
def _legacy_platform_data_root() -> Path:
|
||||
return (PROJECT_ROOT / "src" / "platform" / "data").resolve()
|
||||
|
||||
|
||||
def _legacy_root_data_root() -> Path:
|
||||
return (PROJECT_ROOT.parent / "data").resolve()
|
||||
|
||||
|
||||
def _backup_keep_count() -> int:
|
||||
raw = (os.getenv("AIA_ASSISTANT_PREMERGE_BACKUP_KEEP") or os.getenv("OPS_ASSISTANT_PREMERGE_BACKUP_KEEP") or "").strip()
|
||||
if not raw:
|
||||
return 3
|
||||
try:
|
||||
val = int(raw)
|
||||
except Exception:
|
||||
return 3
|
||||
return max(0, val)
|
||||
|
||||
|
||||
def _cleanup_pre_merge_backups() -> None:
|
||||
root = _canonical_data_root()
|
||||
if not root.exists():
|
||||
return
|
||||
keep = _backup_keep_count()
|
||||
backups = sorted([p for p in root.glob("_pre_merge_sqlite_*") if p.is_dir()], key=lambda p: p.name, reverse=True)
|
||||
for old in backups[keep:]:
|
||||
try:
|
||||
shutil.rmtree(old)
|
||||
except Exception as exc:
|
||||
warnings.warn(f"自动清理旧备份失败(已跳过): {old} ({exc})", RuntimeWarning, stacklevel=1)
|
||||
|
||||
|
||||
def attachments_dir() -> Path:
|
||||
p = (_canonical_data_root() / "attachments").resolve()
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _sqlite_sidecars(main: Path) -> list[Path]:
|
||||
return [main, Path(str(main) + "-wal"), Path(str(main) + "-shm")]
|
||||
|
||||
|
||||
def _sqlite_chat_session_count(db: Path) -> int | None:
|
||||
if not db.exists():
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=3.0)
|
||||
try:
|
||||
r = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='chat_session'").fetchone()
|
||||
if not r or int(r[0] or 0) == 0:
|
||||
return 0
|
||||
row = conn.execute("SELECT COUNT(*) FROM chat_session").fetchone()
|
||||
return int(row[0] or 0) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _checkpoint_wal_best_effort(main: Path) -> None:
|
||||
try:
|
||||
conn = sqlite3.connect(str(main), timeout=5.0)
|
||||
try:
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _copy_sqlite_bundle(src_main: Path, dst_main: Path) -> None:
|
||||
dst_main.parent.mkdir(parents=True, exist_ok=True)
|
||||
_checkpoint_wal_best_effort(src_main)
|
||||
for s in _sqlite_sidecars(src_main):
|
||||
if not s.exists():
|
||||
continue
|
||||
d = dst_main.parent / s.name
|
||||
if s.resolve() == d.resolve():
|
||||
continue
|
||||
shutil.copy2(s, d)
|
||||
|
||||
|
||||
def _backup_sqlite_bundle_copy(main: Path, dest_dir: Path) -> None:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
_checkpoint_wal_best_effort(main)
|
||||
for s in _sqlite_sidecars(main):
|
||||
if s.exists():
|
||||
shutil.copy2(s, dest_dir / s.name)
|
||||
|
||||
|
||||
def _merge_attachment_tree(src: Path, dst: Path) -> int:
|
||||
if not src.is_dir():
|
||||
return 0
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
copied = 0
|
||||
for f in src.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
rel = f.relative_to(src)
|
||||
target = dst / rel
|
||||
if target.exists():
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(f, target)
|
||||
copied += 1
|
||||
return copied
|
||||
|
||||
|
||||
def _run_default_data_migration(canonical_db: Path) -> None:
|
||||
legacy_root_data = _legacy_root_data_root()
|
||||
legacy_root_db = legacy_root_data / "ai_ops.sqlite"
|
||||
if legacy_root_db.exists() and not canonical_db.exists():
|
||||
_copy_sqlite_bundle(legacy_root_db, canonical_db)
|
||||
_merge_attachment_tree(legacy_root_data / "attachments", attachments_dir())
|
||||
|
||||
legacy_platform_data = _legacy_platform_data_root()
|
||||
legacy_db = legacy_platform_data / "ai_ops.sqlite"
|
||||
if legacy_db.exists():
|
||||
leg_n = _sqlite_chat_session_count(legacy_db)
|
||||
if leg_n is not None:
|
||||
if not canonical_db.exists():
|
||||
_copy_sqlite_bundle(legacy_db, canonical_db)
|
||||
else:
|
||||
can_n = _sqlite_chat_session_count(canonical_db)
|
||||
can_n = 0 if can_n is None else can_n
|
||||
force = str(os.getenv("AIA_LEGACY_DB_FORCE_PREMERGE") or os.getenv("OPS_LEGACY_DB_FORCE_PREMERGE") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
if force and leg_n > can_n:
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
bak = _canonical_data_root() / f"_pre_merge_sqlite_{ts}"
|
||||
try:
|
||||
_checkpoint_wal_best_effort(canonical_db)
|
||||
_backup_sqlite_bundle_copy(canonical_db, bak)
|
||||
_copy_sqlite_bundle(legacy_db, canonical_db)
|
||||
except (OSError, PermissionError, shutil.Error) as exc:
|
||||
warnings.warn(
|
||||
"未能用旧库覆盖当前主库(可能被其它进程占用)。"
|
||||
f"已备份当前库到: {bak}。可关闭占用后重启,或手动合并。原因: {exc}",
|
||||
RuntimeWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
_merge_attachment_tree(legacy_platform_data / "attachments", attachments_dir())
|
||||
_cleanup_pre_merge_backups()
|
||||
|
||||
|
||||
def db_path() -> str:
|
||||
global _DATA_MIGRATION_DONE
|
||||
p = os.getenv("AIA_ASSISTANT_DB_PATH") or os.getenv("OPS_ASSISTANT_DB_PATH") or "data/ai_ops.sqlite"
|
||||
path = Path(p)
|
||||
if not path.is_absolute():
|
||||
path = (PROJECT_ROOT / path).resolve()
|
||||
use_default = not (os.getenv("AIA_ASSISTANT_DB_PATH") or os.getenv("OPS_ASSISTANT_DB_PATH"))
|
||||
if use_default and not _DATA_MIGRATION_DONE:
|
||||
_DATA_MIGRATION_DONE = True
|
||||
try:
|
||||
_run_default_data_migration(path)
|
||||
except Exception as exc:
|
||||
warnings.warn(f"默认数据目录迁移未完全成功(可稍后重试或检查权限): {exc}", RuntimeWarning, stacklevel=1)
|
||||
return str(path)
|
||||
|
||||
|
||||
__all__ = ["PROJECT_ROOT", "attachments_dir", "db_path"]
|
||||
57
svc/config/runtime_paths.py
Normal file
57
svc/config/runtime_paths.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from svc.config.paths import PROJECT_ROOT
|
||||
|
||||
|
||||
def oclaw_root() -> Path:
|
||||
return Path(PROJECT_ROOT).resolve()
|
||||
|
||||
|
||||
def runtime_root() -> Path:
|
||||
return (oclaw_root() / "runtime").resolve()
|
||||
|
||||
|
||||
def runtime_skills_root() -> Path:
|
||||
"""Canonical skills tree lives at repo ``skills/``; ``runtime/skills`` remains a migration fallback."""
|
||||
root_skills = (oclaw_root() / "skills").resolve()
|
||||
legacy_runtime_skills = (runtime_root() / "skills").resolve()
|
||||
if root_skills.is_dir():
|
||||
return root_skills
|
||||
if legacy_runtime_skills.is_dir():
|
||||
return legacy_runtime_skills
|
||||
return root_skills
|
||||
|
||||
|
||||
def runtime_hooks_root() -> Path:
|
||||
return (runtime_root() / "hooks").resolve()
|
||||
|
||||
|
||||
def runtime_hooks_bundled_root() -> Path:
|
||||
return (runtime_hooks_root() / "bundled").resolve()
|
||||
|
||||
|
||||
def runtime_extensions_root() -> Path:
|
||||
return (runtime_root() / "extensions").resolve()
|
||||
|
||||
|
||||
def runtime_operations_scripts_root() -> Path:
|
||||
return (runtime_root() / "operations" / "scripts").resolve()
|
||||
|
||||
|
||||
def ws_protocol_schemas_root() -> Path:
|
||||
return (oclaw_root() / "interfaces" / "ws" / "protocol_schemas").resolve()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"oclaw_root",
|
||||
"runtime_root",
|
||||
"runtime_skills_root",
|
||||
"runtime_hooks_root",
|
||||
"runtime_hooks_bundled_root",
|
||||
"runtime_extensions_root",
|
||||
"runtime_operations_scripts_root",
|
||||
"ws_protocol_schemas_root",
|
||||
]
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue