Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions .claude/reports/2026-07-13-console-v2-phase2-s5-dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Отчёт: Console v2 Фаза 2, Этап 5 — Dashboard + read-op шедулер

> Инженер → Гейт-2. Ветка `feat/console-v2-phase2-s5-dashboard` (коммит `feat(app,ui)…`),
> НЕ запушено. Спека: `.claude/specs/2026-07-13-console-v2-phase2-s5-dashboard.md`
> (Гейт-1 две подписи; №1 home=Dashboard ратифицирован Капитаном).

## Сделано — по пунктам спеки

1. **protocol.rs**: `Request::Positions`, клиентский `Position` (9 полей, `extra:
BTreeMap` — рендерим-не-парсим; `protocol` оставлен строкой: неизвестный будущий
протокол рендерится как есть, не валит парс), `PositionsOutcome`, `parse_positions`
(ok/пустой список валиден/wallet_locked/unauthorized+protocol_error → fail-closed/мусор
→ Malformed). 6 тестов, вкл. verbatim «∞»/«80%» и `{"op":"positions"}`-энкод.
2. **transport.rs**: прокладка Request/Reply (паттерн Context, механически).
3. **app.rs — шедулер (ядро)**: `View::Dashboard` (+home после auth — ратификация №1);
`Positions`-тристейт (`NotYet | Loaded | Unavailable`); `read_age` (тики; **сброс в
точке диспетча** — дизайн-находка кодинга: сброс в apply_* заставил бы первые positions
ждать ~30 c после auth-context; born-stale дефолт даёт первый показ сразу после первого
list-reply, как требовала спека); `next_read` (чередование Context/Positions);
`context_stale` (wallet_locked-рефреш хранит данные + метит; успех снимает).
**Политика list-first дословно**: read-op только из `on_reply(List)`, никогда из тика;
`flush_pending()` первым (/check-1); тройная чистота слота; вход на Dashboard метит
стейл (`max(STALE_TICKS)` — не сбрасывает более старый возраст). Док-коммент
`dispatch_read_op` несёт честную границу гарантии (формулировка Ревьюера с Гейта-1).
4. **ui.rs**: третья вкладка ` Dashboard [d] ` (первая в ряду — home); `render_dashboard`:
Waiting (янтарь при pending>0) → Balance (per-chain `wei_to_eth`, «no balances
reported» ≠ «balance unavailable», staleness-нота) → Positions (тристейт-тексты;
строка: protocol · balance_formatted symbol name — extra-пары verbatim; **адресов
нет** — №4; overflow → «+N more — terminal too small»).
5. **main.rs**: `d` из Queue/Receive; Dashboard-арм: `a`/Esc/`r`/`q`, остальное мертво
(перечислением, вкл. Enter — карточка за экраном не откроется; модельный view-гвард
on_open с Этапа 3 это дублирует).
6. **Хелперы**: ровно 5 (app.rs `watching`, ui.rs ×3, main.rs `confirming_at`) получили
явный `Msg::View(View::Queue)` — **0 существующих тестов переписано** (прогноз спеки
сошёлся).

## Не сделано / отложено

Ничего из скоупа. (Секция активности на дашборде — Этап 7, отсутствует, не заглушена.)

## Замечено, не трогаю

1. `poll_round`-хелпер тестов ассертит «тик → всегда List» в каждом прогоне — инвариант
(а) проверяется структурно во всех шедулер-тестах, не одним.
2. Red-тест порядка «flush > read-op» реализован ближайшей исполнимой формой (Queue +
parked get → после List уходит Get): состояние «parked get + активный Dashboard»
сегодня недостижимо — гейт переключения отвергает таб при parked get (Этап 3). Порядок
в коде закреплён + задокументирован; наблюдаемым он станет, только если появится вид,
паркующий интенты. Ревьюеру на подтверждение.

## Как тестировал — команды + вывод

```
cargo test → 180 (lib) + 19 (main) + 1 (tty_gate) зелёные [было 159+17+1; +23 теста]
cargo fmt --check → чисто · clippy --all-targets -D warnings → чисто · deny → ok×4
```

**Red→green — 7 мутаций шедулера, каждая роняла ровно свой тест, все откачены:**
| Мутация | Упавший тест |
|---|---|
| list-first снят (`was_list`→false) | `the_first_list_reply_…_dispatches_positions` |
| view-гейт снят | `a_read_op_is_refused_off_the_dashboard` |
| стейлнес-гейт снят | `read_ops_wait_out_the_staleness_window` |
| вход-на-дашборд не метит стейл | `entering_the_dashboard_marks_the_data_stale` |
| `context_stale` не ставится | `a_failed_balance_refresh_keeps_the_data_and_flags_it` |
| home снова Queue | `the_home_view_after_auth_is_the_dashboard` |
| read-op рождается из тика | `the_first_list_reply_…` (через poll_round-ассерт) |

