t-261004113351: изоляция памяти инстанса — SIMBIOT_HOME (репо = чистый код)
- 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
This commit is contained in:
parent
7d995e75da
commit
3dc6ca6ab9
18 changed files with 856 additions and 22 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
36
README.md
36
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
|
||||
```
|
||||
|
||||
| файл | назначение |
|
||||
|---|---|
|
||||
| `<home>/db/harness.db` | ES-база (события, проекции, delivery-леджер) |
|
||||
| `<home>/rag/rag.db` | RAG-индекс знаний инстанса |
|
||||
| `<home>/env/.env` | секреты (токены, ключи роутера), 0600 |
|
||||
| `<home>/docs/` | проектные документы владельца (aum/balances.json) |
|
||||
| `<home>/digests/` | файлы-выжимки уровней |
|
||||
| `<home>/audit/` | ключи крипто-аудита ed25519 |
|
||||
| `<home>/inbox/`, `<home>/run/`, `<home>/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
|
||||
|
|
|
|||
114
cli.py
114
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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 → <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:
|
||||
|
|
|
|||
|
|
@ -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 → <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), это норма архитектуры
|
||||
|
|
|
|||
143
harness/paths.py
Normal file
143
harness/paths.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""Simbiot Harness — единая карта персистентных путей (SIMBIOT_HOME).
|
||||
|
||||
t-261004113351-2858c1 (мультитенантность): память и знания инстанса — данные,
|
||||
а не код. Репозиторий = чистый код; всё, что живёт дольше процесса (ES-база,
|
||||
RAG-индекс знаний, входящие каналы, выжимки, секреты, доки инстанса),
|
||||
размещается в одном каталоге-хоме.
|
||||
|
||||
SIMBIOT_HOME (env) — корень данных инстанса. Задан → структура:
|
||||
|
||||
<home>/db/harness.db — ES-база (события, проекции, delivery-леджер)
|
||||
<home>/rag/rag.db — RAG-индекс знаний (векторные чанки)
|
||||
<home>/env/.env — секреты инстанса (токены, ключи роутера)
|
||||
<home>/inbox/ — входящие каналов (portal/state, legacy outbox)
|
||||
<home>/digests/ — файлы-выжимки уровней (summary_ref)
|
||||
<home>/audit/ — ключи крипто-аудита (ed25519)
|
||||
<home>/docs/ — проектные документы инстанса (aum/balances.json)
|
||||
<home>/voice/ — кэш голосовых (если используется ASR)
|
||||
<home>/run/ — runtime-штампы каналов (channel_<tg|portal>.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: <repo>/harness/channel_<name>.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
|
||||
|
|
@ -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_<n>.json.
|
||||
# Дашборд читает штампы (не дёргает портал сам) — возраст штампа = возраст данных.
|
||||
STAMP_DIR = _paths.stamp_dir() # legacy: корень репо; SIMBIOT_HOME → <home>/run
|
||||
|
||||
|
||||
def write_channel_stamp(name: str, ok: bool, **extra) -> None:
|
||||
"""Атомарный штамп успеха канала: harness/channel_<portal|tg>.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:
|
||||
|
|
|
|||
|
|
@ -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 → <home>/rag/rag.db
|
||||
# (t-261004113351: инстанс Юры не должен искать по нашим знаниям).
|
||||
DEFAULT_DB = _paths.rag_db()
|
||||
|
||||
# Лимиты на тик: 1 запрос, k<=5, таймаут 20с (эмбеддинг ~2.7с + запас)
|
||||
MAX_K = 5
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 → <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
|
||||
|
||||
|
|
|
|||
196
scripts/init_instance.py
Executable file
196
scripts/init_instance.py
Executable file
|
|
@ -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) копирует его проектные документы в <home>/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 (токены/ключи) → <home>/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) доки владельца → <home>/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())
|
||||
|
|
@ -118,6 +118,9 @@ cat > "$BIN_DIR/simbiot" <<EOF
|
|||
# Simbiot Harness CLI (установлен scripts/install.sh в $INSTALL_DIR)
|
||||
unset PYTHONPATH
|
||||
unset PYTHONHOME
|
||||
# память инстанса — вне репо (t-261004113351): репо = чистый код
|
||||
export SIMBIOT_HOME="\${SIMBIOT_HOME:-$HOME/.simbiot-data}"
|
||||
mkdir -p "\$SIMBIOT_HOME" 2>/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)"
|
||||
|
|
|
|||
140
scripts/migrate_to_home.sh
Executable file
140
scripts/migrate_to_home.sh
Executable file
|
|
@ -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 уведёт поиск на
|
||||
# пустой <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" <<EOF
|
||||
{"task": "t-261004113351-2858c1", "home": "$HOME_DIR",
|
||||
"health": "$health", "ts": "$(date -u '+%F %T')"}
|
||||
EOF
|
||||
SIMBIOT_HOME="$HOME_DIR" "$REPO/venv/bin/python" "$REPO/cli.py" tg-notify \
|
||||
"📦 Память харнеса изолирована в $HOME_DIR (репо = чистый код). Здоровье: $health" \
|
||||
-k t-261004113351-migrated 2>/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
|
||||
|
|
@ -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
|
||||
137
tests/test_instance_isolation.py
Normal file
137
tests/test_instance_isolation.py
Normal file
|
|
@ -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)
|
||||
Loading…
Reference in a new issue