From f286f1b8ed355fe1b76f73cb392f934cbc30b8db Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Sun, 4 Oct 2026 00:16:09 +0000 Subject: [PATCH] =?UTF-8?q?AUM=20v1:=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=86=D0=B5=D0=BB=D0=B8=20=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=BC=D0=B0=20(t-261004000834-ed84f5,=20day-?= =?UTF-8?q?20731):=20balances.json=20+=20=D0=B0=D0=B2=D1=82=D0=BE-refresh?= =?UTF-8?q?=20=D0=B8=D0=B7=20API=20(OpenRouter=20credits,=20Stripe=20live;?= =?UTF-8?q?=20livemode-only),=20AUM=3D26.7=20(=D0=B1=D1=8B=D0=BB=D0=BE=20N?= =?UTF-8?q?one),=20aum=20--record=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20aum=5Ftarget=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D1=85=20=D1=82=D0=B8=D0=BA=D0=BE=D0=B2;=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20test=5Faum=20+4=20(mock=20API,=20livemode=3DFals?= =?UTF-8?q?e=E2=86=92None)=20=E2=80=94=20=D0=92=D0=A1=D0=95=20=D0=9E=D0=9A?= =?UTF-8?q?;=20hour-=D1=82=D0=B8=D0=BA:=20task=20list=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BA=D0=BB=D0=BE=D0=BD=D0=BE=D0=B2-=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=20+=20tg=5Finbox=20env-fallback=20(e-261003125707)?= =?UTF-8?q?;=20=D0=B4=D0=BD=D0=B5=D0=B2=D0=BD=D0=BE=D0=B9=20=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D1=91=D1=82=20day-2026-10-04?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli.py | 26 ++++++-- docs/aum/balances.json | 32 +++++++++ docs/digests/day-2026-10-04.md | 84 +++++++++++++++++++++++ harness/aum.py | 117 ++++++++++++++++++++++++++++++++- harness/tg_inbox.py | 6 ++ tests/test_aum.py | 69 +++++++++++++++++++ 6 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 docs/aum/balances.json create mode 100644 docs/digests/day-2026-10-04.md diff --git a/cli.py b/cli.py index b9ea22d..e8c8d89 100644 --- a/cli.py +++ b/cli.py @@ -14,7 +14,12 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) p_task = sub.add_parser("task", help="подать задачу") - p_task.add_argument("title") + # t-261003125513/e-261003125707: 'task list' раньше создавал задачу-клон + # 'list' — теперь показывает доску (внутри 'list' как слово не валидируем, + # чтобы не породить рекурсию справки) + p_task.add_argument("title", nargs="?") + p_task.add_argument("--list", dest="list_board", action="store_true", + help="показать доску задач (не создавать)") p_task.add_argument("-d", "--details", default="") p_task.add_argument("-m", "--minutes", type=int, default=5, help="оценка длительности (класс S≤5м M≤60м L≤8ч XL)") @@ -160,12 +165,21 @@ def main(): elif args.cmd == "wizard": _run_wizard(args) elif args.cmd == "task": - tid, size = commands.submit_task(args.title, args.details, args.minutes) - if args.await_delivery: - commands.mark_awaiting_delivery(tid, reason="подана из TG с автодоставкой") - print(f"OK: задача {tid} [{size}] подана (результат уйдёт в TG при готовности)") + # t-261003125513/e-261003125707: голое 'list'/'tasks'/'task list' — + # показать доску, а не плодить задачи-клоны + if getattr(args, "list_board", False) or (args.title or "").strip() in ("list", "tasks", "task list"): + es.init_db() + for t in commands.list_tasks(): + print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}") + elif not args.title: + p_task.print_help() else: - print(f"OK: задача {tid} [{size}] подана") + tid, size = commands.submit_task(args.title, args.details, args.minutes) + if args.await_delivery: + commands.mark_awaiting_delivery(tid, reason="подана из TG с автодоставкой") + print(f"OK: задача {tid} [{size}] подана (результат уйдёт в TG при готовности)") + else: + print(f"OK: задача {tid} [{size}] подана") elif args.cmd == "task-await": es.init_db() commands.mark_awaiting_delivery(args.task_id, reason=args.reason) diff --git a/docs/aum/balances.json b/docs/aum/balances.json new file mode 100644 index 0000000..c48fd92 --- /dev/null +++ b/docs/aum/balances.json @@ -0,0 +1,32 @@ +{ + "_comment": "Замер AUM кооператива (t-261004000834-ed84f5, day-тик 2026-10-04). amount_usd — USD-эквивалент; null — требует ручного ввода Alex (в total не входит). Авто-источники помечены src; обновлять по мере изменения балансов.", + "as_of": "2026-10-04", + "accounts": [ + { + "name": "OpenRouter кредиты коопа (доступно, key из simbiot-harness/.env)", + "amount_usd": 26.7, + "src": "openrouter.ai/api/v1/credits (замер 2026-10-03 23:5x UTC: total=163, usage=136.30)", + "auto": true, + "measured_at": "2026-10-04T00:15:21+00:00" + }, + { + "name": "US LLC Delaware (операционный)", + "amount_usd": null, + "src": "ручной ввод Alex (API-источник неизвестен)" + }, + { + "name": "Крипто-кошелёк кооператива", + "amount_usd": null, + "src": "ручной ввод Alex" + }, + { + "name": "Stripe live balance (available)", + "amount_usd": 0.0, + "src": "api.stripe.com/v1/balance livemode=True, STRIPE_LIVE_KEY из coop/.env (замер 2026-10-03): available=0, pending=0", + "auto": true, + "measured_at": "2026-10-04T00:15:22+00:00" + } + ], + "debts": [], + "notes": "Первый непустой замер: total = 26.70 USD. Что НЕ вошло и почему: Stripe test-balance (livemode=False, pending $335.24) — тестовые деньги, в AUM не актив; 9router prepay — величина предоплаты не замерена (нужен источник); выручка presale — нулевая на момент замера (live available=0, pending=0). Для Alex: заполнить null-строки (LLC-счёт, крипто-кошелёк, 9router prepay) — после этого AUM станет полным. Методика: docs/aum.md." +} diff --git a/docs/digests/day-2026-10-04.md b/docs/digests/day-2026-10-04.md new file mode 100644 index 0000000..f7bb6d7 --- /dev/null +++ b/docs/digests/day-2026-10-04.md @@ -0,0 +1,84 @@ +# Дневной отчёт Simbiot Harness — 2026-10-04 + +Дневной тик (day-20731, 00:00 UTC). Манифест дня: разбор эскалаций → верификация +системы → план → L-задачи. Одна L-задача закрыта в тике (AUM v1), вторая в очереди +на часовой. + +## План дня + +1. **AUM v1 — сделать метрику цели измеримой** (t-261004000834-ed84f5, [L]) — + ✅ закрыто дневным тиком, см. ниже. Мотивация: северная звезда (t-260929161138) + была None с момента фиксации — управлять долгосрочной базой без числа нельзя. +2. **Первый канал трафика на presale** (t-261004000834-4bbc6f, [L]) — launch-kit + органического запуска (Show HN / Reddit / X-тред, EN-черновики) + верификация + клик-трекинга на presale.cil.by; публикация — только через Alex (график + 3-через-3, docs/alex-schedule.md). По docs/SALES-PLAN-1000-US.md: воронка, + лендинг, живой Stripe и счётчик мест готовы — трафика ~0, без канала 1000 + предзаказов недостижимы. Фильтр AUM: трафик → предзаказы $99.9/мес → revenue. +3. Фон: M-задача t-261003162607-aa8abf (HTML-план с ИИ-маскотами, результат + обещан Alex в TG) возвращена в очередь — заберёт часовой тик. + +## Эскалации + +- **e-261003170542-389f29 (warn, hour→day) — закрыт.** Hour-тик упал на + транзиентном «Request timed out» (стрим роутера умер в event_loop) при + выполнении aa8abf. Причина инфраструктурная, не код: тик повторяется сам на + следующем часу; задача requeue'd с сохранением delivery-await (Alex получит + результат в TG автоматически). Пятый падёж за сутки на таймаутах — + контрмеры уже в проде (tick_timeout, изолированный runner с killpg, + авто-схлопывание failed>24ч), наблюдение продолжается. +- Открытых эскалаций на конец тика: **0**. + +## Сделано в тике: AUM v1 (t-261004000834-ed84f5, [L]) ✅ + +**AUM впервые измерен: 26.70 USD (было None).** + +1. `docs/aum/balances.json` заведён по шаблону: OpenRouter кредиты коопа + **26.70$** (API: total=163, usage=136.30), Stripe live available **0.0$** + (livemode=True, available=0/pending=0 — presale-выручки ещё нет), LLC-счёт и + крипто-кошелёк — `null` (ждут ручного ввода Alex; в total не входят — метрика + честная, не раздутая). +2. Авто-обновление балансов в `harness/aum.py`: `refresh_auto_balances()` — + счета с `auto:true` обновляются из API перед каждым снапшотом `measure()` + (включая дневной `AumMeasured`): OpenRouter `/api/v1/credits`, Stripe + `/v1/balance`. Только read-only GET, ключи из локальных .env (в стор/логи не + попадают), сетевая ошибка → остаётся прошлое значение (stale честнее + выдуманного), запись атомарная (tmp+rename), выключатель + `SIMBIOT_AUM_AUTO_REFRESH=0`. +3. Защита от фальшивых денег: Stripe test-balance (livemode=False, pending + $335.24) в AUM **не** актив — проверено обоими ключами; тест livemode=False + → None. +4. `cli.py aum --record` записан: `policy.aum_target` обновлён — **цель в + контексте каждого тика min/hour/day теперь с числом**: «AUM=26.7 у.е.». Приёмка + из постановки выполнена: `cli.py aum` → 26.7, дневной `AumMeasured` завтра + уйдёт в стор уже не с None. + +Осталось для полного AUM (нужно Alex): суммы LLC-счёта, крипто-кошелька, +9router prepay — заполнение null-строк поднимет total автоматически. + +## Верификация системы + +- Тесты репо: **17/17 файлов зелёные** (прямой запуск; unittest-discover по + этим файлам неприменим — часть тестов скриптового стиля с sys.exit на уровне + модуля, это не регрессия логики). +- Аудит стора `cli.py verify`: 828 518 событий, подписи ed25519 + тампер-цепочка + **валидны** (ключ faf8d19d9aed71fe). +- Доска: открытых задач 1 (aa8abf, ready, M — на часовой), failed 0. +- Коммит: часы-наработка (task list без клонов, tg_inbox env-fallback) + AUM v1 + вынесены в git. + +## Метрики + +- **AUM: 26.70 у.е.** (первый непустой замер; полный — после ввода Alex). +- burn 9router: 24ч=1.64 · 7д=2291.81 у.е. Тренд благоприятный: 7д-окно + снизилось с 2521.59 (манифест вчера) до ~2292 — экономия после переключения + конфигураций держится. +- Стикerness уровней: min/hour под гейтом тихо (критичных сигналов нет) — + расходы на тики экономятся; day-тик выполнил манифест и закрыл L-задачу. + +## На завтра + +- Заполнение balances.json Alex'ом (LLC, крипта, 9router prepay) → полный AUM. +- 4bbc6f: launch-kit → публикация с аккаунтов Alex (учесть 3-через-3), первые + UTM-метрики → цикл «канал → клики → checkout». +- Мониторить второй заход aa8abf на часовом (результат должен уйти в TG). diff --git a/harness/aum.py b/harness/aum.py index b8f14c9..210bae7 100644 --- a/harness/aum.py +++ b/harness/aum.py @@ -69,6 +69,111 @@ def read_balances(): return {"total": None, "accounts": [], "src": "balances.json (нет файла)"} +# ────────────────── авто-источники балансов (t-261004000834-ed84f5) ────────────────── +# AUM не должен жить только на ручном вводе: что измеряется API — измеряем сами. +# Принцип: read-only GET, ключи только из локальных .env (в сток/логи не попадают), +# ошибка сети/ключа → старое значение остаётся (stale-факт честнее выдуманного). + +AUTO_REFRESH_TIMEOUT = 15 # сек на API-запрос + + +def _env_file_value(path: str, prefix: str): + """Значение VAR=… из .env-файла (первое вхождение) или None.""" + try: + with open(path, encoding="utf-8") as f: + for line in f: + if line.startswith(prefix + "="): + return line.split("=", 1)[1].strip().strip('"').strip("'").strip() or None + except OSError: + pass + return None + + +def _http_json(url: str, headers: dict): + import urllib.request + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=AUTO_REFRESH_TIMEOUT) as resp: + return json.load(resp) + + +def refresh_openrouter_credits(env_path: str = None): + """Доступные кредиты OpenRouter коопа (USD) или None (нет ключа/ошибка).""" + env_path = env_path or os.path.join(HARNESS_ROOT, ".env") + key = _env_file_value(env_path, "OPENROUTER_KEY") + if not key: + return None + try: + d = _http_json("https://openrouter.ai/api/v1/credits", + {"Authorization": f"Bearer {key}"}) + inf = d.get("data", {}) + total, usage = inf.get("total_credits"), inf.get("total_usage") + if total is None: + return None + return round(float(total) - float(usage or 0.0), 2) + except Exception: + return None + + +def refresh_stripe_available(coop_env: str = "/root/coop/.env"): + """Stripe live available USD или None. Только livemode=True: тестовые + деньги (livemode=False) в AUM не актив — иначе метрика врёт.""" + key = _env_file_value(coop_env, "STRIPE_LIVE_KEY") + if not key: + return None + try: + d = _http_json("https://api.stripe.com/v1/balance", + {"Authorization": f"Bearer {key}"}) + if not d.get("livemode"): + return None + for b in d.get("available", []): + if b.get("currency") == "usd": + return round(b["amount"] / 100.0, 2) + return 0.0 + except Exception: + return None + + +def refresh_auto_balances(path: str = None, coop_env: str = "/root/coop/.env"): + """Обновить accounts с auto:true в balances.json значениями из API. + + Роутинг по имени счёта (openrouter → кредиты, stripe → live available). + Файла нет / ни одно auto-поле не обновилось → ничего не пишем. + Возвращает список имён обновлённых счетов.""" + path = path or os.environ.get("SIMBIOT_BALANCES_JSON", BALANCES_JSON) + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except Exception: + return [] + updated = [] + for acc in data.get("accounts", []): + if not acc.get("auto"): + continue + name = (acc.get("name") or "").lower() + val = None + if "openrouter" in name: + val = refresh_openrouter_credits() + elif "stripe" in name: + val = refresh_stripe_available(coop_env) + if val is None: + continue + acc["amount_usd"] = val + acc["measured_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") + updated.append(acc.get("name")) + if not updated: + return [] + data["as_of"] = datetime.now(timezone.utc).date().isoformat() + try: + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + os.replace(tmp, path) # атомарно: читатели не увидят полуфайл + except Exception: + return [] + return updated + + def read_router_spend(days: int = 7): """Суточный расход 9router за N дней: {date: {cost, reqs}}. Ошибка → {}.""" try: @@ -99,7 +204,17 @@ def read_coop_structure(): # ────────────────────────── замер ────────────────────────── def measure(): - """Снапшот метрики: AUM (если замерен) + burn 24ч/7д + структура кооператива.""" + """Снапшот метрики: AUM (если замерен) + burn 24ч/7д + структура кооператива. + + Авто-балансы (accounts c auto:true) перед замером обновляются из API + (t-261004000834-ed84f5). SIMBIOT_AUM_AUTO_REFRESH=0 выключает (тесты/ + офлайн-режим); сетевая ошибка не валит снапшот — останется прошлое + значение (stale честнее выдуманного).""" + if os.environ.get("SIMBIOT_AUM_AUTO_REFRESH", "1") not in ("0", "false", "no"): + try: + refresh_auto_balances() + except Exception: + pass # обновление — best effort: замер burn важнее и идёт всегда bal = read_balances() spend = read_router_spend(days=7) days_iter = list(spend.values()) diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index 8cba361..dd93c64 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -35,6 +35,12 @@ def _load_env(): TG_TOKEN = line.split("=", 1)[1].strip() elif line.startswith("SIMBIOT_TG_ALLOWED="): ALLOWED = {x.strip() for x in line.split("=", 1)[1].split(",") if x.strip()} + _tok = os.environ.get("SIMBIOT_TG_TOKEN", "").strip() # systemd EnvironmentFile задаёт переменные, но не пишет .env + if not TG_TOKEN and _tok: + TG_TOKEN = _tok + _allow = os.environ.get("SIMBIOT_TG_ALLOWED", "").strip() + if TG_TOKEN and not ALLOWED and _allow: + ALLOWED = {x.strip() for x in _allow.split(",") if x.strip()} def _api(method: str, params: dict, timeout: int = 35): diff --git a/tests/test_aum.py b/tests/test_aum.py index 5f5e561..21c87e1 100644 --- a/tests/test_aum.py +++ b/tests/test_aum.py @@ -127,6 +127,75 @@ r = subprocess.run(["/root/simbiot-harness/venv/bin/python", "/root/simbiot-harn capture_output=True, text=True, timeout=120, env=env) check("cli aum-pilot ok", r.returncode == 0 and "вердикт" in r.stdout, r.stderr[:200]) +# ── 9. авто-обновление балансов из API (t-261004000834-ed84f5) — hermetic ── +import tempfile +tmpdir = tempfile.mkdtemp(prefix="simbiot-aum-") + +# 9a: файла нет → [] без падения +check("refresh_auto: нет файла → []", + aum.refresh_auto_balances(path="/nonexistent-balances.json") == []) + +# 9b: auto-счёт, но ключей нет → значение не пишется, ошибок нет +bal_nokey = os.path.join(tmpdir, "b1.json") +with open(bal_nokey, "w") as f: + json.dump({"accounts": [{"name": "Stripe live balance (available)", + "amount_usd": 0.0, "auto": True}]}, f) +_saved_efv = aum._env_file_value +aum._env_file_value = lambda *a, **k: None # ключей нет +try: + got = aum.refresh_auto_balances(path=bal_nokey, coop_env=os.path.join(tmpdir, "no.env")) +finally: + aum._env_file_value = _saved_efv +check("refresh_auto: без ключа → [] и файл не тронут", + got == [] and json.load(open(bal_nokey))["accounts"][0]["amount_usd"] == 0.0) + +# 9c: _http_json мокается → суммы пишутся, measured_at/as_of обновляются, +# ручные счета не трогаются +bal_mock = os.path.join(tmpdir, "b2.json") +with open(bal_mock, "w") as f: + json.dump({"accounts": [ + {"name": "OpenRouter кредиты", "amount_usd": None, "auto": True}, + {"name": "Stripe live balance", "amount_usd": None, "auto": True}, + {"name": "ручной счёт Alex", "amount_usd": 5.0}], + "as_of": "2000-01-01"}, f) +with open(os.path.join(tmpdir, "stripe.env"), "w") as f: + f.write("STRIPE_LIVE_KEY=sk_test_fake\n") + + +def _fake_http(url, headers): + if "openrouter" in url: + return {"data": {"total_credits": 100, "total_usage": 40}} + return {"livemode": True, "available": [{"currency": "usd", "amount": 12345}]} + + +_saved_http = aum._http_json +aum._http_json = _fake_http +try: + got2 = aum.refresh_auto_balances(path=bal_mock, + coop_env=os.path.join(tmpdir, "stripe.env")) +finally: + aum._http_json = _saved_http +d2 = json.load(open(bal_mock)) +check("refresh_auto: mock API → 60.0 и 123.45 записаны", + len(got2) == 2 and abs(d2["accounts"][0]["amount_usd"] - 60.0) < 0.001 + and abs(d2["accounts"][1]["amount_usd"] - 123.45) < 0.001, + f"got2={got2} d2={d2}") +check("refresh_auto: measured_at у auto, ручной не тронут, as_of обновлён", + bool(d2["accounts"][0].get("measured_at")) + and d2["accounts"][2]["amount_usd"] == 5.0 and d2["as_of"] != "2000-01-01") + +# 9d: Stripe livemode=False → None: тестовые деньги в AUM не актив +def _fake_http_testmode(url, headers): + return {"livemode": False, "available": [{"currency": "usd", "amount": 33524}]} + + +aum._http_json = _fake_http_testmode +try: + v_test = aum.refresh_stripe_available(coop_env=os.path.join(tmpdir, "stripe.env")) +finally: + aum._http_json = _saved_http +check("stripe: livemode=False → None (не актив)", v_test is None) + print() print("ИТОГ:", "ВСЕ ОК" if not fails else f"ПРОВАЛЫ: {fails}") sys.exit(1 if fails else 0) \ No newline at end of file