(Каденция (в) и чередование (г) — два разных ассерта одного теста
`read_ops_wait_out_the_staleness_window`; мутация стейлнеса доказана, мутацию чередования
покрывает финальный ассерт того же теста.)

**DoD-смоук** (pty 80×24, стаб proto 2 с Aave-фикстурой из канона §3.8,
`scratchpad/smoke_dashboard.py`): **10/10 PASS** — home=Dashboard после PIN · три вкладки
с клавишами · Waiting-блок · «chain 1 0.01 ETH» · «aave_v3 1000 USD» · «∞» и «80%»
verbatim на экране · `a`→Queue · `d`→обратно · exit 6 · stdout чист.

## Отклонения от плана и почему

1. **Семантика `read_age`: сброс в точке диспетча, не в apply_*** (дизайн-уточнение,
в плюс): сброс на auth-context-ответе заставил бы первые positions ждать полное окно
(~30 c) после входа — против «первый показ сразу» из спеки. Born-stale дефолт +
сброс-на-диспетч дают заявленную каденцию (каждый op ~раз в 60 c, офсет 30 c) и
немедленный первый фетч.
2. Мутация (б) спеки («read-op при открытой карточке») не исполнима буквально: карточка
существует только в Queue-виде, а view-гейт диспетча (мутация M2) покрывает то же
условие строже. Задокументировано выше.

## Вопросы Ревьюеру

1. «Замечено» п.2 — согласен ли, что недостижимость «parked get + Dashboard» делает
red-тест порядка структурно невозможным, и закрепление порядка кодом+комментарием
достаточно?
2. `render_dashboard` overflow-бюджет считает `budget + if remaining == 1 { 1 } else { 0 }`
(последняя позиция может занять строку маркера) — читабельность vs точность; есть ли
более чистая формулировка на твой вкус?

---

# Дельта после Гейта-2 (раунд фиксов)

## БЛОКЕР — закрыт

`render_dashboard`, бюджет позиций: внутри цикла сравнивается **`used`** — строки,
добавленные ИМЕННО циклом — против `budget = height - lines.len()` (шапка вычитается
ровно один раз, в момент расчёта). Резерв строки маркера — только пока это не последняя
позиция (точный фит показывает всё без маркера). `shown`-мертвяк ушёл вместе с формулой
(МИНОР-2 закрыт тем же диффом). Коммент функции теперь описывает и сам баг (двойное
вычитание шапки), чтобы формулу не «упростили» обратно.

**Граничный тест** `the_positions_budget_sits_exactly_on_its_boundary` (геометрия
100×24 расписана в комменте): 15 позиций = точный фит, все 15 видны, маркера нет;
16 позиций → 14 видны + «+2 more» = бюджет заполнен полностью, ничего влезающего не
спрятано. **Red-proof:** возврат багованной формулы роняет ровно этот тест (болк
резал на 11 из 15).

## МИНОР-1 — закрыт

- Docstring теста `a_parked_user_intent_outranks_everything_after_a_list_reply`
переписан честно: он пиннит только «parked get уходит после list-reply на Queue»,
порядок flush/dispatch НЕ свидетельствует (view-чек режет раньше).
- **Узкий тест на сам гвард**: `the_dispatcher_itself_refuses_a_parked_intent` —
состояние «parked intent + активный Dashboard» собрано руками (тест-модуль имеет
доступ к приватным полям), `dispatch_read_op` отказывает при pending и работает без
него. Будущий вид, умеющий парковать при живом дашборде, наследует отказ.

## МИНОР-2 — закрыт (мертвяк `shown` удалён переписыванием формулы).

## НИТы

- ✅ Прямой тест «Dashboard-свитч отвергнут при parked get» добавлен
(`a_dashboard_switch_is_refused_while_a_get_is_parked`).
- ✅ Граница overflow-бюджета — покрыта (выше).
- ✅ Ветка «N pending» — `the_waiting_block_counts_pending_items` (2 pending, press a).
- ✅ Асимметрия apply_context/apply_positions — комментарий с причиной (второго
потребителя у позиций нет; недостижимо на shipped-ядре; revisit-условие названо).
- ❌ Дубль `q` в 3 армах map_key — оставлен: вынос до матча по фазе меняет структуру
маршрутизации money-смежных клавиш ради трёх строк; не в этом раунде.

## Гейты после фиксов

```
cargo test → 184 + 19 + 1 зелёные (+4 теста к раунду кода)
fmt --check чисто · clippy --all-targets -D warnings чисто · deny ok×4
```
133 changes: 133 additions & 0 deletions .claude/reports/2026-07-13-smoke-dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""DoD smoke — Console v2 Этап 5 Dashboard.

