Skip to content

Local-Bot Dashboard Runbook ​

Operator-facing playbook for the Local-Bot Mode integration between pm-engine and pm-engine-dashboard (ADR-005). Covers enabling the engine SSE server, consuming the SSE stream, managing sessions, distinguishing live vs probe, and triaging the most common failure modes.

Миграция pm-bot → pm-engine завершена. Боевой бинарь — pm-engine (был pm-bot); дашборд — pm-engine-dashboard (был pm-dashboard), единственный дашборд. Local-API движка биндится через PM_ENGINE_SSE_* (был BOT_LOCAL_API_*); дашборд-прокси ходит на upstream PM_ENGINE_SSE_URL (legacy-зеркало PM_BOT_LOCAL_API_URL) с bearer PM_ENGINE_SSE_TOKEN (legacy-зеркало BOT_LOCAL_API_TOKEN). Опт-ин BOT_LOCAL_API_ENABLED движком НЕ читается — он заменён на PM_ENGINE_SSE_ENABLED.

Related:

  • ADR-005 — pm-engine ↔ pm-engine-dashboard integration (три структурных инварианта INV-A/B/C и resolved-вопросы); отдельного файла ADR в docs/adr/ нет, решение живёт в ../target/engine.md (Observability / SSE layer).
  • crates/pm-bot-events — canonical wire contract (BotEvent enum, EventEnvelope, SessionMeta, BotSnapshot, LocalApiError), общий между pm-engine и pm-engine-dashboard. Имя крейта историческое (pm-bot-events), менять не нужно.
  • env/.env.example — PM_ENGINE_SSE_* env knobs.
  • docs/history/tech-debt-backlog-2026-08-31.md — deferred Local-Bot Mode items.

1. Overview ​

Порты — читать до первой команды. Локально pm-engine-dashboard слушает 0.0.0.0:31350 по умолчанию (crates/pm-engine-dashboard/src/config.rs). :31337 — это ПРОДОВОЕ значение на VPS, где контейнеру задан PM_ENGINE_DASHBOARD_BIND_ADDR=0.0.0.0:31337. Все локальные примеры ниже идут на :31350; если запускаете с явным override — подставляйте свой порт. Имя переменной ровно одно: PM_ENGINE_DASHBOARD_BIND_ADDR (голого DASHBOARD_BIND_ADDR код не читает).

pm-engine-dashboard runs as a stateless wallet-explorer at http://localhost:31350/wallet/{address} (Polymarket Data API polled every 60s, no dependency on the running engine). Local-Bot Mode is an additive layer that gives the operator a real-time SSE stream from a running pm-engine process — live PnL, latency, fills, signals, emergency-stop banner — plus a permanent SQLite journal (engine.db) for post-mortem analysis.

Two surfaces coexist in the same SPA, served by the same dashboard process:

Маршрут SPAРежимИсточник
/ («Обзор»)Local-Bot Modepm-engine SSE on :31342, proxied through pm-engine-dashboard.
/wallet/{address}Wallet-ExplorerPolymarket Data API, 60s polling. Independent of pm-engine.

