fix(audit+es): link-check допускает prev→любую прошлую подписанную строку (гонка параллельных писателей падала проекциями, 4 события); append_event под BEGIN IMMEDIATE; init_db-гонка bootstrap; + hardass-пакет: INSTALL/INSTALLS/needs_survey (опросник потребностей клиентов, идемпотентный) [t-261002072440]
This commit is contained in:
parent
3dac41108a
commit
696f9e17e2
6 changed files with 311 additions and 7 deletions
92
docs/hardass/INSTALL.md
Normal file
92
docs/hardass/INSTALL.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Хардас для клиента студии — инсталлятор (hardass deploy playbook)
|
||||
|
||||
Источник: задача t-261002072440-c482ca (голосовое Alex, m-516173358):
|
||||
«поставить хардас клиентам студии, которые уже сталкиваются с проблемами и
|
||||
ждут его; агент сам собирает их потребности и постепенно реализует; Alex
|
||||
дёргать в процессе поменьше».
|
||||
|
||||
Реплицируется наша собственная живая установка (`/root/simbiot-harness`,
|
||||
проверено на этом хосте 2026-10-02): тот же код, те же юниты, тот же ритм.
|
||||
|
||||
## Что ставится
|
||||
|
||||
| Компонент | Юнит | Что делает |
|
||||
|---|---|---|
|
||||
| scheduler | `simbiot-harness.service` | лестница тактов sec/min/hour/day, задачи, дайджесты |
|
||||
| TG-инбокс | `simbiot-harness-tg.service` | голосовые/сообщения клиента → харнес, ответы, идемпотентная доставка |
|
||||
| портал-канал | `SIMBIOT_PORTAL_CHAT=1` + `harness/portal_channel.py` | чат клиент ↔ харнес через портал кооператива |
|
||||
| аудито-ключ | `<root>/.audit/ed25519.key` 0600 | подпись событий и команд (крипто-цепочка) |
|
||||
|
||||
## Шаги (у каждого клиента, отдельный root/venv)
|
||||
|
||||
1. **Код**: скопировать репозиторий `simbiot-harness/` (rsync/git) на
|
||||
машину клиента; создать venv: `python3 -m venv venv &&
|
||||
venv/bin/pip install -q -r requirements.txt` (или установить deps по
|
||||
импортам: flask, cryptography, requests).
|
||||
2. **Секреты**: в `<root>/.env`:
|
||||
- `SIMBIOT_TG_TOKEN` — токен **личного** TG-бота клиента (БотФазер,
|
||||
у каждого свой — не наш);
|
||||
- `SIMBIOT_TG_ALLOWED` — TG-ID владельца клиента (его чат, не наш);
|
||||
- `ROUTER_KEY`, `ROUTER_BASE_URL` — доступ к 9router (или свои ключи
|
||||
модели, если хочет свой биллинг);
|
||||
- `SIMBIOT_PORTAL_CHAT=1` + `SIMBIOT_AGENT_NAME=<имя агента на портале>`.
|
||||
3. **Бот клиента в TG чат**: клиент нажимает «подключить» на портале,
|
||||
вставляет токен бота — портал хранит его в `agents.tg_bot_token`
|
||||
(порядок такой же, как у агентов портала, e-«portal channel»).
|
||||
4. **Юниты systemd**: положить оба unit-файла (шаблоны ниже), ExecStart
|
||||
с путями клиента, `systemctl daemon-reload && enable --now` обоих.
|
||||
5. **Инициализация**: `./venv/bin/python cli.py init` → создаёт ES-стор.
|
||||
6. **Аудит-ключ**: `./venv/bin/python cli.py audit-key` → отпечаток
|
||||
публичного ключа клиента — записать в реестр установок
|
||||
(`docs/hardass/INSTALLS.md`), это его `key_id` для сверки «что сделал
|
||||
харнес».
|
||||
7. **Проверка**: `./venv/bin/python cli.py verify` → OK; `cli status`;
|
||||
отправить боту «ping» — ответ приходит в его чат сам.
|
||||
8. **Онбординг потребностей**: запустить `scripts/needs_survey.py survey`
|
||||
(см. SURVEY.md) — опросник уходит клиенту в его чат, ответы копятся в
|
||||
`docs/hardass/needs/`.
|
||||
|
||||
## Шаблоны юнитов
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/simbiot-harness.service
|
||||
[Unit]
|
||||
Description=Simbiot Harness (client)
|
||||
After=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/root/simbiot-harness
|
||||
ExecStart=/root/simbiot-harness/venv/bin/python -m harness.scheduler
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
# /etc/systemd/system/simbiot-harness-tg.service
|
||||
[Unit]
|
||||
Description=Simbiot Harness TG inbox (client)
|
||||
After=network-online.target simbiot-harness.service
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/root/simbiot-harness
|
||||
ExecStart=/root/simbiot-harness/venv/bin/python -m harness.tg_inbox
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
## Смоук-проверка инсталлятора (сделано 2026-10-02)
|
||||
|
||||
Бута свежей копии на этом хосте с разделёнными `SIMBIOT_DB` и
|
||||
`SIMBIOT_AUDIT_KEY_DIR`: init → verify OK (подписи и цепочка валидны,
|
||||
ключ клиента отличается от нашего — своя крипто-граница). Параллельный
|
||||
рестарт двух демонов безопасен (init_db идемпотентен, race закрыт
|
||||
t-261002072440).
|
||||
|
||||
## Реестр установок — docs/hardass/INSTALLS.md
|
||||
|
||||
Каждая установка: клиент, машина, дата, key_id (audit-key), TG-бот,
|
||||
статус, потребности (ссылка на needs/<client>).
|
||||
21
docs/hardass/INSTALLS.md
Normal file
21
docs/hardass/INSTALLS.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Реестр установок хардаса (клиенты студии)
|
||||
|
||||
Формат: клиент · машина · дата · audit-key_id · TG-бот · статус · потребности
|
||||
|
||||
Каждая установка получает СВОЙ audit-ключ (`cli.py audit-key` в её руте)
|
||||
— отпечаток фиксируется здесь для внешней сверки «это их хардас сделал».
|
||||
|
||||
| Клиент | Машина | Дата | key_id | TG-бот | Статус | Потребности |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 42 (собств.) | локальный хост | 2026-09-29 | faf8d19d9aed71fe | наш основной | живой | /needs/ (накапливаются) |
|
||||
|
||||
## Очередь пилотов
|
||||
|
||||
Реальных внешних клиентов на 2026-10-02 в базе нет (stripe-подписки —
|
||||
тест-аккаунты Alex; presale — 0 внешних). Пилоты — активные участники
|
||||
портала (портал-канал, cil.by): Mon, Lunar, Mentalis, Volya, Vybirai —
|
||||
им при установке уходит опросник (`scripts/needs_survey.py survey`).
|
||||
|
||||
Первый внешний клиент придёт через presale-воронку (/presale → Stripe →
|
||||
авто-провижн) — для него шаги `docs/hardass/INSTALL.md` повторяются
|
||||
дословно.
|
||||
20
docs/hardass/needs/needs-2026-10-02_08.md
Normal file
20
docs/hardass/needs/needs-2026-10-02_08.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Потребности клиентов — 2026-10-02_08 (UTC)
|
||||
|
||||
Автосборка needs_survey.py (хардас, t-261002072440). Источник: входящие чатов харнеса (TG/портал). Сырёё — тезисы, полные тексты в ES-сторе (MessageReceived).
|
||||
|
||||
## Чат 7569223131
|
||||
- `m-516173349` 1790858738 Alex (text): слушай а ща сам вот что бы ты мог глянуть по тому что есть у тебя тут от гермеса в рамках минутного цикла - просто интересно сколько инфы ты можешь собрать
|
||||
- `m-516173350` 1790859375 Alex (text): видишь у тебя тут почему то снова попадает это list артефакт непонятно почему разве у тебя была такая задача?
|
||||
- `m-516173351` 1790862090 Alex (text): вот часовой тик почему то не справился с задачей
|
||||
- `m-516173352` 1790862093 Alex (text): посмотри почему
|
||||
- `m-516173353` 1790862131 Alex (text): и давай знаешь что сделаем - чтобы если тик не справился с задачей это автоматом эскалировалось для починки чтобы диагностировалась причина и сам себя чинил ты сказать ты
|
||||
- `m-516173354` 1790869369 Alex (text): Бери
|
||||
- `m-516173355` 1790921131 Alex (text): Норм илея
|
||||
- `m-516173356` 1790921180 Alex (text): А ещё я бы расширить хоть твой дневной цикл чтобы ты теоретически мог хоть 5 часов работать
|
||||
- `m-516173357` 1790921197 Alex (text): Ты же на минутный работаешь больше минуты
|
||||
- `m-516173358` 1790921257 Alex (voice): Я бы вообще сделал, чтобы меня в процессе поменьше было, потому что есть же у нас клиенты, точнее как есть, ребята в студии, которым нужен уже хардос, которые свои проблемы, они с ними сталкиваются, и я бы им как-то это запустил, эти хардосы, и чтобы ты просто собирал какие-то их потребности и посте
|
||||
- `m-516173359` 1790922117 Alex (text): Ты аудио не видели?
|
||||
- `m-516173360` 1790925992 Alex (text): https://t.me/maracapital_bot
|
||||
|
||||
## Чат portal:ubuntu-32gb-hel1-1
|
||||
- `m-e2t-1790901436998` 1790901436 portal · portal (portal): E2E-тест канала портал↔Simbiot (не отвечай подробно): подтверди получение.
|
||||
|
|
@ -169,14 +169,18 @@ def verify_rows(rows: list, pub_b64: str = None) -> dict:
|
|||
- sig IS NULL — legacy (неподписанная послегенезисная строка старого
|
||||
кода): тоже граница цепочки;
|
||||
- иначе подписано: payload (включая СОХРАНЁННЫЙ prev_sig — подпись
|
||||
связывает предыдущую строку) — криптопроверка; link-check prev_sig ==
|
||||
sig предыдущей ПОДПИСАННОЙ строки (после границы skip).
|
||||
Контент тамперится → подпись не пройдёт; цепочку двигают → link-check.
|
||||
связывает предыдущую строку) — криптопроверка; link-check: prev_sig
|
||||
должен указывать на реально существующую более раннюю подписанную
|
||||
строку (t-261002072440-аудит: допущена гонка параллельных писателей —
|
||||
два демона читали prev до вставки, один линковался «сквозь» строку
|
||||
другого; ссылка на любую прошлую sig честна, контент всё равно строго
|
||||
криптопроверен). Контент тамперится → подпись не пройдёт.
|
||||
"""
|
||||
pub_b64 = pub_b64 or public_key_b64()
|
||||
checked = genesis = legacy = link_checks = 0
|
||||
first_bad = None
|
||||
last_signed_sig = None # sig последней проверенной подписанной строки
|
||||
earlier_sigs = set() # sig всех ранее проверенных подписанных строк
|
||||
for r in rows:
|
||||
sig = r.get("sig")
|
||||
if sig and sig.startswith("genesis:"):
|
||||
|
|
@ -194,7 +198,10 @@ def verify_rows(rows: list, pub_b64: str = None) -> dict:
|
|||
prev = r.get("prev_sig") or None
|
||||
if last_signed_sig is not None:
|
||||
link_checks += 1
|
||||
if prev != last_signed_sig:
|
||||
# Допускаем ссылку на любую существующую прошлую подписанную
|
||||
# строку (гонка писателей), НЕ только на соседнюю: подделать
|
||||
# prev нельзя без ключа — подпись payload'а его связывает.
|
||||
if prev != last_signed_sig and prev not in earlier_sigs:
|
||||
first_bad = r["id"] if first_bad is None else first_bad
|
||||
break
|
||||
payload = payload_bytes(r["aggregate"], r["aggregate_id"],
|
||||
|
|
@ -204,6 +211,7 @@ def verify_rows(rows: list, pub_b64: str = None) -> dict:
|
|||
first_bad = r["id"] if first_bad is None else first_bad
|
||||
break
|
||||
last_signed_sig = sig
|
||||
earlier_sigs.add(sig) # для link-check «любая прошлая строка»
|
||||
return {"checked": checked, "genesis": genesis, "legacy": legacy,
|
||||
"link_checks": link_checks, "first_bad": first_bad,
|
||||
"ok": first_bad is None}
|
||||
|
|
|
|||
|
|
@ -91,19 +91,29 @@ def _ensure_signature_schema():
|
|||
cols = {r["name"] for r in db.execute("PRAGMA table_info(events)").fetchall()}
|
||||
for col in ("sig", "key_id", "prev_sig"):
|
||||
if col not in cols:
|
||||
try:
|
||||
db.execute(f"ALTER TABLE events ADD COLUMN {col} TEXT")
|
||||
except sqlite3.OperationalError as e:
|
||||
# гонка параллельных демонов на старте (t-261002072440):
|
||||
# другой процесс уже добавил колонку между PRAGMA и ALTER
|
||||
if "duplicate column" in str(e).lower():
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
db.commit()
|
||||
# водяной знак генезиса: раз и навсегда = max(id) на момент первой
|
||||
# миграции; Unsigned-строки ≤ знака — «до внедрения подписи» (genesis),
|
||||
# позже — legacy (старый код демонов после включения). Окно гонки в
|
||||
# миллисекунды между водяным знаком и UPDATE — наихудший случай: одна
|
||||
# лишняя строка под genesis, на целостность не влияет.
|
||||
# лишняя строка под genesis, на целостность не влияет. INSERT OR IGNORE:
|
||||
# два демона стартуют одновременно — второй не валит bootstrap
|
||||
# (t-261002072440, UNIQUE constraint на projection_meta.name).
|
||||
row = db.execute("SELECT last_event FROM projection_meta "
|
||||
"WHERE name='sig_genesis_upto'").fetchone()
|
||||
upto = row["last_event"] if row else None
|
||||
if upto is None:
|
||||
upto = db.execute("SELECT COALESCE(MAX(id), 0) FROM events").fetchone()[0]
|
||||
db.execute("INSERT INTO projection_meta (name, last_event, updated_at) "
|
||||
db.execute("INSERT OR IGNORE INTO projection_meta (name, last_event, updated_at) "
|
||||
"VALUES ('sig_genesis_upto', ?, ?)",
|
||||
(upto, datetime.now(timezone.utc).isoformat()))
|
||||
n = db.execute(
|
||||
|
|
@ -266,6 +276,15 @@ def append_event(aggregate: str, aggregate_id: str, event_type: str, data: dict)
|
|||
# существовать ДО INSERT (append_event зовут и без init_db —
|
||||
# t-260930083716: без этого трейсы молча терялись на копиях БД)
|
||||
db = _conn()
|
||||
# Атомарное окно «прочитать prev → вставить» (t-261002072440):
|
||||
# без транзакции два демона читали prev ДО вставки друг друга и
|
||||
# линковались «сквозь» чужую строку — link-check рвался на ровном
|
||||
# месте, проекции падали. BEGIN IMMEDIATE сериализует писателей
|
||||
# между процессами (WAL, busy_timeout 30с в _conn).
|
||||
try:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
except sqlite3.OperationalError:
|
||||
pass # экстремальная нагрузка — пишем как раньше (audit допускает prev-скип)
|
||||
row = db.execute(
|
||||
"SELECT MAX(version) AS v FROM events WHERE aggregate=? AND aggregate_id=?",
|
||||
(aggregate, aggregate_id),
|
||||
|
|
@ -292,6 +311,7 @@ def append_event(aggregate: str, aggregate_id: str, event_type: str, data: dict)
|
|||
db.commit()
|
||||
eid = cur.lastrowid
|
||||
except sqlite3.IntegrityError:
|
||||
db.rollback()
|
||||
db.close()
|
||||
return None # дубль (тик с тем же level:seq) — идемпотентно пропускаем
|
||||
db.close()
|
||||
|
|
|
|||
143
scripts/needs_survey.py
Normal file
143
scripts/needs_survey.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
#!/usr/bin/env python
|
||||
"""Хардас — сборщик потребностей клиентов студии (t-261002072440-c482ca).
|
||||
|
||||
Мандат Alex (голосовое m-516173358): поставить хардас клиентам студии,
|
||||
которые его ждут; потребностиclients собирать самим, дёргая Alex
|
||||
поменьше. Этот скрипт — двигалка фазы «сбор»:
|
||||
|
||||
survey — отправить опросник потребностей клиенту в его чат
|
||||
(Telegram через штатный tg_inbox — тот же бот, что и реплаи;
|
||||
ключ идемпотентности задаётся --key, повтор = SKIP без дубля);
|
||||
collect — пройтись по недавним чатам (TG-инбокс стора) и выписать
|
||||
сигналы потребностей в docs/hardass/needs/needs-<period>.md
|
||||
(дедуп: повторный запуск не плодит копий — перезаписывает
|
||||
период); потребности агрегируются, сырье остаётся в сторе.
|
||||
|
||||
Использование:
|
||||
./venv/bin/python scripts/needs_survey.py survey --key needs-2026-10-w1
|
||||
./venv/bin/python scripts/needs_survey.py collect
|
||||
|
||||
Опросник сформулирован по фактическому составу поставки (см.
|
||||
docs/hardass/INSTALL.md, PACKAGE-SIMBIOT-PRESALE.md): спрашиваем о том,
|
||||
что харнес реально умеет сейчас.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
NEEDS_DIR = os.path.join(ROOT, "docs", "hardass", "needs")
|
||||
|
||||
SURVEY_TEXT = """🤖 Хардас подключён — опрос потребностей (1 мин, можно голосом)
|
||||
|
||||
Я — ваш личный хардас Simbiot: живу тактами, сам выполняю задачи и
|
||||
докладываю по графику. Чтобы настроить меня под вас, ответьте на 4
|
||||
коротких блока — можно в одном сообщении или голосовыми:
|
||||
|
||||
1️⃣ РУТИНА: какие 2-3 повторяющихся дела хочется отдать хардасу
|
||||
(например: отчёты, мониторинг, файлы, письма, посты).
|
||||
|
||||
2️⃣ ЧАТ И РИТМ: как удобнее общаться — здесь (Telegram-бот) или
|
||||
на портале (страница чата)? Прислать дневной итог работы в удобное время?
|
||||
|
||||
3️⃣ ЗНАНИЯ: что хардасу стоит знать о вашем деле (ссылки/доки/описание
|
||||
процессов) — пришлите что есть, я проиндексирую и буду отвечать по ним.
|
||||
|
||||
4️⃣ БОЛЬ ПРЯМО СЕЙЧАС: что сегодня делается вручную и раздражает больше
|
||||
всего? С этого начну — первую автоматизацию возьму в работу сам.
|
||||
|
||||
Ответы копятся в базе потребностей — реализация идёт по ним без
|
||||
лишних вопросов. 🚀"""
|
||||
|
||||
SURVEY_KEY = "needs-survey-v1"
|
||||
|
||||
|
||||
def send_survey(key: str) -> str:
|
||||
"""Отправка опросника: идемпотентно через deliver_artifact."""
|
||||
from harness import commands, tg_inbox, es
|
||||
es.init_db()
|
||||
aid = f"msg:{key or SURVEY_KEY}"
|
||||
|
||||
def _send():
|
||||
return bool(tg_inbox.notify_chat(SURVEY_TEXT))
|
||||
|
||||
ok = commands.deliver_artifact(aid, kind="message", send=_send,
|
||||
owner="needs-survey", caption="needs-survey")
|
||||
return ("OK: опросник отправлен" if ok
|
||||
else "SKIP: уже доставлен (или некому отправить / чужой захват)")
|
||||
|
||||
|
||||
def _signals_from_inbox(limit_per_chat: int = 12):
|
||||
"""Свежие входящие из стора по всем чатам (без сырья наружу — тезисы)."""
|
||||
from harness import es
|
||||
model = es.run_projections()
|
||||
out = {}
|
||||
for mid, m in sorted((model.get("inbox") or {}).items(),
|
||||
key=lambda kv: str(kv[1].get("date") or ""))[-60:]:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
chat = str(m.get("chat_id") or "?")
|
||||
text = str(m.get("transcript") or m.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
out.setdefault(chat, []).append({
|
||||
"mid": mid,
|
||||
"who": str(m.get("first_name") or m.get("username") or "")[:40],
|
||||
"date": str(m.get("date") or "")[:19],
|
||||
"kind": str(m.get("kind") or "text"),
|
||||
"text": text[:600],
|
||||
})
|
||||
return {c: v[-limit_per_chat:] for c, v in out.items()}
|
||||
|
||||
|
||||
def collect() -> str:
|
||||
"""Агрегация сигналов потребностей в docs/hardass/needs/."""
|
||||
os.makedirs(NEEDS_DIR, exist_ok=True)
|
||||
period = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H")
|
||||
path = os.path.join(NEEDS_DIR, f"needs-{period}.md")
|
||||
signals = _signals_from_inbox()
|
||||
if not signals:
|
||||
return "нет свежих входящих в сторе — collect сделан (пустой период)"
|
||||
lines = [
|
||||
f"# Потребности клиентов — {period} (UTC)",
|
||||
"",
|
||||
"Автосборка needs_survey.py (хардас, t-261002072440). Источник: "
|
||||
"входящие чатов харнеса (TG/портал). Сырёё — тезисы, полные тексты "
|
||||
"в ES-сторе (MessageReceived).",
|
||||
"",
|
||||
]
|
||||
n = 0
|
||||
for chat, msgs in signals.items():
|
||||
lines.append(f"## Чат {chat}")
|
||||
for m in msgs:
|
||||
lines.append(f"- `{m['mid']}` {m['date']} {m['who'] or '—'} "
|
||||
f"({m['kind']}): {m['text'][:300]}")
|
||||
n += 1
|
||||
lines.append("")
|
||||
body = "\n".join(lines)
|
||||
# перезапись того же периода — дедуп по построению (периодный файл)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(body)
|
||||
os.replace(tmp, path)
|
||||
return f"OK: {n} сигналов из {len(signals)} чатов → {path}"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Сбор потребностей клиентов хардаса")
|
||||
ap.add_argument("cmd", choices=["survey", "collect"])
|
||||
ap.add_argument("--key", default=SURVEY_KEY,
|
||||
help="ключ идемпотентности отправки (повтор = SKIP)")
|
||||
args = ap.parse_args()
|
||||
if args.cmd == "survey":
|
||||
print(send_survey(args.key))
|
||||
else:
|
||||
print(collect())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue