From 3dc6ca6ab9a1848e0345a03817e1baa547348d43 Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Sun, 4 Oct 2026 12:34:45 +0000 Subject: [PATCH] =?UTF-8?q?t-261004113351:=20=D0=B8=D0=B7=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F=D1=86=D0=B8=D1=8F=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D0=B8?= =?UTF-8?q?=20=D0=B8=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=81=D0=B0=20=E2=80=94?= =?UTF-8?q?=20SIMBIOT=5FHOME=20(=D1=80=D0=B5=D0=BF=D0=BE=20=3D=20=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - harness/paths.py: единая карта персистентных путей; SIMBIOT_HOME → db/ rag/ env/ docs/ digests/ audit/ inbox/ run/ voice/; без него — legacy-пути без изменений; точечные env (SIMBIOT_DB и пр.) сильнее схемы - es/tg_inbox/voice/portal_channel/aum/audit/runtime/rag_lookup/isolated_ runner/cli: все записи-чтения памяти через paths - scripts/init_instance.py: новый инстанс — чистая база + идентичность владельца + его доки (+RAG) + селективный импорт выжимок/доков - tests/test_instance_isolation.py: копия с пустой памятью не вспоминает наши проекты (контекст тика/RAG/прод — 14 проверок ALL PASS, регресс 7/7) - scripts/migrate_to_home.sh: перенос боевого инстанса в хом (тихое окно, идемпотентно, RAG-корпус коопа сохраняется точечным SIMBIOT_RAG_DB) - install.sh/service/README: свежие установки сразу с ~/.simbiot-data --- .gitignore | 4 + README.md | 36 ++++++ cli.py | 114 +++++++++++++++++- harness/audit.py | 3 +- harness/aum.py | 5 +- harness/es.py | 14 ++- harness/isolated_runner.py | 5 +- harness/paths.py | 143 ++++++++++++++++++++++ harness/portal_channel.py | 38 ++++-- harness/rag_lookup.py | 7 +- harness/runtime.py | 3 +- harness/tg_inbox.py | 11 +- harness/voice.py | 8 +- scripts/init_instance.py | 196 +++++++++++++++++++++++++++++++ scripts/install.sh | 12 +- scripts/migrate_to_home.sh | 140 ++++++++++++++++++++++ scripts/simbiot-harness.service | 2 + tests/test_instance_isolation.py | 137 +++++++++++++++++++++ 18 files changed, 856 insertions(+), 22 deletions(-) create mode 100644 harness/paths.py create mode 100755 scripts/init_instance.py create mode 100755 scripts/migrate_to_home.sh create mode 100644 tests/test_instance_isolation.py diff --git a/.gitignore b/.gitignore index 32092a4..5e64699 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ harness/harness_es.db harness/hg_tg_inbox.db hg_tg_inbox.db store.db +# изоляция памяти (t-261004113351): локальный хом, если запустили без env +.simbiot-data/ +harness/channel_*.json +harness/channel_*.json.tmp diff --git a/README.md b/README.md index 870fca8..e1bbde1 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,44 @@ SIMBIOT_TG_ALLOWED, ROUTER_KEY, SIMBIOT_MODEL и др.); в systemd-режиме (глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`) - `harness/commands.py` — command side - `harness/tg_commands.py` — без-LLM команды бота (сек-рефлекс) +- `harness/paths.py` — карта персистентных путей (SIMBIOT_HOME) - `cli.py` — init/task/task-await/tasks/status/events/requeue +## Изоляция памяти инстанса — SIMBIOT_HOME (t-261004113351) + +Память агента — данные, не код: при переносе инстанса другому владельцу +(мультитенантность) он не должен «думать» чужими проектами. Всё, что живёт +дольше процесса — ES-база, RAG-индекс знаний, `.env`, ключи аудита, выжимки, +штампы каналов, доки инстанса — размещается в одном каталоге-хоме, репо +остаётся чистым кодом. Свежие установки: `~/.simbiot-data` (install.sh +проставляет в обёртку и systemd-юнит автоматически). + +``` +SIMBIOT_HOME=/path/to/home venv/bin/python cli.py status +``` + +| файл | назначение | +|---|---| +| `/db/harness.db` | ES-база (события, проекции, delivery-леджер) | +| `/rag/rag.db` | RAG-индекс знаний инстанса | +| `/env/.env` | секреты (токены, ключи роутера), 0600 | +| `/docs/` | проектные документы владельца (aum/balances.json) | +| `/digests/` | файлы-выжимки уровней | +| `/audit/` | ключи крипто-аудита ed25519 | +| `/inbox/`, `/run/`, `/voice/` | каналы, штампы, кэш ASR | + +Приоритет: точечная env (`SIMBIOT_DB`, `SIMBIOT_RAG_DB`, …) → `SIMBIOT_HOME` +→ legacy-пути из корня репо (обратная совместимость: не задан `SIMBIOT_HOME` +— всё работает по-старому). + +Новый инстанс (например, для Юры): `scripts/init_instance.py` — чистая база + +идентичность владельца + его доки (+RAG-индексация) + опциональный селективный +импорт выжимок/доков из инстанса-источника. Тест изоляции: +`tests/test_instance_isolation.py` (поднять копию с пустой памятью — контекст +тика и RAG не содержат чужих проектов, 14 проверок). Перенос существующего +инстанса в хом: `scripts/migrate_to_home.sh` (ждёт тихое окно без LLM-воркеров, +идемпотентен). + ## CLI ```bash diff --git a/cli.py b/cli.py index e8c8d89..d1026bf 100644 --- a/cli.py +++ b/cli.py @@ -7,6 +7,7 @@ import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from harness import commands, es +from harness import paths as _paths # t-261004113351-2858c1: .env инстанса def main(): @@ -38,6 +39,10 @@ def main(): p_cl.add_argument("task_id") p_cl.add_argument("-r", "--reason", default="manual close") + p_bl = sub.add_parser("block", help="заблокировать живую задачу (ждёт уточнений); requeue вернёт в очередь") + p_bl.add_argument("task_id") + p_bl.add_argument("-r", "--reason", default="ожидает уточнений") + p_cmp = sub.add_parser("complete", help="закрыть running-задачу с результатом (батч-тик, t-260930101325)") p_cmp.add_argument("task_id") p_cmp.add_argument("-a", "--answer", required=True, help="результат задачи") @@ -149,6 +154,23 @@ def main(): help="модель уровней (по умолчанию auto — как в harness/levels.py)") p_wiz.add_argument("--no-init", action="store_true", help="не инициализировать стор") + # Подключение Telegram без полного визарда (просьба Alex, 2026-10-04): + # токен/чаты в .env + самотест getMe + рестарт TG-поллера + меню команд. + p_tgc2 = sub.add_parser("tg-connect", + help="подключить Telegram: токен бота в .env, самотест, рестарт поллера") + p_tgc2.add_argument("token", nargs="?", default=None, + help="токен TG-бота (SIMBIOT_TG_TOKEN)") + p_tgc2.add_argument("--token", dest="token_opt", default=None, + help="токен TG-бота флагом (то же, что позиционным)") + p_tgc2.add_argument("--allowed", default=None, + help="разрешённые chat-id через запятую (SIMBIOT_TG_ALLOWED)") + p_tgc2.add_argument("--no-restart", action="store_true", + help="не перезапускать simbiot-harness-tg (токен подхватится при следующем рестарте)") + p_tgc2.add_argument("--no-commands", action="store_true", + help="не регистрировать меню команд в чате (setMyCommands)") + p_tgs2 = sub.add_parser("tg-status", + help="состояние Telegram-подключения: токен, чаты, поллер, меню бота") + p_rag = sub.add_parser("rag-query", help="семантический поиск по базе кооператива (вендор /root/rag)") p_rag.add_argument("question") p_rag.add_argument("-k", type=int, default=5, help="сколько чанков (<=5)") @@ -164,6 +186,10 @@ def main(): elif args.cmd == "wizard": _run_wizard(args) + elif args.cmd == "tg-connect": + _run_tg_connect(args) + elif args.cmd == "tg-status": + _run_tg_status(args) elif args.cmd == "task": # t-261003125513/e-261003125707: голое 'list'/'tasks'/'task list' — # показать доску, а не плодить задачи-клоны @@ -205,6 +231,11 @@ def main(): es.init_db() tid = commands.close_task(args.task_id, args.reason) print(f"OK: задача {tid} закрыта (closed — архив; requeue вернёт в очередь)") + elif args.cmd == "block": + # t-261004122019: blocked — задача есть, но не ready (ждёт уточнений) + es.init_db() + tid = commands.block_task(args.task_id, args.reason) + print(f"OK: задача {tid} заблокирована (blocked; requeue вернёт в очередь)") elif args.cmd == "promote": es.init_db() tid, new_size = commands.promote_task(args.task_id, args.reason) @@ -543,8 +574,89 @@ def _tg_selftest(token: str) -> str: return f"ОШИБКА ({e.__class__.__name__}) — токен не принят, но запись в .env сделал" +# ============================================================================ +# Подключение Telegram из CLI (просьба Alex, 2026-10-04): раньше токен был +# только внутри интерактивного wizard'а — теперь точечные команды. +# ============================================================================ +def _tg_restart_poller() -> str: + """Рестарт TG-поллера, если он под systemd (иначе подхватит при след. старте).""" + import subprocess + if not os.path.exists("/run/systemd/system"): + return "systemd не обнаружен — поллер подхватит токен при следующем старте" + r = subprocess.run(["systemctl", "restart", "simbiot-harness-tg"], + capture_output=True, text=True, timeout=30) + if r.returncode == 0: + return "OK: simbiot-harness-tg перезапущен — читает токен из .env" + return f"рестарт pollera не удался: {(r.stderr or r.stdout).strip()[:200]}" + + +def _run_tg_connect(args) -> None: + token = (args.token or getattr(args, "token_opt", None) or "").strip() + env_path = _paths.env_path() + vals = {} + if token: + vals["SIMBIOT_TG_TOKEN"] = token + if args.allowed is not None and args.allowed.strip(): + vals["SIMBIOT_TG_ALLOWED"] = args.allowed.strip() + if not vals: + cur = _dotenv_get(env_path, "SIMBIOT_TG_TOKEN") + print("Токен не передан." if not cur + else f"Токен уже задан ({_mask(cur)}). Передай его аргументом: " + "simbiot tg-connect <ТОКЕН> [--allowed 123,456]") + return + if "SIMBIOT_TG_TOKEN" in vals: + print(f" TG-токен: {_tg_selftest(vals['SIMBIOT_TG_TOKEN'])}") + for key, val in vals.items(): + _dotenv_set(env_path, key, val) + print(f" .env ← {key}={_mask(val) if 'TOKEN' in key else val}") + if not args.no_commands: + from harness import tg_commands + reg = tg_commands.register_bot_commands() + print(" меню команд: " + ({"cyrillic": "зарегистрировано (кириллица)", + "latin": "зарегистрировано (транслит)"}.get(reg, "не удалось")) + if reg else " меню команд: пропущено (нет токена/недоступен TG)") + if not args.no_restart: + print(" " + _tg_restart_poller()) + + +def _run_tg_status(args) -> None: + import subprocess + import urllib.request + + from harness import tg_inbox + tg_inbox._load_env() + tok = tg_inbox.TG_TOKEN + print("🔌 Telegram-подключение") + if not tok: + print(" токен: НЕ задан — simbiot tg-connect <ТОКЕН>") + return + print(f" токен: задан ({_mask(tok)})") + try: + with urllib.request.urlopen(f"https://api.telegram.org/bot{tok}/getMe", timeout=5) as r: + me = json.loads(r.read().decode()).get("result", {}) + print(f" getMe: ок, бот @{me.get('username')} (id {me.get('id')})") + except Exception as e: + print(f" getMe: ОШИБКА ({e.__class__.__name__}) — токен не принимается") + allowed = sorted(tg_inbox.ALLOWED) + print(" разрешённые чаты: " + (", ".join(allowed) if allowed else "не заданы")) + import subprocess + if os.path.exists("/run/systemd/system"): + for unit in ("simbiot-harness-tg.service", "simbiot-harness.service"): + st = subprocess.run(["systemctl", "is-active", unit], + capture_output=True, text=True).stdout.strip() + print(f" {unit}: {st}") + cmds = [("никак", [])] + try: + from harness import tg_commands + cmds = tg_commands.get_my_commands() or [("нет", [])] + except Exception: + pass + names = ", ".join(c[0] for c in cmds) if cmds and cmds[0][0] not in ("никак", "нет") else "нет" + print(f" меню бота: {names}") + + def _run_wizard(args) -> None: - env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") + env_path = _paths.env_path() vals = { "SIMBIOT_TG_TOKEN": args.tg_token if args.tg_token is not None else None, "SIMBIOT_TG_ALLOWED": args.tg_allowed, diff --git a/harness/audit.py b/harness/audit.py index 189e095..9097097 100644 --- a/harness/audit.py +++ b/harness/audit.py @@ -42,7 +42,8 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import ( from cryptography.hazmat.primitives import serialization HARNESS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -KEY_DIR = os.environ.get("SIMBIOT_AUDIT_KEY_DIR", os.path.join(HARNESS_ROOT, ".audit")) +from . import paths as _paths # t-261004113351-2858c1: ключи аудита в хоме +KEY_DIR = os.environ.get("SIMBIOT_AUDIT_KEY_DIR", "") or _paths.audit_key_dir() PRIV_PATH = os.path.join(KEY_DIR, "ed25519.key") PUB_PATH = os.path.join(KEY_DIR, "ed25519.pub") diff --git a/harness/aum.py b/harness/aum.py index 210bae7..80522be 100644 --- a/harness/aum.py +++ b/harness/aum.py @@ -38,8 +38,9 @@ from datetime import datetime, timezone ROUTER_DB = os.environ.get( "SIMBIOT_ROUTER_DB", "/root/.9router/db/data.sqlite") +from . import paths as _paths # t-261004113351-2858c1: доки инстанса HARNESS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -BALANCES_JSON = os.path.join(HARNESS_ROOT, "docs", "aum", "balances.json") +BALANCES_JSON = os.path.join(_paths.docs_dir(), "aum", "balances.json") COOP_JSON = os.environ.get("SIMBIOT_COOP_JSON", "/root/coop_data.json") RATE_FLASH = 0.001022 # у.е./токен glm-5.3-flash (дайджест 25.09–02.10) @@ -98,7 +99,7 @@ def _http_json(url: str, headers: dict): def refresh_openrouter_credits(env_path: str = None): """Доступные кредиты OpenRouter коопа (USD) или None (нет ключа/ошибка).""" - env_path = env_path or os.path.join(HARNESS_ROOT, ".env") + env_path = env_path or _paths.env_path() # t-261004113351: .env инстанса key = _env_file_value(env_path, "OPENROUTER_KEY") if not key: return None diff --git a/harness/es.py b/harness/es.py index c962ca3..b9ee3fe 100644 --- a/harness/es.py +++ b/harness/es.py @@ -10,7 +10,10 @@ import threading import time from datetime import datetime, timezone -DB_PATH = os.environ.get("SIMBIOT_DB", os.path.join(os.path.dirname(__file__), "harness.db")) +# t-261004113351-2858c1: путь БД из paths (SIMBIOT_HOME → /db/harness.db; +# без хома — legacy harness/harness.db). Точечная SIMBIOT_DB сильнее схемы. +from . import paths as _paths +DB_PATH = _paths.db_path() # RLock обязателен: rebuild_projections держит лок и зовёт # run_projections, который берёт его же — обычный Lock давил сам себя. @@ -390,6 +393,15 @@ def _apply_event(model: dict, ev: dict): t = model["tasks"].get(aid) if t: t.update(status="done", result_ref=data.get("result_ref"), finished=ev["timestamp"]) + elif etype == "TaskBlocked": + # t-261004122019: задача есть, но не ready — ждёт уточнений/внешнего + # события. scheduler берёт только ready, так что blocked выпадает из + # батчей; обратно — requeue (blocked → ready). + t = model["tasks"].get(aid) + if t: + t.update(status="blocked", block_reason=(data.get("reason") or "")[:300], + blocked_by=data.get("by") or "manual", + blocked_at=ev["timestamp"]) elif etype == "TaskFailed": t = model["tasks"].get(aid) if t: diff --git a/harness/isolated_runner.py b/harness/isolated_runner.py index ddea8ae..2e7281f 100644 --- a/harness/isolated_runner.py +++ b/harness/isolated_runner.py @@ -13,6 +13,7 @@ import sys import time _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +from . import paths as _paths # t-261004113351-2858c1: БД из SIMBIOT_HOME _VENV_PY = os.path.join(_HARNESS_DIR, "venv", "bin", "python") _WORKER = os.path.join(_HARNESS_DIR, "harness", "_llm_worker.py") MARKER = "@@SIMBIOT_RESULT@@" @@ -44,7 +45,9 @@ def run_llm_tick(level_cfg, prompt: str, session_id=None, trace_prefix=None, tim "level": level_cfg.name, # для записи BudgetSpent самим воркером } env = dict(os.environ) - env["SIMBIOT_DB"] = env.get("SIMBIOT_DB", os.path.join(_HARNESS_DIR, "harness", "harness.db")) + # t-261004113351-2858c1: БД из paths (SIMBIOT_HOME → /db/harness.db); + # SIMBIOT_HOME и SIMBIOT_DB наследуются воркеру через dict(os.environ). + env["SIMBIOT_DB"] = env.get("SIMBIOT_DB", _paths.db_path()) if timeout is None: # таймаут уровня: тик может быть длиннее интервала — занятый уровень # просто поглощает такты (TickAbsorbed), это норма архитектуры diff --git a/harness/paths.py b/harness/paths.py new file mode 100644 index 0000000..76845c4 --- /dev/null +++ b/harness/paths.py @@ -0,0 +1,143 @@ +"""Simbiot Harness — единая карта персистентных путей (SIMBIOT_HOME). + +t-261004113351-2858c1 (мультитенантность): память и знания инстанса — данные, +а не код. Репозиторий = чистый код; всё, что живёт дольше процесса (ES-база, +RAG-индекс знаний, входящие каналы, выжимки, секреты, доки инстанса), +размещается в одном каталоге-хоме. + +SIMBIOT_HOME (env) — корень данных инстанса. Задан → структура: + + /db/harness.db — ES-база (события, проекции, delivery-леджер) + /rag/rag.db — RAG-индекс знаний (векторные чанки) + /env/.env — секреты инстанса (токены, ключи роутера) + /inbox/ — входящие каналов (portal/state, legacy outbox) + /digests/ — файлы-выжимки уровней (summary_ref) + /audit/ — ключи крипто-аудита (ed25519) + /docs/ — проектные документы инстанса (aum/balances.json) + /voice/ — кэш голосовых (если используется ASR) + /run/ — runtime-штампы каналов (channel_.json) + +Не задан (legacy) → всё по-старому из корня репо: harness/harness.db, .env, +docs/, out/, .audit/ — обратная совместимость существующих установок и +systemd-юнитов без правки окружения. + +Приоритет источников (12-фактор): точечная env-переменная (SIMBIOT_DB, +SIMBIOT_RAG_DB, SIMBIOT_AUDIT_KEY_DIR, ...) сильнее схемы SIMBIOT_HOME, +SIMBIOT_HOME сильнее legacy-дефолта. +""" +import os + +_HARNESS_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(_HARNESS_DIR) + +_HOME = os.environ.get("SIMBIOT_HOME", "").strip() +IS_ISOLATED = bool(_HOME) + + +def home() -> str: + """Корень данных инстанса (SIMBIOT_HOME или корень репо — legacy).""" + return _HOME or REPO_ROOT + + +# ── ES-база: события/проекции/идемпотентная доставка ──────────────────────── +def db_path() -> str: + env = os.environ.get("SIMBIOT_DB", "").strip() + if env: + return env + if IS_ISOLATED: + return os.path.join(_HOME, "db", "harness.db") + return os.path.join(_HARNESS_DIR, "harness.db") # legacy: как было + + +# ── .env: секреты инстанса ────────────────────────────────────────────────── +def env_path() -> str: + if IS_ISOLATED: + return os.path.join(_HOME, "env", ".env") + return os.path.join(REPO_ROOT, ".env") + + +# ── RAG-индекс знаний ─────────────────────────────────────────────────────── +def rag_db() -> str: + env = os.environ.get("SIMBIOT_RAG_DB", "").strip() + if env: + return env + if IS_ISOLATED: + return os.path.join(_HOME, "rag", "rag.db") + return "/root/rag/rag.db" # legacy (общая база кооп-хоста, как было) + + +# ── выжимки уровней (файлы summary_ref) ───────────────────────────────────── +def digests_dir() -> str: + if IS_ISOLATED: + return os.path.join(_HOME, "digests") + return os.path.join(REPO_ROOT, "out") # legacy out/ + + +# ── проектные документы инстанса ──────────────────────────────────────────── +def docs_dir() -> str: + if IS_ISOLATED: + return os.path.join(_HOME, "docs") + return os.path.join(REPO_ROOT, "docs") + + +# ── ключи крипто-аудита ───────────────────────────────────────────────────── +def audit_key_dir() -> str: + env = os.environ.get("SIMBIOT_AUDIT_KEY_DIR", "").strip() + if env: + return env + if IS_ISOLATED: + return os.path.join(_HOME, "audit") + return os.path.join(REPO_ROOT, ".audit") + + +# ── входящие каналов: state + legacy-каталоги файлопередачи ───────────────── +def channel_state_file() -> str: + if IS_ISOLATED: + return os.path.join(_HOME, "inbox", ".channel-last") + return os.path.join(REPO_ROOT, "portal", "inbox", ".channel-last") + + +def inbox_pending_dir() -> str: + if IS_ISOLATED: + return os.path.join(_HOME, "inbox", "pending") + return os.path.join(REPO_ROOT, "portal", "inbox", "pending") + + +def inbox_outbox_dir() -> str: + if IS_ISOLATED: + return os.path.join(_HOME, "inbox", "outbox") + return os.path.join(REPO_ROOT, "portal", "inbox", "outbox") + + +# ── runtime-штампы каналов для дашборда ───────────────────────────────────── +def stamp_dir() -> str: + if IS_ISOLATED: + return os.path.join(_HOME, "run") + return REPO_ROOT # legacy: /harness/channel_.json + + +# ── кэш голосовых ─────────────────────────────────────────────────────────── +def voice_dir() -> str: + env = os.environ.get("SIMBIOT_VOICE_DIR", "").strip() + if env: + return env + if IS_ISOLATED: + return os.path.join(_HOME, "voice") + return "/var/lib/simbiot-harness/voice" # legacy + + +def ensure_instance_dirs() -> list: + """Создать структуру хома изолированного инстанса. Идемпотентно. + + В legacy-режиме ничего не создаёт (там всё уже лежит как лежало). + Возвращает список созданных каталогов. + """ + if not IS_ISOLATED: + return [] + made = [] + for sub in ("db", "rag", "env", "inbox", "digests", "audit", "docs", "voice", "run"): + p = os.path.join(_HOME, sub) + if not os.path.isdir(p): + os.makedirs(p, exist_ok=True) + made.append(p) + return made diff --git a/harness/portal_channel.py b/harness/portal_channel.py index 368a112..dd46c7d 100644 --- a/harness/portal_channel.py +++ b/harness/portal_channel.py @@ -27,10 +27,12 @@ import datetime import urllib.parse import urllib.request +from . import paths as _paths # t-261004113351-2858c1: SIMBIOT_HOME + REPORTER_ENV = "/etc/coop-reporter.env" -STATE_FILE = "/root/simbiot-harness/portal/inbox/.channel-last" -PENDING_DIR = "/root/simbiot-harness/portal/inbox/pending" # legacy-путь (пусто) -OUTBOX_DIR = "/root/simbiot-harness/portal/inbox/outbox" # legacy fallback +STATE_FILE = _paths.channel_state_file() # t-261004113351-2858c1: SIMBIOT_HOME +PENDING_DIR = _paths.inbox_pending_dir() +OUTBOX_DIR = _paths.inbox_outbox_dir() _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HTTP_TIMEOUT = 8 # портал отвечает быстро; висеть дольше — вред @@ -43,6 +45,27 @@ def _now_ms() -> int: return int(time.time() * 1000) +# ── живой статус каналов для дашборда (t-261004103718-d8112b) ────────────── +# Каждый реальный пользователь канала (сек-рефлекс портала, TG-поллер) после +# УСПЕШНОГО обмена пишет штамп {"ok":bool,"ts":ms,...} в harness/channel_.json. +# Дашборд читает штампы (не дёргает портал сам) — возраст штампа = возраст данных. +STAMP_DIR = _paths.stamp_dir() # legacy: корень репо; SIMBIOT_HOME → /run + + +def write_channel_stamp(name: str, ok: bool, **extra) -> None: + """Атомарный штамп успеха канала: harness/channel_.json.""" + try: + path = os.path.join(STAMP_DIR, "harness", f"channel_{name}.json") + d = {"ok": bool(ok), "ts": _now_ms()} + d.update(extra) + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(d, f) + os.replace(tmp, path) + except OSError: + pass # штамп — диагностика, не должен ломать канал + + def _cfg() -> dict: cfg = {"portal_url": "", "agent_token": "", "agent_name": ""} for k, ek in (("portal_url", "SIMBIOT_PORTAL_URL"), @@ -266,7 +289,7 @@ def _apply_tg_command(text: str) -> None: # 0) идемпотентность: тот же токен уже в .env — не применяем повторно # (не дёргаем getMe/рестарт, не спамим отчётом при ретраях канала) try: - with open(os.path.join(_HARNESS_DIR, ".env")) as f: + with open(_paths.env_path()) as f: for line in f: if line.startswith("SIMBIOT_TG_TOKEN="): if line.split("=", 1)[1].strip() == token: @@ -290,14 +313,14 @@ def _apply_tg_command(text: str) -> None: allowed = payload.get("allowed") or payload.get("owner_tg") or "" if not allowed: try: - with open(os.path.join(_HARNESS_DIR, ".env")) as f: + with open(_paths.env_path()) as f: for line in f: if line.startswith("SIMBIOT_TG_ALLOWED="): allowed = line.split("=", 1)[1].strip() except OSError: pass # 3) атомарная запись .env (в .gitignore — в git не попадает) - env_path = os.path.join(_HARNESS_DIR, ".env") + env_path = _paths.env_path() tmp_path = env_path + ".tmp" try: with open(env_path) as f: @@ -347,7 +370,8 @@ def sec_reflex() -> dict: return {"skipped": "disabled"} # мастер-харнес: канал выключен флагом _last_reflex["ts"] = now out = {} - out["ingested"] = pull() + out["ingested"] = pull() # доехали сюда = портал ответил HTTP (успех обмена) + write_channel_stamp("portal", ok=True, ingested=out["ingested"]) out["outbox_files"] = _flush_outbox_files() # legacy: file-pending (если портал когда-то писал файлами) try: diff --git a/harness/rag_lookup.py b/harness/rag_lookup.py index 5c1dc3c..0a20e5e 100644 --- a/harness/rag_lookup.py +++ b/harness/rag_lookup.py @@ -18,14 +18,15 @@ import os import subprocess _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +from . import paths as _paths # t-261004113351-2858c1: RAG-индекс инстанса _RAG_VENV_PY = "/root/rag-venv/bin/python" _RAG_SCRIPT = "/root/rag/rag.py" # База по умолчанию: корпус кооператива (12.5k чанков) — то, что просил Алекс # для мин-пульса; hermes.db (54k) — опцией RAG_DB для верхних уровней. -# SIMBIOT_RAG_DB — для второго инстанса харнеса (агент со своей базой, -# t-261001124130: мигрирующий агент получает собственный RAG-индекс). -DEFAULT_DB = os.environ.get("SIMBIOT_RAG_DB", "/root/rag/rag.db") +# SIMBIOT_RAG_DB — точечное переопределение; SIMBIOT_HOME → /rag/rag.db +# (t-261004113351: инстанс Юры не должен искать по нашим знаниям). +DEFAULT_DB = _paths.rag_db() # Лимиты на тик: 1 запрос, k<=5, таймаут 20с (эмбеддинг ~2.7с + запас) MAX_K = 5 diff --git a/harness/runtime.py b/harness/runtime.py index 2542afb..f0712e0 100644 --- a/harness/runtime.py +++ b/harness/runtime.py @@ -9,6 +9,7 @@ import uuid from datetime import datetime, timezone _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +from . import paths as _paths # t-261004113351-2858c1: .env инстанса def _env_or_file(name: str, default: str = "") -> str: @@ -21,7 +22,7 @@ def _env_or_file(name: str, default: str = "") -> str: val = os.environ.get(name, "") if val: return val - path = os.path.join(_HARNESS_DIR, ".env") + path = _paths.env_path() try: with open(path) as f: for line in f: diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index dd93c64..82d79b2 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -18,8 +18,10 @@ import urllib.error from . import es +from . import paths as _paths # t-261004113351-2858c1: .env инстанса + _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -_ENV_PATH = os.path.join(_HARNESS_DIR, ".env") +_ENV_PATH = _paths.env_path() TG_TOKEN = None ALLOWED = set() @@ -336,6 +338,13 @@ def poll_loop(): while True: try: updates = _api("getUpdates", {"offset": offset, "timeout": 30, "limit": 16}) + # getUpdates ответил — канал жив: штамп для дашборда + # (t-261004103718-d8112b). getUpdates сам НЕ пробуем в дашборде: + # конфликт long polling с этим процессом (сдвиг offset). + from . import portal_channel as _pch + _pch.write_channel_stamp( + "tg", ok=True, + updates=len((updates or {}).get("result") or [])) for u in updates.get("result", []): offset = max(offset, u["update_id"] + 1) msg = u.get("message") or u.get("edited_message") diff --git a/harness/voice.py b/harness/voice.py index 20ec290..4682095 100644 --- a/harness/voice.py +++ b/harness/voice.py @@ -29,17 +29,19 @@ from . import es # ─── конфигурация ─────────────────────────────────────────────────────────── _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -_ENV_PATH = os.path.join(_HARNESS_DIR, ".env") +from . import paths as _paths # t-261004113351-2858c1 +_ENV_PATH = _paths.env_path() _FW_WORKER = os.path.join(_HARNESS_DIR, "harness", "_fw_asr_worker.py") _FW_PYTHON = "/usr/bin/python3" # faster-whisper живёт в системном python ASR_URL = os.environ.get("SIMBIOT_ASR_URL", "http://127.0.0.1:8084") + +VOICE_DIR = _paths.voice_dir() # SIMBIOT_HOME → /voice; legacy без хома # что расшифровываем: voice — главное; audio/video_note — та же речь TRANSCRIBABLE_KINDS = {"voice", "audio", "video_note"} # длина, сек: длиннее не тянем (поллер не должен вязнуть в 10-мин озвучках) MAX_DURATION = 300 -# аудио-кэш: file_unique_id → ogg-файл -VOICE_DIR = os.environ.get("SIMBIOT_VOICE_DIR", "/var/lib/simbiot-harness/voice") +# аудио-кэш: file_unique_id → ogg-файл (путь задаёт paths.voice_dir выше) TG_TOKEN = None diff --git a/scripts/init_instance.py b/scripts/init_instance.py new file mode 100755 index 0000000..a188b08 --- /dev/null +++ b/scripts/init_instance.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Simbiot — инициализация нового изолированного инстанса (t-261004113351). + +Мультитенантность: память инстанса (ES-база, RAG-индекс, .env, доки) живёт +в отдельном SIMBIOT_HOME, репозиторий остаётся чистым кодом. Скрипт: + + 1) создаёт структуру хома (db/ rag/ env/ inbox/ digests/ audit/ docs/ …) + 2) инициализирует ЧИСТУЮ ES-базу (никаких событий старого инстанса) + 3) пишет PolicyChanged с идентичностью нового владельца + 4) копирует его проектные документы в /docs/ (+ RAG-индексация, + если вендор доступен) + 5) опционально: селективный импорт знаний из инстанса-источника — + последние выжимки (--import-digests) и выбранные доки (--import-docs) + 6) печатает, как поднять: демоном (systemd) и разово (CLI) + +Пример (инстанс Юры на той же машине): + venv/bin/python scripts/init_instance.py --home /var/lib/simbiot-yura \ + --name Юра --identity "Ты — агент Юры…" \ + --docs /tmp/yura-project/*.md --env-from /root/yura.env + +На машине Юры (после install.sh): + ~/simbiot-harness/venv/bin/python scripts/init_instance.py \ + --home ~/simbiot-data --name Юра --env-from ~/yura.env --docs ~/my-project +""" +import argparse +import glob +import json +import os +import shutil +import subprocess +import sys +import tempfile + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +_RAG_VENV_PY = "/root/rag-venv/bin/python" +_RAG_SCRIPT = "/root/rag/rag.py" + +# точечные переопределения окружения вызывающего НЕ должны протекать в новый +# инстанс (например, SIMBIOT_DB боя, выставленная воркером часа) +_POINT_ENV = ("SIMBIOT_DB", "SIMBIOT_RAG_DB", "SIMBIOT_AUDIT_KEY_DIR", + "SIMBIOT_VOICE_DIR") + + +def _jlog(msg: str) -> None: + print(json.dumps({"ts": __import__("datetime").datetime.now().isoformat(), + "msg": msg}, ensure_ascii=False)) + + +def _rag_index(files: list, rag_db: str) -> dict: + """Индексация файлов в RAG-базу инстанса (вендор дедупит по sha).""" + res = {"indexed": [], "failed": [], "error": None} + if not (os.path.exists(_RAG_VENV_PY) and os.path.exists(_RAG_SCRIPT)): + res["error"] = "rag-вендор не найден — документы скопированы, индексация пропущена" + return res + os.makedirs(os.path.dirname(rag_db), exist_ok=True) + env = dict(os.environ, RAG_DB=rag_db) + for path in files: + try: + proc = subprocess.run([_RAG_VENV_PY, _RAG_SCRIPT, "add", path], + capture_output=True, text=True, timeout=120, + env=env) + (res["indexed"] if proc.returncode == 0 else res["failed"]).append( + os.path.basename(path)) + except Exception as e: + res["failed"].append({"file": os.path.basename(path), + "err": str(e)[:200]}) + return res + + +def _expand_docs(patterns: list) -> list: + out = [] + for p in patterns or []: + for m in (glob.glob(p) if any(c in p for c in "*?[") else [p]): + if os.path.isfile(m): + out.append(os.path.abspath(m)) + return out + + +def _export_digests(src_db: str, per_level: int, out_dir: str) -> list: + """Выжимки инстанса-источника → md-файлы (для селективного переноса).""" + import sqlite3 + made = [] + os.makedirs(out_dir, exist_ok=True) + src = sqlite3.connect(f"file:{src_db}?mode=ro", uri=True) + src.row_factory = sqlite3.Row + for lvl in ("min", "hour", "day"): + rows = src.execute( + "SELECT timestamp, data FROM events WHERE event_type='DigestWritten'" + " AND json_extract(data,'$.level')=? AND json_extract(data,'$.stub')" + " IS NULL ORDER BY id DESC LIMIT ?", (lvl, per_level)).fetchall() + for r in rows: + d = json.loads(r["data"]) + fn = os.path.join(out_dir, f"digest-{lvl}-{r['timestamp'][:16].replace(':','')}.md") + with open(fn, "w", encoding="utf-8") as f: + f.write(f"# Выжимка {lvl} {r['timestamp']}\n\n{d.get('text','')}\n") + made.append(fn) + src.close() + return made + + +def main() -> int: + ap = argparse.ArgumentParser(description="init изолированного инстанса Simbiot") + ap.add_argument("--home", required=True, help="SIMBIOT_HOME нового инстанса") + ap.add_argument("--name", default="agent", help="имя владельца (для идентичности)") + ap.add_argument("--identity", default=None, + help="идентичность (PolicyChanged); по умолчанию — по имени") + ap.add_argument("--docs", nargs="*", default=[], + help="проектные документы нового владельца (файлы/глоб)") + ap.add_argument("--env-from", default=None, + help="готовый .env (токены/ключи) → /env/.env (0600)") + ap.add_argument("--src-db", default=None, + help="ES-база инстанса-источника (для селективного импорта)") + ap.add_argument("--import-digests", type=int, default=0, + help="сколько последних выжимок на уровень перенести из --src-db") + ap.add_argument("--import-docs", nargs="*", default=[], + help="файлы-источники знаний (доки/выжимки) → RAG нового инстанса") + ap.add_argument("--force", action="store_true", help="работать даже если хом не пуст") + args = ap.parse_args() + + home = os.path.abspath(os.path.expanduser(args.home)) + # SIMBIOT_HOME должен быть в env ДО импорта harness.* (paths читает при старте) + for k in _POINT_ENV: + os.environ.pop(k, None) + os.environ["SIMBIOT_HOME"] = home + sys.path.insert(0, _REPO) + + if os.path.isdir(home) and os.listdir(home) and not args.force: + print(f"ОТКАЗ: {home} не пуст (запуск повторно?). --force чтобы продолжить.") + return 2 + os.makedirs(home, exist_ok=True) + + from harness import paths, es, commands # после выставления SIMBIOT_HOME + + made = paths.ensure_instance_dirs() + if made: + _jlog({"home_dirs": len(made), "home": home}) + + # 1) чистая база + идентичность + es.init_db() + n_events = es._conn().execute("SELECT COUNT(*) c FROM events").fetchone()["c"] + if n_events: + print(f"ОТКАЗ: в {paths.db_path()} уже {n_events} событий — это не чистый инстанс.") + return 2 + identity = args.identity or ( + f"Ты — Simbiot-агент {args.name}. Помогаешь {args.name} в его проектах; " + "чужих проектов и контекстов у тебя нет — если спрашивают о том, чего " + "нет в твоей базе, так и говори.") + commands.set_policy(identity=identity) + _jlog({"identity_set": True, "db": paths.db_path()}) + + # 2) .env инстанса + if args.env_from: + dst = paths.env_path() + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copyfile(args.env_from, dst) + os.chmod(dst, 0o600) + _jlog({"env": dst}) + + # 3) доки владельца → /docs (+RAG) + report = {"docs_copied": [], "rag": None, "digests_imported": []} + docs = _expand_docs(args.docs) + for p in docs: + dst = os.path.join(paths.docs_dir(), os.path.basename(p)) + shutil.copyfile(p, dst) + report["docs_copied"].append(os.path.basename(p)) + + # 4) селективный импорт знаний + to_index = list(docs and [os.path.join(paths.docs_dir(), os.path.basename(p)) + for p in docs] or []) + if args.import_digests and args.src_db: + exported = _export_digests(args.src_db, args.import_digests, + os.path.join(home, "digests", "imported")) + report["digests_imported"] = [os.path.basename(x) for x in exported] + to_index.extend(exported) + to_index.extend(_expand_docs(args.import_docs)) + if to_index: + rag = _rag_index(to_index, paths.rag_db()) + if not rag["failed"]: + rag.pop("failed") # пустой список — не шумим в отчёте + report["rag"] = rag + + print(json.dumps({"ok": True, "home": home, "db": paths.db_path(), + "env": paths.env_path(), "rag_db": paths.rag_db(), + "docs_dir": paths.docs_dir(), **report}, + ensure_ascii=False, indent=2)) + print("\nКак поднять:") + print(f" разово: SIMBIOT_HOME={home} {_REPO}/venv/bin/python {_REPO}/cli.py status") + print(f" демон: SIMBIOT_HOME={home} {_REPO}/venv/bin/python -m harness.scheduler") + print(" systemd: скопируй scripts/simbiot-harness.service, добавь строку") + print(f" Environment=SIMBIOT_HOME={home}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/install.sh b/scripts/install.sh index 4453e93..df8ca97 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -118,6 +118,9 @@ cat > "$BIN_DIR/simbiot" </dev/null || true cd "$INSTALL_DIR" exec ./venv/bin/python cli.py "\$@" EOF @@ -135,6 +138,9 @@ cat > "$INSTALL_DIR/bin/simbiot" <<'EOF' DIR="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)" unset PYTHONPATH unset PYTHONHOME +# память инстанса — вне репо (t-261004113351): репо = чистый код +export SIMBIOT_HOME="${SIMBIOT_HOME:-$HOME/.simbiot-data}" +mkdir -p "$SIMBIOT_HOME" 2>/dev/null || true cd "$DIR" || exit 1 PY="$DIR/venv/bin/python" [ -x "$PY" ] || PY="$(command -v python3)" @@ -202,7 +208,11 @@ fi # ---------------------------------------------------------------------------- if [ "${FORCE_SYSTEMD:-}" = "yes" ]; then if [ "$(id -u)" = "0" ] && command -v systemctl >/dev/null 2>&1 && systemctl is-system-running >/dev/null 2>&1; then - cp "$INSTALL_DIR/scripts/simbiot-harness.service" /etc/systemd/system/ 2>/dev/null || sed -e "s|/root/simbiot-harness|$INSTALL_DIR|g" -e "s|root|$USER|g" "$INSTALL_DIR/scripts/simbiot-harness.service" > /etc/systemd/system/simbiot-harness.service + # рендер шаблона: плейсхолдеры __INSTALL_DIR__/__DATA_DIR__ всегда + # подставляются (голый cp клал бы юнит с заглушками — t-261004113351) + sed -e "s|__INSTALL_DIR__|$INSTALL_DIR|g" \ + -e "s|__DATA_DIR__|${SIMBIOT_DATA_DIR:-$HOME/.simbiot-data}|g" \ + "$INSTALL_DIR/scripts/simbiot-harness.service" > /etc/systemd/system/simbiot-harness.service systemctl daemon-reload systemctl enable --now simbiot-harness say "${GREEN}→${NC} systemd: simbiot-harness запущен (systemctl status simbiot-harness)" diff --git a/scripts/migrate_to_home.sh b/scripts/migrate_to_home.sh new file mode 100755 index 0000000..7a1e5a6 --- /dev/null +++ b/scripts/migrate_to_home.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# ============================================================================ +# Simbiot — перенос памяти инстанса из репо в SIMBIOT_HOME (t-261004113351). +# +# Репозиторий становится чистым кодом: ES-база, .env, ключи аудита, балансы, +# выжимки, штампы каналов переезжают в один каталог-хом (по умолчанию +# /var/lib/simbiot-harness). Идемпотентен: повторный запуск — SKIP уже +# перенесённого. +# +# БЕЗОПАСНОСТЬ: скрипт сам ждёт «тихого окна» (нет живых LLM-воркеров — +# паттерн scripts/safe-restart.sh), останавливает три сервиса харнеса, +# переносит файлы, добавляет SIMBIOT_HOME в systemd-юниты и обёртки, +# поднимает сервисы и проверяет здоровье. Откат: см. конец файла. +# +# scripts/migrate_to_home.sh [HOME] # по умолчанию /var/lib/simbiot-harness +# ============================================================================ +set -u +REPO="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)" +HOME_DIR="${1:-/var/lib/simbiot-harness}" +SERVICES="simbiot-harness.service simbiot-harness-tg.service simbiot-dashboard.service" +LOG="${SIMBIOT_MIGRATION_LOG:-/tmp/simbiot-migrate-to-home.log}" +MAX_WAIT="${SIMBIOT_MIGRATION_WAIT:-1800}" +PAT='/harness/_llm_worker\.py$' + +log() { echo "[$(date -u '+%F %T')] $*" >> "$LOG"; echo "$*"; } + +# ── 0) тихое окно: мигрировать можно только когда ни один тик не пишет в БД ─ +deadline=$(( $(date +%s) + MAX_WAIT )) +while :; do + if ! pgrep -f "$PAT" > /dev/null; then + sleep 3 + if ! pgrep -f "$PAT" > /dev/null; then + log "тихое окно поймано — начинаю миграцию в $HOME_DIR" + break + fi + fi + if [ "$(date +%s)" -ge "$deadline" ]; then + log "тихого окна нет ${MAX_WAIT}s — отмена (повторить позже)" + exit 0 + fi + sleep 10 +done + +# ── 1) структура хома (audit НЕ создаём: туда переедет каталог целиком) ───── +mkdir -p "$HOME_DIR"/{db,env,inbox,digests,run,voice,docs/aum} +log "home создан: $HOME_DIR" + +moved() { # mv если источник есть и цель отсутствует + [ -e "$1" ] || { [ -L "$1" ] && rm -f "$1"; return 0; } + if [ -e "$2" ]; then log "SKIP (цель есть): $2"; return 0; fi + mkdir -p "$(dirname "$2")" + mv "$1" "$2" && log "moved: $1 → $2" +} + +copied() { # cp если источник есть (переносить нельзя: git-трекается) + [ -e "$1" ] || return 0 + if [ -e "$2" ]; then log "SKIP (цель есть): $2"; return 0; fi + mkdir -p "$(dirname "$2")" + cp -a "$1" "$2" && log "copied: $1 → $2" +} + +# ── 2) стоп сервисов (писателей больше нет — mv БД с WAL безопасен) ───────── +systemctl stop $SERVICES +log "сервисы остановлены: $SERVICES" + +# ── 3) перенос памяти ─────────────────────────────────────────────────────── +moved "$REPO/harness/harness.db" "$HOME_DIR/db/harness.db" +moved "$REPO/harness/harness.db-wal" "$HOME_DIR/db/harness.db-wal" +moved "$REPO/harness/harness.db-shm" "$HOME_DIR/db/harness.db-shm" +moved "$REPO/.env" "$HOME_DIR/env/.env" +moved "$REPO/.audit" "$HOME_DIR/audit" # ключи ed25519 +copied "$REPO/docs/aum/balances.json" "$HOME_DIR/docs/aum/balances.json" +copied "$REPO/out" "$HOME_DIR/digests/out" # выжимки-файлы +copied "$REPO/portal/inbox/.channel-last" "$HOME_DIR/inbox/.channel-last" +for f in "$REPO"/harness/channel_*.json; do + [ -e "$f" ] && { cp -a "$f" "$HOME_DIR/run/" && log "copied: $f"; } +done +[ -f "$HOME_DIR/env/.env" ] && chmod 600 "$HOME_DIR/env/.env" + +# ── 4) SIMBIOT_HOME в юнитах и обёртках ───────────────────────────────────── +# RAG-корпус кооператива (/root/rag/rag.db) — общая инфраструктура хоста, +# его индексируют и другие инструменты (board hook): основному инстансу +# оставляем точечное переопределение, иначе SIMBIOT_HOME уведёт поиск на +# пустой /rag/rag.db (t-261004113351). +RAG_KEEP="" +if [ -e /root/rag/rag.db ]; then + RAG_KEEP="/root/rag/rag.db" +fi +for svc in $SERVICES; do + f="/etc/systemd/system/$svc" + [ -f "$f" ] || continue + if ! grep -q "^Environment=SIMBIOT_HOME=" "$f"; then + sed -i "/^\[Service\]/a Environment=SIMBIOT_HOME=$HOME_DIR" "$f" + log "юнит обновлён: $f (+SIMBIOT_HOME)" + fi + if [ -n "$RAG_KEEP" ] && ! grep -q "^Environment=SIMBIOT_RAG_DB=" "$f"; then + sed -i "/^Environment=SIMBIOT_HOME=/a Environment=SIMBIOT_RAG_DB=$RAG_KEEP" "$f" + log "юнит обновлён: $f (+SIMBIOT_RAG_DB=$RAG_KEEP)" + fi +done +systemctl daemon-reload + +for w in "$REPO/bin/simbiot" /root/.local/bin/simbiot; do + [ -f "$w" ] || continue + if ! grep -q "SIMBIOT_HOME" "$w"; then + sed -i "3a export SIMBIOT_HOME=\"\${SIMBIOT_HOME:-$HOME_DIR}\"" "$w" + log "обёртка обновлена: $w" + fi +done + +# ── 5) подъём и проверка здоровья ─────────────────────────────────────────── +systemctl start simbiot-harness.service simbiot-harness-tg.service simbiot-dashboard.service +sleep 8 +health="FAIL" +if systemctl is-active --quiet simbiot-harness.service \ + && [ -s "$HOME_DIR/db/harness.db" ] \ + && ! ls "$REPO/harness/harness.db" >/dev/null 2>&1; then + health="OK" +fi +log "health: $health (db: $(du -h "$HOME_DIR/db/harness.db" 2>/dev/null | cut -f1))" + +# контрольный прогон CLI из хома (не должен пересоздавать репо-базу) +SIMBIOT_HOME="$HOME_DIR" "$REPO/venv/bin/python" "$REPO/cli.py" status >/dev/null 2>&1 \ + && log "cli status: OK" || log "cli status: FAIL" +[ -e "$REPO/harness/harness.db" ] && log "ВНИМАНИЕ: репо-база пересоздана чем-то!" + +# отчёт + идемпотентное уведомление (не curl — защита от дублей в cli.py) +cat > "$HOME_DIR/migration-report.json" </dev/null || true + +log "миграция завершена: $health" +# ОТКАТ (если что-то не так): systemctl stop $SERVICES; +# mv "$HOME_DIR"/db/harness.db* "$REPO/harness/"; mv "$HOME_DIR/env/.env" "$REPO/.env"; +# убрать Environment=SIMBIOT_HOME из юнитов; systemctl daemon-reload; start +exit 0 diff --git a/scripts/simbiot-harness.service b/scripts/simbiot-harness.service index bf9a5c2..f891b05 100644 --- a/scripts/simbiot-harness.service +++ b/scripts/simbiot-harness.service @@ -10,6 +10,8 @@ ExecStart=__INSTALL_DIR__/venv/bin/python -m harness.scheduler Restart=always RestartSec=5 Environment=PYTHONUNBUFFERED=1 +# память инстанса (база/.env/выжимки) — вне репо (t-261004113351) +Environment=SIMBIOT_HOME=__DATA_DIR__ [Install] WantedBy=multi-user.target \ No newline at end of file diff --git a/tests/test_instance_isolation.py b/tests/test_instance_isolation.py new file mode 100644 index 0000000..82431d0 --- /dev/null +++ b/tests/test_instance_isolation.py @@ -0,0 +1,137 @@ +"""Изоляция памяти инстанса Simbiot (t-261004113351-2858c1). + +Тест постановки: «поднять копию с пустой памятью, спросить про проекты — +не должна вспоминать наши». Проверяем три контура утечки: + + 1) ES-память: контекст тика нового инстанса (выжимки/эскалации/доска/ + AUM-цель из policy) — пустые, идентичность — нового владельца; + 2) RAG-знания: индекс нового инстанса — только его документы (вендор + возвращает top-k на любой запрос, поэтому смотрим содержимое индекса); + его тема — находится (положительный контроль: поиск живой); + 3) прод не задет: в боевой БД нет событий тестового инстанса. + +Запуск: venv/bin/python tests/test_instance_isolation.py +""" +import json +import os +import shutil +import subprocess +import sqlite3 +import sys +import tempfile + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_PY = os.path.join(_REPO, "venv", "bin", "python") +_PROD_DB = os.path.join(_REPO, "harness", "harness.db") + +fails = [] + + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "") + if not cond: + fails.append(name) + +# ── стенд: новый инстанс Юры с одним его документом ──────────────────────── +home = tempfile.mkdtemp(prefix="simbiot-iso-yura-") +docs = tempfile.mkdtemp(prefix="simbiot-iso-docs-") +doc = os.path.join(docs, "greenhouse.md") +with open(doc, "w", encoding="utf-8") as f: + f.write("# Проект Юры: умная теплица\n\nДатчики влажности, автополив на ESP32, " + "урожай томатов к июлю.\n") + +r = subprocess.run([_PY, os.path.join(_REPO, "scripts", "init_instance.py"), + "--home", home, "--name", "Юра", "--docs", doc], + capture_output=True, text=True, timeout=180) +out = {} +try: + lines = r.stdout.splitlines() # _jlog-строки (однострочный JSON) + i = next(i for i, ln in enumerate(lines) if ln.strip() == "{") + j = next(j for j in range(len(lines) - 1, -1, -1) if lines[j].strip() == "}") + out = json.loads("\n".join(lines[i:j + 1])) # итоговый pretty-JSON +except Exception: + pass +check("init: ok", r.returncode == 0 and out.get("ok") is True, + r.stdout[-300:] + r.stderr[-300:]) + +# ── 1) ES-память: контекст тика чистого инстанса ─────────────────────────── +env = {k: v for k, v in os.environ.items() + if k not in ("SIMBIOT_DB", "SIMBIOT_RAG_DB", "SIMBIOT_HOME")} +env["SIMBIOT_HOME"] = home +code = """ +import sys +sys.path.insert(0, %r) +from harness import es +from harness.levels import LEVELS +from harness.scheduler import build_context +model = es.run_projections() +ctx = build_context(LEVELS["hour"], model, batch=[]) +print(ctx) +""" % _REPO +r2 = subprocess.run([_PY, "-c", code], capture_output=True, text=True, + env=env, timeout=120) +ctx = r2.stdout +check("context: построен", bool(ctx.strip()), r2.stderr[-200:]) +for marker, why in [ + ("ВЫЖИМКИ НИЖНИХ УРОВНЕЙ", "наши выжимки протекли"), + ("ЭСКАЛАЦИИ (", "наши эскалации протекли"), + ("ДОСКА ЗАДАЧ", "наши задачи протекли"), + ("ЦЕЛЬ (AUM)", "наша AUM-цель протекла"), + ("ИНБOX", "наш инбокс протёк"), + ("кооператив 42", "наш контекст протёк"), +]: + check(f"context: без «{marker}»", marker not in ctx, why) +check("context: идентичность Юры", "Юра" in ctx and "ИДЕНТИЧНОСТЬ" in ctx) + +# события нового инстанса: только генезис (PolicyChanged), ничего нашего +db = sqlite3.connect(os.path.join(home, "db", "harness.db")) +evs = [t[0] for t in db.execute("SELECT event_type FROM events")] +db.close() +check("db: генезис только", set(evs) <= {"PolicyChanged"}, evs) + +# ── 2) RAG-знания нового инстанса ────────────────────────────────────────── +# Вендор возвращает top-k ближайших на ЛЮБОЙ запрос (пустой базы не бывает), +# поэтому «наших знаний нет» проверяем по содержимому индекса: все чанки — +# только из документов владельца. Положительный контроль: его тема — находится. +env2 = dict(env) +r3 = subprocess.run([_PY, "-c", """ +import sys, json +sys.path.insert(0, %r) +from harness import rag_lookup +his = rag_lookup.rag_query("теплица автополив датчики", k=3) +print(json.dumps({"his": [c["source"] for c in his]})) +""" % _REPO], capture_output=True, text=True, env=env2, timeout=120) +try: + rag = json.loads(r3.stdout.strip().splitlines()[-1]) +except Exception: + rag = {"his": []} +rdb = sqlite3.connect(f"file:{os.path.join(home, 'rag', 'rag.db')}?mode=ro", uri=True) +paths = [p for (p,) in rdb.execute("SELECT DISTINCT path FROM chunks")] +rdb.close() +check("rag: в индексе только документы владельца", + paths and all(os.path.realpath(p).startswith(home + os.sep) for p in paths), + paths) +check("rag: его документ находится", + any("greenhouse" in s for s in rag["his"]), rag["his"]) +check("rag: база инстанса отдельная", + os.path.exists(os.path.join(home, "rag", "rag.db")) + and os.path.realpath(os.path.join(home, "rag", "rag.db")) + != os.path.realpath("/root/rag/rag.db")) + +# ── 3) прод не задет: кросс-записей нет ──────────────────────────────────── +# Прод живёт и пишет события каждую секунду; кроме того, трейсы ЭТОЙ сессии +# legitimately упоминают «greenhouse» (я писал тест). Настоящая утечка — если +# генезис тестового инстанса (PolicyChanged с его идентичностью) записался +# в боевую базу. Проверяем именно её. +pdb = sqlite3.connect(f"file:{_PROD_DB}?mode=ro", uri=True) +dirty = pdb.execute( + "SELECT COUNT(*) FROM events WHERE aggregate_id='policy' AND data LIKE ?", + ("%агент Юра%",)).fetchone()[0] +pdb.close() +check("prod: генезис чужого инстанса не попал", dirty == 0, dirty) + +shutil.rmtree(home, ignore_errors=True) +shutil.rmtree(docs, ignore_errors=True) + +print(("=" * 8) + (" FAIL: " + str(len(fails)) if fails else " ALL PASS")) +sys.exit(1 if fails else 0)