Отдельного маршрута /local больше нет. До перехода на SPA (X1.4b, ADR-012) Local-Bot Mode был самостоятельной страницей /local, которую рендерил сам Rust-бэкенд. Сейчас это главная страница SPA /, а имя local осталось только в HTTP-API (/api/local/*) — его не переименовывали и не будут. Везде ниже «Local-Bot Mode» = главная страница SPA.

When to use Local-Bot Mode:

  • Post-mortem of a session (fills timeline, decision/signal sequence, latency histories).
  • Watching an active pm-engine process for emergency stops, RTDS toxic-flow cancels, or order-rejection patterns from the web UI.
  • Live drain / resume / reset control (see §6).

When to use Wallet-Explorer Mode:

  • Reading the public Polymarket positions of any wallet without a local engine process.
  • Quick portfolio overview after pm-engine has stopped.

How to reach it: the wallet link in the «Обзор» page header (0xBA56…B7F4 (live)) opens /wallet/{address} for the wallet of the running strategy. Clicking any wallet row under «Кошельки» opens the same route for that row's address. There is no top-nav item; the header address is resolved from the wallet label in SessionMeta.wallet through the wallets store, so it appears only once /api/wallets has loaded and the label exists there.

Do not confuse the two number sets: wallet-explorer reads the Polymarket Data API (all on-chain positions of the address, including anything the engine never opened), while «Обзор» shows what the engine itself booked. A divergence between them is expected, not a defect.

Note on the bare /{address} path. Before X1.4b (ADR-012) the Rust backend rendered the explorer itself at GET /{wallet}; that handler is gone. The path is not a redirect. Hitting the backend directly returns 404. Behind Caddy it returns 200 with the SPA's real index.html (try_files {path} /index.html) — measured 827 bytes, bundle loads — and then renders NOTHING, because vue-router has no matching route and no catch-all. Never use it as a liveness probe — probe GET /api/{wallet}/snapshot.

Independence contract (D1): killing or restarting pm-engine MUST NOT break wallet-explorer mode. The main page surfaces a banner when pm-engine is offline; the wallet route is unaffected.


2. Quick start ​

2.1 Enable the engine SSE server ​

The SSE server is opt-in — by default PM_ENGINE_SSE_ENABLED is unset/false and pm-engine runs without binding the axum SSE server.

sh
# Minimum opt-in: master switch only, defaults for the rest
PM_ENGINE_SSE_ENABLED=true cargo run -p pm-engine

# With a custom bind / token (remote ops via SSH tunnel or docker-net)
PM_ENGINE_SSE_ENABLED=true \
PM_ENGINE_SSE_BIND=127.0.0.1:31342 \
PM_ENGINE_SSE_TOKEN=<32-hex-bytes-or-more> \
  cargo run -p pm-engine

Fail-fast (design §7-SSE.5): a non-loopback PM_ENGINE_SSE_BIND (0.0.0.0:* / public IP) without PM_ENGINE_SSE_TOKEN panics at startup — the token is mandatory whenever the bind leaves loopback. On the production compose the engine binds 0.0.0.0:31342 with the token supplied from the instance .env.

Opt-in SSE / локального API в pm-engine — PM_ENGINE_SSE_ENABLED (default OFF; crates/pm-engine/src/main.rs); legacy pm-bot флаг BOT_LOCAL_API_ENABLED в движке отсутствует. Session-строку в БД движок НЕ создаёт: идентичность сессии выводится из процесса (SeedSessionMeta → SessionMeta c id=1, один runtime на процесс; crates/pm-engine/src/sse/seed.rs), в БД не пишется.

2.2 Start pm-engine-dashboard ​

pm-engine-dashboard runs on :31350 locally (see the port note in §1). The proxy discovers pm-engine via PM_ENGINE_SSE_URL (default http://127.0.0.1:31342; legacy-зеркало PM_BOT_LOCAL_API_URL). Bearer token — PM_ENGINE_SSE_TOKEN (legacy-зеркало BOT_LOCAL_API_TOKEN); дашборд держит его server-side, SPA токен не видит.

sh
cargo run -p pm-engine-dashboard
# or with debug logging
RUST_LOG=pm_engine_dashboard=debug cargo run -p pm-engine-dashboard

2.3 Open the UI ​

Бэкенд отдаёт только JSON и SSE; HTML — это SPA. Локально её поднимает Vite, в проде её раздаёт Caddy.

sh
cd frontend/pm-engine-dashboard-web && pnpm dev

Vite слушает :5174 и проксирует /api на http://localhost:31350 (vite.config.ts; strictPort: false, поэтому при занятом порте он молча уедет на соседний — смотрите строку, которую печатает сам Vite). Прокси подставляет BOT_LOCAL_API_TOKEN из корневого .env server-side, так что бандлу токен не достаётся.

http://localhost:5174/          # Local-Bot Mode (главная «Обзор»)

Голый http://localhost:31350/ — это не UI, а текстовая подсказка оператору (routes::dashboard::info_page).

The SPA loads, the EventSource('/api/local/stream') subscription opens, the /api/local/seed GET returns the current BotSnapshot, and the widgets begin rendering. The session reports factual mode live or probe.


3. Environment knobs ​

The SSE knobs are opt-in / semantic-Optional — when PM_ENGINE_SSE_ENABLED is unset/false the axum server is not bound.

Env varDefaultRead byPurpose
PM_ENGINE_SSE_ENABLEDfalsepm-engineMaster switch. false ⇒ SSE server not bound.
PM_ENGINE_SSE_BIND127.0.0.1:31342pm-engineTCP bind. Loopback-only by default. Non-loopback bind REQUIRES PM_ENGINE_SSE_TOKEN (fail-fast panic otherwise).
PM_ENGINE_SSE_TOKENemptypm-engine + dashboardBearer token. If set, every endpoint requires Authorization: Bearer <token> (constant-time compare). Mandatory on non-loopback bind.
PM_ENGINE_SSE_URLhttp://127.0.0.1:31342pm-engine-dashboardWhere the dashboard /api/local/* proxy points. Legacy-зеркало: PM_BOT_LOCAL_API_URL. Match the engine's PM_ENGINE_SSE_BIND.
BOT_LOCAL_API_TOKENemptypm-engine-dashboardLegacy-зеркало для PM_ENGINE_SSE_TOKEN на стороне дашборда (LIVE — оставлено ради обратной совместимости конфигов).

Why opt-in (not fail-fast): Local-Bot Mode is an observability / journal layer, not a trading-correctness primitive. A pm-engine that can trade but cannot serve the SSE API is still trading correctly; the dashboard simply 503's. Forcing every operator to set the SSE vars to trade would create a footgun without trading-safety value.


4. Endpoint reference ​

All endpoints are served by pm-engine on PM_ENGINE_SSE_BIND. pm-engine-dashboard exposes them under /api/local/* as a proxy (503 when pm-engine is unreachable). Response shapes are typed in crates/pm-bot-events/src/lib.rs — consult that file for authoritative field-by-field schemas.

Verified engine routes (crates/pm-engine/src/sse/mod.rs):

MethodPathReturns / purpose
GET/api/local/streamtext/event-stream SSE multiplex (see §5). Optional Last-Event-ID header for replay.
GET/api/local/seedBotSnapshot — full materialised view (session meta, open orders, positions, latest metrics, emergency flag). First SPA load + after event: resync.
GET/api/local/sessions/{id}/decisions?limit=N&from=KVec<EventEnvelope> — event_log slice for a session (decision/signal replay).
GET/api/local/fillsFills feed for the active session.
GET/api/local/identityEngine identity (wallet / strategy / git_sha).
GET/api/local/position_quotesTop-of-book quotes for tokens in the active set.
GET/api/local/unrealized_pnl_historyUnrealized-PnL time series for the equity/drawdown widgets.
GET/api/local/balance_historyЧасовая серия баланса pUSD за 7 суток. Беспараметрический: всегда РОВНО 168 слотов [текущий час − 167ч, текущий час], ASC. Час без успешной выборки — balance_usdc: null (ключ присутствует всегда; разрыв, а не ноль). Источник — таблица balance_hourly, которая НЕ стирается операторским Reset.
GET/api/local/budget_historyИстория применённой пары базового и effective бюджета активной стратегии. Всегда РОВНО 168 UTC-слотов [текущий час − 167ч, текущий час], ASC; отсутствующий атомарный снимок даёт оба null. Источник — budget_hourly; Polygon RPC для чтения истории не вызывается.
GET/api/local/realized_pnl_historyНакопительный реализованный PnL за те же 168 часовых слотов. realized_usd НЕ nullable: час без закрытий несёт значение предыдущего. Накопление С НАЧАЛА ОКНА, не all-time. Выводится SQL по positions — своей таблицы и писателя нет.
POST/api/local/orders/{id}/cancelCancel a single resting order.
POST/api/local/emergency-stopOperator emergency halt (see §6). Idempotent.
POST/api/local/drain / GET /api/local/drainEnter DRAIN MODE / read drain state. See runbook.md §Manual entry-halt.
POST/api/local/resumeLeave DRAIN MODE.
POST / GET/api/local/restartStart / observe the single-flight «Рестарт системы» workflow. POST requires X-Confirm-System: RESTART plus body confirmation and explicit cancel_orders, exit_positions, reset_statistics booleans.
POST/api/local/reset (+ alias /api/local/sessions/reset)Reset session + self-restart (see §6). Guarded by X-Confirm-Session: RESET header AND body {"confirmation":"RESET"}.

The dashboard proxy additionally surfaces /api/local/sessions (list) and /api/local/sessions/{id}/kpi for the session picker / KPI rollups.

Auth: when PM_ENGINE_SSE_TOKEN is set, every endpoint requires Authorization: Bearer <token> (constant-time compare). The dashboard proxy injects it server-side.

Errors: typed LocalApiError JSON bodies with HTTP status reflecting the kind (401 Unauthorized, 404 NotFound, 409 conflict, 503 ServiceUnavailable). The dashboard proxy preserves the body verbatim.

Часовые серии графиков главной страницы. Две серии кормят ряд из трёх графиков (баланс → реализованный PnL → нереализованный PnL). Оси НЕ сведены к общей шкале: это три самостоятельных виджета, ряд — только компоновка.

balance_history пишется фоновой задачей обновления баланса при каждой УСПЕШНОЙ выборке pUSD (без дополнительных RPC-вызовов), бакет — last-write-wins («часовое закрытие»), retention 168 бакетов. Таблица balance_hourly намеренно вне wipe_runtime_data — полный перечень таблиц, переживающих Reset, и обоснование каждой: target/storage.md §«Операторский Reset». Писатель существует только при заданном PM_ENGINE_RPC_URL; без него серия останется пустой, и UI сообщает об этом явно.

realized_pnl_history выводится запросом по positions и наследует ТРИ ПРИНЯТЫХ ограничения (решение оператора 2026-07-26 — «без нового писателя»):

  1. операторский Reset удаляет positions, поэтому кривая обнуляется, тогда как соседний график баланса Reset переживает;
  2. у закрытий через resolution/redeem closed_at — момент подтверждения on-chain redeem, отстающий от экономического события до ~24 часов на погодных рынках; корректно времени́рована лишь SELL-exit-часть популяции;
  3. resolution_sweep дописывает реализованный PnL задним числом, поэтому уже отданный час может измениться при следующем обновлении.

Это ПОВЕДЕНИЕ, а не дефекты. Предикаты отбора (strategy, status IN ('CLOSED','REDEEMED'), deleted_at IS NULL, closed_at IS NOT NULL) совпадают с sum_realized_pnl_closed — иначе оператор получил бы третье несогласованное число о реализованном PnL.

Legacy pm-bot endpoints /api/local/health, /api/local/sessions/rotate, /api/local/sessions/:id/events, /api/local/positions, /api/local/sim-positions в engine SSE-роутере отсутствуют (crates/pm-engine/src/sse/mod.rs). Движок регистрирует: stream, sessions/{id}/decisions, seed, fills, identity, position_quotes, unrealized_pnl_history, balance_history, realized_pnl_history, orders/{id}/cancel, emergency-stop, drain (GET+POST), resume, reset, sessions/reset. Маршрута rotate нет (dashboard-сторона удалила /sessions list и /sessions/{id}/kpi proxy, crates/pm-engine-dashboard/src/routes/local.rs).


5. SSE event taxonomy ​

Single multiplexed endpoint /api/local/stream carries all named events. Clients filter via EventSource.addEventListener('<name>', cb). Every payload is a JSON-serialised BotEvent variant (the kind field matches the SSE event name). Event names come from the retained pm-bot-events wire crate.

SSE event nameSemantics
order_placedExecutor place ack — order is live on CLOB.
order_cancelledUnified cancel/expiry.
order_rejectedExecutor place err path — CLOB rejected the POST.
fillWS-derived fill (source: "live").
order_filled_inferredREST-polling fallback detected a fill the WS missed.
position_openedFirst fill on a new condition_id ⇒ position created.
position_closedPosition size went to zero ⇒ realized PnL emitted.
signalA strategy evaluate_* produced a Decision worth journaling.
late_entry_rejectedDirectional decision arrived after the deadline — order NOT placed.
merge_positions_called / merge_positions_confirmedMerge SDK ack / Polygon tx confirmation.
metrics_tick1 Hz bundle: latency p50/p95, command counters, WS reconnect counts, is_emergency.
leaderboard_updatedPolymarket leaderboard rank/PnL/volume refresh.
ws_connected / ws_disconnectedMarket or User WS reconnected / dropped. Frontend WS-status dots flip.
session_started / session_endedSession lifecycle (auto-create on start / rotate / reset).
quote_tickTop-of-book bid/ask/mid удерживаемых токенов. Фиксированные 500 мс независимо per-token: immediate sparse + latest-wins trailing; одинаковый BBO не повторяется.
position_attributes_updatedSparse mutation of an open position's exit_price / avg_entry_price / size / status, tagged with a typed reason.
heartbeatControl — keep-alive. Client should reconnect if silent too long.
resyncControl — broadcast lag exceeded threshold ⇒ client must GET /api/local/seed and resume.

Таблица перечисляет полный набор BotEvent-вариантов wire-контракта pm-bot-events (superset), а не гарантированный per-engine emit-set. Подтверждённые эмиттеры в pm-engine: fill (crates/pm-engine/src/wire/bot_event_map.rs, crates/pm-engine/src/engine/position_registry.rs), quote_tick (crates/pm-engine/src/engine/quote_tick_publisher.rs), metrics_tick (crates/pm-engine/src/observability/). Для ряда вариантов (session_started / session_ended, leaderboard_updated, merge_positions_*, position_attributes_updated, order_filled_inferred) emit-sites в текущем движке не найдены — они часть контракта, но движком могут не эмитироваться.

Replay via Last-Event-ID: on reconnect with Last-Event-ID: N, the server replays events with seq > N in seq order, then switches to live broadcast.


6. Sessions, reset, drain ​

6.1 What a session is ​

A session row uniquely identifies a single pm-engine run (or a labelled segment). It carries started_at / ended_at, strategy (the only active value is directional), wallet, git_sha, and mode ('live' / 'probe'). A session is auto-created when pm-engine starts.

6.2 «Рестарт системы»: применение сохранённой стратегии ​

Глобальная кнопка находится в top-bar рядом с Drain. Сам рестарт всегда перезапускает только pm-engine и тем самым перечитывает актуальный config.db; три галочки управляют подготовкой:

  1. Отменить все ордера — workflow блокирует новые BUY, отменяет каждый durable open-order и не продолжает при частичной ошибке.
  2. Выйти из всех позиций — автоматически включает отмену ордеров. Только после завершения cancel-all взводится отдельный workflow force-exit; стратегия на каждом тике использует канонический FAK-at-best-bid путь и workflow ждёт durable open_positions == 0 (не более 15 минут).
  3. Сбросить статистику — вызывает исторический wipe_runtime_data, поэтому разрешён только при нуле открытых ордеров и позиций. Без галочки торговые таблицы и история переживают рестарт.

Все разрушительные галочки по умолчанию выключены. Progress читается через GET /api/local/restart; закрытие вкладки не отменяет engine-owned workflow. Любая ошибка отмены, чтения БД, flat-проверки или таймаут liquidation переводит workflow в failed: wipe, RuntimeResetting и exit(0) не выполняются. Временная блокировка BUY/force-exit не является persistent Drain и не может стереть уже активный операторский/circuit-breaker Drain.

Взаимное исключение с Reset. Пока в этом процессе идёт POST /api/local/restart, конкурентный POST /api/local/reset отказывает 409 ДО отмены заявок и стирания состояния — и наоборот, идущий Reset блокирует новый рестарт до своего завершения. Точная форма обоих 409 — таблица «REST snapshots» в wire-contract.md.

Legacy /reset: cancel-all + wipe + self-restart ​

Внимание: «Reset session» и POST /api/local/emergency-stop — две независимые операции. Reset перезагружает процесс ради подхвата свежей config.db; он не связан с возобновлением после emergency-stop. Emergency-stop возобновляется ТОЛЬКО полным перезапуском процесса pm-engine (якорь A6). Reset через UI тоже завершает процесс, поэтому фактически снимает emergency-флаг как побочный эффект — но опираться на это не следует: при инциденте предпочтите явный docker compose restart pm-engine на VPS, чтобы шаг ребута был осмысленным operator action, а не побочным эффектом UI-кнопки.

Legacy endpoint (в новом WebUI отдельной кнопки нет) выполняет:

  1. Отменяет все live ордера (cancel-all).
  2. Ротирует journal session (новая session_id).
  3. Эмитит SSE-событие о рестарте.
  4. Запускает второй cancel-all как страховку.
  5. Стирает runtime-state (engine.db data-таблицы) и завершает процесс через process::exit(0). Стирается не вся БД: три таблицы намеренно переживают Reset — исчерпывающий перечень и обоснование каждой в target/storage.md §«Операторский Reset».

Цель: новая сессия стартует со свежей strategy config из config.db (например, после редактирования knobs через WebUI configurator, ADR-015), без ручного рестарта оператором. config.db (strategy knobs) Reset'ом не стирается — только runtime-state engine.db.

Перезапуск процесса ​

СредаПоведение
Docker (production VPS)Контейнер pm-engine-harvester-engine имеет restart: unless-stopped — Docker автоматически перезапустит на exit(0). Дополнительная конфигурация не нужна. Dashboard / capture sidecar на reset не self-exit'ят.
Local cargo run -p pm-engineПроцесс просто завершится. Для seamless-рестарта: until cargo run -p pm-engine -- <args>; do sleep 2; done. Без wrapper'а — ручной рестарт.

Что НЕ перезапускается ​

  • pm-engine-dashboard (отдельный процесс) — продолжает работать, переподключается к новому pm-engine процессу через SSE.
  • Задания бэктеста — их больше НЕТ: подсистема снята 2026-08-21 вместе с маршрутами и супервизором заданий дашборда.

Trigger из CLI ​

Кроме WebUI кнопки, Reset можно вызвать через curl (порт движка :31342; при заданном PM_ENGINE_SSE_TOKEN добавить -H 'Authorization: Bearer <token>'):

sh
curl -X POST http://127.0.0.1:31342/api/local/sessions/reset \
  -H 'X-Confirm-Session: RESET' \
  -H 'Content-Type: application/json' \
  -d '{"confirmation":"RESET"}'

Оба гварда (header X-Confirm-Session: RESET И body confirmation:RESET) обязательны — иначе 400, без побочных эффектов.

6.3 Emergency-stop ​

POST /api/local/emergency-stop — идемпотентный operator-halt: отменяет открытые ордера и блокирует новые входы на время жизни процесса. Возобновление — ТОЛЬКО полный рестарт процесса pm-engine (якорь A6). Не путать с per-position automated stop-loss (ADR-027) и с DRAIN MODE (обратимый, только BUY-сторона).

6.4 Drain / resume ​

Обратимый entry-halt (движок перестаёт выставлять новые BUY, выходы продолжаются). Полный флоу оператора, endpoints и SSH/curl fallback — runbook.md §«Manual entry-halt (DRAIN MODE)».

6.5 Journal retention (устарело) ​

Legacy pm-bot имел ручной pm-cli journal gc --older-than <N>d + in-process auto-prune scheduler (BOT_JOURNAL_AUTO_PRUNE_*) для retention журнала. После упрощения session-модели этот механизм удалён: pm-cli journal gc теперь deprecated no-op (exit 0 с уведомлением, ничего не удаляет; crates/pm-cli/src/journal.rs), а BOT_JOURNAL_AUTO_PRUNE_* движком не читается. Исторический playbook retention сохранён в ../history/journal-retention-2026-05-03.md. Активной retention-политики для engine.db в коде движка нет: планировщик auto-prune отсутствует, схема растёт без авто-обрезки.


7. Live/probe distinction ​

The session mode field is the canonical source of truth and is fixed at process start. live means continuous real trading; probe means the same real exchange adapter behind the bounded one-shot probe guards. Neither mode synthesizes fills or PnL.


8. Graceful degradation ​

Failure modeEffect on tradingEffect on dashboard
PM_ENGINE_SSE_ENABLED unset/falseNone — SSE server not bound./api/local/* → 503, баннер «engine offline» на главной. Wallet route unaffected.
pm-engine crashed / not startedn/a (it's down)/api/local/* → 503. Wallet route unaffected.
SSE broadcast lag exceeds thresholdNoneServer emits event: resync; client refetches /api/local/seed and resumes.
WS disconnected (Market or User channel)RTDS / WS reconnect with backoff; CLOB reconciliation continues via REST polling.WS-status dot flips red until reconnect; orderbook freshness stale until then.
Manual edit of position state while pm-engine runsIn-memory ledger stays at pre-edit value until restart — admission budget desyncs from DB truth.Mitigation: restart pm-engine after any manual state edit (the ledger is seeded from scratch on every startup).

9. Troubleshooting ​

"503 on /api/local/*" ​

pm-engine is offline OR PM_ENGINE_SSE_ENABLED is unset/false.

  1. Confirm pm-engine is running: ps aux | grep pm-engine (locally) or docker ps | grep pm-engine-harvester-engine (VPS).
  2. Confirm the switch: launching shell / instance .env has PM_ENGINE_SSE_ENABLED=true.
  3. Confirm the bind + reachability: curl http://127.0.0.1:31342/api/local/seed — if this responds then pm-engine is up but the dashboard's PM_ENGINE_SSE_URL points elsewhere.

«Баннер EmergencyStop на главной странице» ​

The is_emergency flag is set in BotSnapshot / metrics_tick. Tail pm-engine logs for the trigger reason (balance discrepancy detector, operator-triggered emergency-stop endpoint, repeated CLOB rejection). Stop the engine, investigate, restart cleanly (resume requires a full process restart per якорь A6).

«Как проверить режим сессии» ​

Таблицы сессий в engine.db нет — идентичность сессии выводится из процесса, а mode (live или probe) отдаётся в BotSnapshot.session.mode (crates/pm-engine/src/sse/seed.rs):

sh
curl -s http://127.0.0.1:31350/api/local/seed | jq '.session.mode'

"Connection dot stays red" ​

(a) curl http://127.0.0.1:31350/api/local/seed — 503 ⇒ pm-engine down / wrong port; (b) DevTools → Network → EventSource/api/local/stream должен быть 200 text/event-stream; (c) token mismatch — engine PM_ENGINE_SSE_TOKEN vs dashboard PM_ENGINE_SSE_TOKEN (legacy-зеркало BOT_LOCAL_API_TOKEN).


10. Security model ​

Loopback default. PM_ENGINE_SSE_BIND=127.0.0.1:31342. The local API is not designed for direct LAN exposure — no in-process TLS, no per-user auth. A non-loopback bind REQUIRES PM_ENGINE_SSE_TOKEN (fail-fast).

Remote ops via SSH tunnel:

sh
ssh -L 31342:127.0.0.1:31342 trader@host
# now on the workstation:
PM_ENGINE_SSE_URL=http://127.0.0.1:31342 cargo run -p pm-engine-dashboard

…or run pm-engine-dashboard on the trading host and SSH-forward its port instead (VPS 31337, local default 31350). That gets you /api/* only: the backend serves no HTML, so the SPA needs a forward to Caddy — рецепт с проверенными адресами и обязательным заголовком Host лежит в CLAUDE.md §«Browser access to the live dashboard». On the production VPS the tunnel/Caddy stack terminates TLS; the engine SSE itself never serves TLS.

Bearer token. When PM_ENGINE_SSE_TOKEN is set, every endpoint requires Authorization: Bearer <token> (constant-time compare). The dashboard injects it server-side so the SPA never holds it.

Production browser smoke ​

Единственный повторяемый вход для агентской проверки боевого SPA:

bash
scripts/dashboard-browser-smoke.sh

Скрипт read-only относительно VPS: через docker inspect он разрешает текущий адрес pm-caddy, держит SSH-туннель только на время прогона и ставит перед ним локальный потоковый HTTP-прокси. Прокси меняет только Host на harvester-engine.grot.app для GET/HEAD, поэтому Caddy отдаёт правильный сайт, а SSE не буферизуется. Любой другой HTTP-метод отклоняется локальным 405 и до Caddy не доходит. Playwright открывает loopback URL: строка в /etc/hosts, временный Playwright config и обход Cloudflare Access не создаются.

Прогон подтверждает живую страницу «Обзор», подключённый SSE worker, не менее 15 сохранённых и отрисованных точек в каждом sparkline, отсутствие /backtest и четырёх снятых API, а также отсутствие ошибок страницы и консоли. Нереализованный PnL обязан иметь не менее 15 точек одновременно в модели и renderer, когда в seed есть открытая позиция. При пустой торговой сессии backend-history по действующему контракту пуст, поэтому отчёт возвращает unrealizedObservation: not_observable_no_open_positions, а не подменяет отсутствующее измерение успехом. При открытой позиции midnight требует заголовок, работающую ячейку и изменение её HH:mm в пределах следующей минуты; без открытых строк он возвращает observation: not_observable_no_open_positions, initialCellValues: [] и tickedCellValues: [].

Локальные порты 18180 и 18181 принадлежат скрипту только на время работы. Если один из них уже занят, прогон завершается ошибкой и не завершает чужой процесс. trap закрывает собственную Playwright-сессию, прокси и SSH-туннель при успехе и при любой ошибке. Скрипт не содержит resume, рестарт или торговую команду.


11. Rollback ​

Disable the SSE layer without code changes:

diff
# Revert to trading-only mode (no SSE server, dashboard /api/local/* 503s):
-PM_ENGINE_SSE_ENABLED=true
+PM_ENGINE_SSE_ENABLED=false

After restart the axum SSE server is not bound, /api/local/* 503s, and the trading hot path is unaffected. Existing engine.db rows are preserved; re-enabling later resumes journaling on the same DB.


12. Cross-references ​

  • ADR-005 — pm-engine ↔ pm-engine-dashboard integration (design lives in ../target/engine.md SSE/Observability layer).
  • runbook.md — deploy + smoke + Telegram setup + Manual entry-halt (DRAIN MODE).
  • alerts-and-monitoring.md — pm-engine /metrics (:31340) + in-process AlertEngine → Telegram нотификации (внешний Prometheus/Alertmanager стек = future).
  • tech-debt-backlog-2026-08-31.md — открытые tech-debt items (снимок на момент архивации).
  • ../history/tech-debt-resolved.md — закрытые Local-Bot Mode items.
  • pm-bot-events crate (crates/pm-bot-events/) — canonical wire contract for cross-process communication (имя историческое).