Real binary in a pty against a proto-2 stub server:
unlock → queue (tab bar) → r → Receive (address + QR) → a → queue → q.
Asserts on the raw stderr stream (UI) and the exit code.
"""

import fcntl
import json
import os
import pty
import socket
import struct
import subprocess
import sys
import termios
import threading
import time

ADDR = "0x489Fe09Fbb489Fe09Fbb489Fe09Fbb489F9Fbbbb"
SOCK = "/tmp/claude-1000/-home-temrjan-Dev/064de351-afaf-44e1-a4c0-aa64358663a5/scratchpad/approve.sock"
BIN = "/home/temrjan/Dev/projects/rustok/console/target/debug/rustok-console"


def serve(listener: socket.socket) -> None:
conn, _ = listener.accept()
f = conn.makefile("rw", encoding="utf-8")
for line in f:
req = json.loads(line)
op = req.get("op")
if op == "hello":
resp = {"ok": True, "proto": req["proto"], "server": "stub/0.0"}
elif op == "auth":
resp = {"ok": True}
elif op == "context":
resp = {"ok": True, "address": ADDR,
"balances": [{"chain_id": 1, "symbol": "ETH", "balance": "10000000000000000"}],
"allowed_chains": [1]}
elif op == "positions":
resp = {"ok": True, "positions": [{
"protocol": "aave_v3", "chain_id": 1,
"asset_address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
"asset_symbol": "USD", "asset_name": "Aave v3 account",
"asset_decimals": 8, "balance": "100000000000", "balance_formatted": "1000",
"extra": {"health_factor": "∞", "ltv": "80%"}}]}
elif op == "list":
resp = {"ok": True, "pending": []}
else:
resp = {"ok": False, "error": "protocol_error"}
f.write(json.dumps(resp) + "\n")
f.flush()


def main() -> int:
if os.path.exists(SOCK):
os.unlink(SOCK)
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.bind(SOCK)
listener.listen(1)
threading.Thread(target=serve, args=(listener,), daemon=True).start()

# stdin + stderr on the pty (interactive UI), stdout piped (decision stream).
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
os.set_blocking(master, False)
proc = subprocess.Popen(
[BIN],
stdin=slave,
stdout=subprocess.PIPE,
stderr=slave,
env={**os.environ, "RUSTOK_APPROVE_SOCK": SOCK, "TERM": "xterm-256color"},
)
os.close(slave)

captured = bytearray()

def drain(seconds: float) -> None:
end = time.time() + seconds
while time.time() < end:
try:
captured.extend(os.read(master, 65536))
except BlockingIOError:
time.sleep(0.05)
except OSError:
break

def press(keys: str, wait: float = 0.7) -> None:
os.write(master, keys.encode())
drain(wait)

drain(1.0) # connect + hello → unlock screen
press("1\r", wait=1.0) # PIN → auth ok → context → HOME = Dashboard
drain(3.5) # первый poll-цикл: list → positions
dash_frame = bytes(captured)
captured.clear()
press("a", wait=0.7) # → Queue
queue_frame = bytes(captured)
captured.clear()
press("d", wait=0.7) # → назад на Dashboard
back_frame = bytes(captured)
press("q", wait=0.5) # quit
proc.wait(timeout=5)
os.close(master)

text_dash = dash_frame.decode("utf-8", "replace")
text_queue = queue_frame.decode("utf-8", "replace")
text_back = back_frame.decode("utf-8", "replace")

checks = [
("home = Dashboard (title after PIN)", "Dashboard" in text_dash),
("three tabs with keys", "Dashboard [d]" in text_dash and "Queue·0 [a]" in text_dash and "Receive [r]" in text_dash),
("waiting block", "Waiting for you" in text_dash),
("balance humanized per chain", "chain 1" in text_dash and "0.01 ETH" in text_dash),
("position row verbatim", "aave_v3" in text_dash and "1000 USD" in text_dash),
("extra display strings verbatim", "∞" in text_dash and "80%" in text_dash),
("'a' switches to the queue", "Queue" in text_queue),
("'d' returns to the dashboard", "Dashboard" in text_back),
("exit code = aborted (6)", proc.returncode == 6),
("stdout stayed clean (no decisions)", proc.stdout.read() == b""),
]


ok = True
for name, passed in checks:
print(f" {'PASS' if passed else 'FAIL'} {name}")
ok &= passed
print(f"\nsmoke: {'PASS' if ok else 'FAIL'} ({sum(p for _, p in checks)}/{len(checks)})")
return 0 if ok else 1


if __name__ == "__main__":
sys.exit(main())
Loading
Loading