diff --git a/.claude/reports/2026-07-14-console-v2-phase2-s7-activity.md b/.claude/reports/2026-07-14-console-v2-phase2-s7-activity.md new file mode 100644 index 0000000..8916902 --- /dev/null +++ b/.claude/reports/2026-07-14-console-v2-phase2-s7-activity.md @@ -0,0 +1,197 @@ +# Отчёт: Console v2 Фаза 2, Этап 7 — вид Activity + локальный лог (ПОСЛЕДНИЙ этап) + +> Инженер → Капитану (для Ревьюера, Гейт-2). Спека: +> `.claude/specs/2026-07-14-console-v2-phase2-s7-activity.md` (Гейт-1: обе подписи, находка +> Ревьюера про id-tiebreak вшита в тест-план). Ветка `feat/console-v2-phase2-s7-activity` +> (НЕ запушена): `805a9b4` format → `dc99d99` protocol → `6682dab` app/transport/ui/main → +> `4c94e62` спека+смоук. core — ноль изменений (op готов, core#93). + +## Сделано — по пунктам спеки + +1. **format.rs**: `short_addr` возвращён (первые 6 + последние 4, EIP-55 регистр verbatim; + не-ASCII/короткое/без префикса — как есть). Только для Activity-списка. +2. **protocol.rs**: `Request::Activity` + `OutcomeEntry` + `parse_activity` (зеркало + positions). **`OutcomeState` — свой enum из 4 слов, осознанно НЕ `TerminalState`**: + §3.9 обещает «never pending» → «pending» в ответе валит парс (default-deny, тест). + Ошибок-деграций нет: любые `ok:false` → `Unexpected` → Fatal (словарь §3.9). +3. **transport.rs**: `Request::Activity`/`Reply::Activity` + 2 арма диспетча. +4. **app.rs — шедулер**: `dispatch_read_op` выбирает op по view: Dashboard — чередование + Context/Positions (двухвалентный `ReadOp` НЕ тронут — уход/возврат возобновляет + чередование с того же места, тест), Activity-вью — `activity` на том же list-first + слоте, та же STALE_TICKS-каденция; вход в Activity метит стейл (зеркало Dashboard). + Все гейты слота (flush-первым, тройная чистота, тик-не-рождает) — без изменений. +5. **app.rs — история**: `HistoryEntry` (JSONL-форма = display-форма, unknown-поля + игнорятся) + `HistoryFilter` (`f`-цикл, гейт на view в модели) + чистый + `Vec` (sort unix desc + id asc, кап 500). **У модели нет часов и ФС**: rich-запись + рождается в `apply_resolve`, пока карточка жива (to/amount/chain оттуда), и дренится + `take_decided_outcome()`; server-исходы дренятся `take_server_outcomes()`. + `fill_history_detail` добирает ТОЛЬКО пустые поля (в обе стороны: tx_hash с сервера + к rich-записи и to/amount к server-записи), присутствующее не перезаписывается. + **`Decision`/`decision_line` не тронуты** (ADR #7) — отдельный канал, доказано тестом + и смоуком (ровно одна JSON-строка на piped stdout). +6. **main.rs**: `HistoryLog` (путь-каскад `RUSTOK_CONSOLE_LOG` → + `$RUSTOK_DATA_DIR/console-activity.jsonl` → session-only с нотой; dedup-set; + `record()` = новый id → append+push, знакомый → fill-missing без второй строки в + файле); `load_history` (битые строки скип+счёт, дедуп по id newest-wins — две + конкурентные консоли, /check-3); компакция >1000 валидных → newest 500 атомарно + (tmp+rename, битое выбрасывается — файл самочинится); отказ записи → session-only + + видимая нота, не падение. Дренажи в цикле: stamp (`now` для решения, + `arrival − age` для server-исхода, §3.9) → append → push — один проход, до рендера. + Ключи: `h` из трёх видов; в Activity: `a`/Esc/`d`/`r`/`f`/`q`. +7. **ui.rs**: 4-я вкладка `Activity [h]`; `render_activity` — фильтр-шапка, строки + newest-first (rich: возраст·исход·сумма→short_addr·tx; server-only: честное + «(details not recorded)»), пустые состояния, overflow по уроку Этапа 5 + (exact-fit/маркер «+K more», бюджет считается один раз), футер-нота деградации + лога; семантические цвета исходов (fg-ассерт). + +## Критерии приёмки — все закрыты + +| # | Критерий | Статус | +|---|---|---| +| 1 | red→green на каждый кусок | ✅ 32 новых теста, 12 red-пруфов (11 мутаций + 1 true-red литерала теста) | +| 2 | Лог переживает retention | ✅ смоук: run 2 при ПУСТОМ server-окне показывает всё из файла | +| 3 | Инварианты §4 | ✅ short_addr только в списке (смоук: полного адреса НЕТ в кадре); карточка/Receive/fit-гейт не тронуты; в логе только публичные поля исхода | +| 4 | Порядок цикла нормативен | ✅ дренаж→stamp→append→push в одном проходе (код + смоук «мгновенная rich-строка») | +| 5 | decision_line байт-в-байт | ✅ существующие тесты Decision без правок; смоук: ровно 1 строка ADR #7 | +| 6 | Ноль правок существующих тестов | ✅ подтверждено: 184 старых лib-теста нетронуты (/check-4 сошёлся — substring-таб-ассерты пережили вкладку) | +| 7 | Точные CI-команды | ✅ см. ниже | +| 8 | DoD-смоук | ✅ **11/11 PASS** | + +## Как тестировал + +Гейты (все 4 CI-джоба дословно): `cargo fmt --all --check` чисто · +`cargo clippy --all-targets -- -D warnings` чисто · `cargo test` — **210+25+1** +(было 184+19+1: +26 lib, +6 main) · `cargo deny check advisories licenses bans sources` ok×4. + +**Red-пруфы — каждый ронял ровно свой тест** (все откатаны, дерево = коммиты): +protocol: «Pending прокрался в OutcomeState» · «порядок перевёрнут в парсе»; +format: «хвост 4→3»; шедулер: «Activity-вью шлёт Positions» · «вход не метит стейл» · +«activity двигает чередование»; история: «свап армов outcome_state» · «fill перезаписывает +присутствующее» · «сорт oldest-first»; main: «record не дедупит» · «load-дедуп oldest-wins»; +ui: «резерв маркера снят» (класс блокера Этапа 5). Плюс true-red: тест rich-строки падал +на моём неверном литерале (`0xfeedf…` вместо `0xfeedfe…`) — чинился ТЕСТ, код был прав. + +**DoD-смоук** (`.claude/reports/2026-07-14-smoke-activity.py`, реконструктор экрана вместо +substring по сырому ANSI — ratatui диффит кадры): реальный бинарь в pty против proto-2 +стаба, два прогона над одним лог-файлом — вход→h→server-строки→фильтр→решение по +карточке→**rich-строка мгновенно**→короткий адрес (полного НЕТ в кадре)→ровно одна +decision-строка на piped stdout→лог 3 исхода на диске (rich с to/tx)→**перезапуск +подхватывает историю при пустом server-окне**→exit 6×2. 11/11. + +## Не сделано / отложено (по спеке) + +Dashboard-activity-строка (реш. №1, отдельный мини-круг) · скролл (реш. №3) · +core/канон — ноль · README-строка про `RUSTOK_CONSOLE_LOG` — релизный поезд §8. + +## Отклонения от плана + +1. **(нейтральное, поимённо)** Спека называла `ReadOp::Activity` — вариант НЕ добавлен: + activity не участвует в чередовании (op выбирается по view), двухвалентное состояние + `ReadOp` не тронуто — уход/возврат на Dashboard возобновляет чередование с того же + места (тест `activity_never_joins_the_dashboard_alternation`). Буква изменена, цель + гейта (view-выбор op на том же слоте) — точно по спеке. +2. **(в плюс)** Смоук-ассерты переписаны с сырого ANSI-потока на реконструированный экран + (30-строчный парсер CSI-H): ratatui диффит кадры — устоявшийся экран не эмитит байтов, + слова рвутся курсор-джампами. Урок в отчёт: substring по pty-потоку валиден только на + переходных кадрах. + +## Замечено, не трогаю + +1. MoveUp/Down двигают невидимый queue-selected на не-Queue видах — существующее, безвредно. +2. ТЗ §2-таблица: Dashboard-данные числят activity — отметить «бэклог» при правке ТЗ на закрытии. +3. `.claude/reports/__pycache__` появлялся от прогона смоука — прибран; в .gitignore console + не входит (одна строка при случае, не этот PR). + +## Вопросы Ревьюеру + +1. `HistoryLog::record` на знакомом id НЕ пишет коррективу в файл (append-only; fill только + в памяти) — файл в углу «решил не я» остаётся без tx_hash. Спека это фиксирует как цену + угла; подтверди, что цена принята (альтернатива — корректива-строка с тем же id, + load-дедуп newest-wins её подхватит: +3 строки кода, но файл двоит записи). + **[Закрыт вердиктом: цена принята как описано.]** +2. Смоук-таймауты (3.5 с на полл-цикл) — на медленном CI не гоняется (смоук локальный, + как у Этапов 3/5); флейк-риска в юнитах нет (все синхронные). **[Закрыт: согласен.]** + +--- + +# ДЕЛЬТА — раунд фиксов Гейта-2 (2026-07-14, `718c594`) + +## БЛОКЕР-1 — закрыт, TRUE-RED в обе стороны + +`load_history` теперь мержит дубликаты id **field-by-field в порядке файла** (первая запись +— база, поздние только заполняют пустое) через **единый шов +`HistoryEntry::fill_missing_from`** — тот же, что у внутрисессионного fill. Целиковой +замены по unix больше нет — штампы rich (`now` в момент решения) и poor +(`arrival − age`) из разных формул и сравнению не подлежат. + +**TRUE-RED пруф:** тест `load_merges_duplicate_ids_field_by_field_never_by_timestamp` +(rich unix=100 → poor unix=200 тем же id; и обратный порядок файла) прогнан ПРОТИВ +нефиксённого кода — **FAILED с `left: None`** (rich-детали реально терялись), после фикса +зелёный в обе стороны. + +Заодно МИНОР-9: `fill_history_detail(&HistoryEntry)` вместо пяти позиционных Option +(свап-риск снят типом), МИНОР-6: fill-тест кроет все 5 полей в обе стороны, включая +reason с реальным значением. + +## БЛОКЕР-2 — закрыт, TRUE-RED + +`load_history` читает **байты**: порванная не-UTF8 запись (SIGKILL посреди append) +травит одну строку (скип+счёт+нота), а не весь файл; `NotFound` (нормальная пустая +история) отличён от **unreadable** (права/I/O) — последний поднимает ноту «activity log +could not be read — history may be incomplete», никогда не выглядит здоровой пустотой. + +**TRUE-RED пруфы:** `a_torn_non_utf8_tail_never_hides_the_healthy_lines` — FAILED на +нефиксённом коде (весь файл прятался); `an_unreadable_file_is_flagged…` (chmod 000) — +red мутацией «unreadable→empty». + +## МИНОРы — все в раунде + +1. **TOCTOU компакции**: писатели (append И компакция) сериализуются эксклюзивным локом + на **sidecar-lockfile** (`.lock`, std `File::lock`, снимается на drop). Sidecar, + а не сам лог — rename компакции оставил бы ждущего писателя с локом на unlinked-иноде + (запись в пустоту). Компакция **перечитывает файл заново под локом** — снэпшот до + лока не может стереть конкурентный аппенд. Читатели не локаются (аппенды + построчно-атомарны, rename атомарен). +2. **Триггер по сырым строкам** (`raw_lines`, не дедупнутым записям) — дублирующий мусор + не растёт мимо триггера; ассерт в roundtrip-тесте. +3. **Периодическая компакция**: каждые `HISTORY_CAP` аппендов резидентной сессии + (тест: раздутый дубликатами файл + взведённый счётчик → один record() сжимает файл + 601→2 строки, счётчик перевзводится). +4. **tmp уникален по PID** (`.jsonl.tmp.`). +5. **0600** на лог/лок/tmp (`OpenOptionsExt::mode`; тест ассертит mode; мутация 0644 + ловится). +6. §МИНОР-7 (слабый дедуп-тест) — усилился фиксом БЛОКЕРа-1 (формы записей различны); + roundtrip-тест переписан: «первая запись файла — база, не конкурс таймстампов». +7. **open_at/каскад тестируемы без env** (МИНОР-8): `open()` = тонкая env-обёртка над + `open_at(Option)`; тесты open_at: None → session-only-нота; unreadable → + нота + appends best-effort. Сам env-каскад (6 строк read-only var()) остался без + юнита — покрыт смоуком (RUSTOK_CONSOLE_LOG в env стаба). + +## НИТы + +Взяты: цвет исходов ×4 состояния (был только Denied) · overflow+нота вместе (нота +резервирует строку до бюджета — «+3 more» и нота видны одновременно) · граница +short_addr ровно 13 hex · ключи d/r из Activity. Отклонён с причиной: раздельная +бюджетная дисциплина пустой/note-ветки — косметика без паники (слова Ревьюера), +поведение уже покрыто note+overflow-тестом. + +## Не тестировано (честно) + +Кросс-процессная гонка лока (два живых процесса) — юнитом не воспроизводится; логика +доказана конструкцией (sidecar + re-read под локом) и smoke двумя последовательными +процессами. Env-каскад — см. МИНОР-8 выше. + +## Гейты финального состояния + +fmt чисто · clippy `-D warnings` чисто · `cargo test` **211+30+1** (+5 тестов раунда) · +deny ok×4 · **смоук 11/11 PASS** после переписанного I/O (локи прозрачны для сценария). +Red-пруфы раунда: 3 TRUE-RED (оба БЛОКЕРа, обе стороны merge) + 3 мутации +(unreadable→empty · 0644 · fill-перезапись) — каждая роняла ровно свой тест; всё +откатано, дерево = коммиты. + +## Флаг Капитану (решение не моё и не Ревьюера) + +Целостность лога от same-uid-подделки (сфабрикованная запись с новым id): 0600 сделан; +вопрос «защищать ли журнал от подделки соседом по uid» — отдельный, рекомендация +Ревьюера — решить осознанно ПОСЛЕ этого PR (первая персистентность в кодовой базе). +Прошу слово Капитана: бэклог-issue или снять. diff --git a/.claude/reports/2026-07-14-smoke-activity.py b/.claude/reports/2026-07-14-smoke-activity.py new file mode 100644 index 0000000..a33fd72 --- /dev/null +++ b/.claude/reports/2026-07-14-smoke-activity.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""DoD smoke — Console v2 Этап 7 Activity. + +Real binary in a pty against a proto-2 stub, TWO runs over one log file: + run 1: unlock → h (server outcomes land as poor rows) → f (filter) → + a → Enter → y (approve executes) → h (rich row is there INSTANTLY) + → q. Decision stream: exactly one JSON line on piped stdout. + run 2: fresh process, stub answers activity with [] — every row must come + back from the JSONL log on disk (persistence proven). +""" + +import fcntl +import json +import os +import pty +import re +import socket +import struct +import subprocess +import sys +import termios +import threading +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRATCH = "/tmp/claude-1000/-home-temrjan-Dev/0252c58e-cae7-4976-84d5-5dfa0d509cf8/scratchpad" +SOCK = os.path.join(SCRATCH, "approve7.sock") +LOG = os.path.join(SCRATCH, "console-activity.jsonl") +BIN = os.path.join(HERE, "../../target/debug/rustok-console") + +ITEM_ID = "aaaaaaaa-0000-0000-0000-000000000001" +TO = "0x1111222233334444555566667777888899990000" +TX = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + +state = {"connections": 0, "approved": False} + + +def serve(listener: socket.socket) -> None: + while True: + conn, _ = listener.accept() + state["connections"] += 1 + first_run = state["connections"] == 1 + 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": TO, "balances": [], "allowed_chains": [1]} + elif op == "positions": + resp = {"ok": True, "positions": []} + elif op == "activity": + if first_run: + resp = {"ok": True, "outcomes": [ + {"id": "bbbbbbbb-0000-0000-0000-000000000002", + "state": "denied", "age_secs": 120}, + {"id": "cccccccc-0000-0000-0000-000000000003", + "state": "expired", "age_secs": 1800}]} + else: + # Run 2: the server window is empty — whatever the view + # shows must have come from the file. + resp = {"ok": True, "outcomes": []} + elif op == "list": + pending = [] if (state["approved"] or not first_run) else [{ + "id": ITEM_ID, "kind": "send", "chain_id": 1, "to": TO, + "amount_wei": "1000000000000000", "risk": "safe", + "high_risk": False, "not_after_unix": int(time.time()) + 600}] + resp = {"ok": True, "pending": pending} + elif op == "get": + resp = {"ok": True, "card": { + "id": ITEM_ID, "chain_id": 1, "to": TO, + "amount_wei": "1000000000000000", "decoded_call": None, + "raw_data": "0x", "high_risk": False, "high_risk_reasons": [], + "not_after_unix": int(time.time()) + 600}} + elif op == "approve": + state["approved"] = True + resp = {"ok": True, "state": "executed", "tx_hash": TX} + else: + resp = {"ok": False, "error": "protocol_error"} + f.write(json.dumps(resp) + "\n") + f.flush() + + +CSI = re.compile(r"\x1b\[([0-9;?]*)([A-Za-z])") + + +def screen_of(stream: str, rows: int = 24, cols: int = 80) -> list[str]: + """Rebuild the final screen from a raw ANSI stream: ratatui DIFFS frames + (a settled screen emits nothing; words split across cursor jumps), so + substring asserts must run on the reconstructed grid, not the bytes.""" + grid = [[" "] * cols for _ in range(rows)] + r = c = 0 + i = 0 + while i < len(stream): + ch = stream[i] + if ch == "\x1b": + m = CSI.match(stream, i) + if m: + params, final = m.group(1), m.group(2) + if final == "H": + parts = params.split(";") if params else [] + r = int(parts[0]) - 1 if parts and parts[0] else 0 + c = int(parts[1]) - 1 if len(parts) > 1 and parts[1] else 0 + r = min(max(r, 0), rows - 1) + c = min(max(c, 0), cols - 1) + elif final == "J": + grid = [[" "] * cols for _ in range(rows)] + i = m.end() + continue + i += 1 + continue + if ch == "\r": + c = 0 + elif ch == "\n": + r = min(r + 1, rows - 1) + elif ch >= " ": + grid[r][c] = ch + c = min(c + 1, cols - 1) + i += 1 + return ["".join(row) for row in grid] + + +def on_screen(screen: list[str], *fragments: str) -> bool: + return all(any(f in row for row in screen) for f in fragments) + + +def launch(): + 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, + "RUSTOK_CONSOLE_LOG": LOG, "TERM": "xterm-256color"}, + ) + os.close(slave) + return proc, master + + +def main() -> int: + for p in (SOCK, LOG): + if os.path.exists(p): + os.unlink(p) + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(SOCK) + listener.listen(2) + threading.Thread(target=serve, args=(listener,), daemon=True).start() + + captured = bytearray() + + def drain(master, 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(master, keys: str, wait: float = 0.7) -> None: + os.write(master, keys.encode()) + drain(master, wait) + + def snap() -> list[str]: + return screen_of(bytes(captured).decode("utf-8", "replace")) + + # ── run 1 ── + proc, master = launch() + drain(master, 1.0) + press(master, "1\r", wait=1.0) # PIN → Dashboard + press(master, "h", wait=0.3) # → Activity (marks stale) + drain(master, 3.5) # poll → list → activity dispatch + scr_act = snap() + press(master, "f", wait=0.6) # filter → executed (nothing matches) + scr_filt = snap() + for _ in range(4): # cycle back to All + press(master, "f", wait=0.2) + press(master, "a", wait=0.5) # → Queue + drain(master, 3.0) # a list poll delivers the pending item + press(master, "\r", wait=1.0) # open the card + press(master, "y", wait=1.0) # approve → executed + press(master, "h", wait=0.8) # → Activity: the rich row is INSTANT + scr_rich = snap() + press(master, "q", wait=0.5) + proc.wait(timeout=5) + stdout_1 = proc.stdout.read().decode() + os.close(master) + + # ── run 2: fresh process, empty server window — the file must speak ── + captured.clear() + proc2, master2 = launch() + drain(master2, 1.0) + press(master2, "1\r", wait=1.0) + press(master2, "h", wait=0.8) + scr_reload = snap() + press(master2, "q", wait=0.5) + proc2.wait(timeout=5) + os.close(master2) + + log_lines = [] + if os.path.exists(LOG): + with open(LOG, encoding="utf-8") as fh: + log_lines = [json.loads(line) for line in fh if line.strip()] + + decision_lines = [line for line in stdout_1.splitlines() if line.strip()] + + checks = [ + ("Activity tab renders with the filter header", + on_screen(scr_act, "Activity [h]", "filter: all")), + ("server outcomes render as honest poor rows", + on_screen(scr_act, "rejected", "expired", "(details not recorded)")), + ("filter hides what does not match", + on_screen(scr_filt, "no executed outcomes under this filter") + and not on_screen(scr_filt, "rejected")), + ("the decision births a rich row instantly", + on_screen(scr_rich, "approved", "0.001 ETH")), + ("addresses are shortened on the list", + on_screen(scr_rich, "0x111122…0000") + and not on_screen(scr_rich, TO)), + ("the tx hash rides the rich row", + on_screen(scr_rich, "tx 0xdeadbe…beef")), + ("exactly one decision line on piped stdout (ADR #7)", + len(decision_lines) == 1 and "approved" in decision_lines[0] + and TX in decision_lines[0]), + ("the log file holds all three outcomes", + len(log_lines) == 3 and {e["id"] for e in log_lines} + == {ITEM_ID, "bbbbbbbb-0000-0000-0000-000000000002", + "cccccccc-0000-0000-0000-000000000003"}), + ("the rich record persisted with card data", + any(e.get("to") == TO and e.get("tx_hash") == TX for e in log_lines)), + ("run 2 reloads history from the file (server window empty)", + on_screen(scr_reload, "approved", "0.001 ETH", "rejected")), + ("both runs exit aborted (6)", + proc.returncode == 6 and proc2.returncode == 6), + ] + + for tag, screen in [("act", scr_act), ("filt", scr_filt), + ("rich", scr_rich), ("reload", scr_reload)]: + with open(os.path.join(SCRATCH, f"screen-{tag}.txt"), "w") as out: + out.write("\n".join(screen)) + 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()) diff --git a/.claude/specs/2026-07-14-console-v2-phase2-s7-activity.md b/.claude/specs/2026-07-14-console-v2-phase2-s7-activity.md new file mode 100644 index 0000000..d529dba --- /dev/null +++ b/.claude/specs/2026-07-14-console-v2-phase2-s7-activity.md @@ -0,0 +1,216 @@ +# Console v2 Фаза 2, Этап 7 (ПОСЛЕДНИЙ) — вид Activity + локальный лог исходов + +> Статус: **ПРЕДЛОЖЕНИЕ, ждёт Гейта-1.** Кода нет до двух подписей. +> Управляющие: ТЗ §2 (вид «Activity / History: список решений с фильтрами | локальный лог + +> retained outcomes»), §5 Фаза 2 («Локальный лог активности — терминальные исходы переживают +> 60-мин retention ядра»), §9.5 порядок (Этап 7 — последний Фазы 2); канон §3.9 (op готов, +> core PR3 смержен: id/state/tx_hash?/reason?/age_secs, кап 100, newest first, **id = ключ +> дедупа локального лога**, «console derives an absolute time locally (answer arrival − age) +> when writing its local log»), §3.8 (нормативное клиентское правило list-first). +> Сервер-сторона ГОТОВА (core#93) — этот этап чисто console. +> Команда: Капитан/Инженер/Ревьюер; связь только через Капитана. + +## Цель — один абзац + +Console-сторона activity, закрывающая Фазу 2: четвёртый вид **Activity/History** (клавиша +`h`) — список терминальных исходов с фильтром по состоянию; клиентская часть op'а +(`Request::Activity` + парс §3.9); **локальный журнал** (JSONL на персистентном томе +`/data`) — история глубже 60-мин retention ядра, куда richly пишется каждое решение в +момент принятия (to/amount из живой карточки) и bedно доливаются server-only исходы +(экспайры, lockout-лавины, чужие соединения) из activity-ответов; возврат +`format::short_addr` — ТОЛЬКО для дисплей-строк этого списка (ТЗ §4.1). + +## Замер blast radius (сделан ДО спеки, console main `1b7a16e`, 184+19+1) + +1. **Компилятор-форс** от `View::Activity`: render-диспетч (ui.rs:34), три view-блока + `map_key` (main.rs:316-348 — по 1 строке `h` в каждый + новый блок Activity). + Существующие таб-тесты — substring-ассерты (ui.rs:1701, :1718) — 4-ю вкладку + ПЕРЕЖИВАЮТ (/check-4): ожидается ноль правок существующих тестов. +2. **protocol.rs — аддитивно**: `Request::Activity` (serde-tag, парс существующих op не + меняется) + `OutcomeEntry` + `parse_activity` — зеркало `parse_positions` (:554), но БЕЗ + `wallet_locked`-ветки: §3.9 фиксирует словарь `unauthorized | protocol_error`, оба на + корректной authed proto-2 сессии невозможны → любые `ok:false` = `Unexpected` → Fatal + (класс fail-closed, тот же что `apply_resolve` на Unauthorized). +3. **transport.rs — аддитивно**: `Request::Activity`, `Reply::Activity(Vec)`, + 2 арма worker-диспетча (:287-305 паттерн Positions). +4. **app.rs — поведенческое ядро**: `ReadOp::Activity` + правка гейта `dispatch_read_op` + (:901-929, сейчас только `View::Dashboard`); `Reply::Activity`-арм; history-модель + (`Vec` + фильтр + дренажи/пуши). **`Decision`/`decision_line` НЕ трогаются** + (ADR-поверхность инварианта #7) — история идёт отдельным дренажем. +5. **main.rs**: ключи, файл-I/O лога, дренажи в цикле (:185-194 — рядом с take_decision). +6. **format.rs**: возврат `short_addr` (убран в Фазе 1 как dead code — бэклог-пункт «вернуть + в Фазе 2 только для дисплей-списков»). +7. Существующие тесты: правятся ТОЛЬКО tab_line-ассерты; 5 тест-хелперов с явным view, + scheduler-тесты Этапа 5 (Dashboard-ротация), decision_line-тесты — без правок (критерий 5). + +## Ключевые факты (проверены по коду) + +- `apply_resolve` держит живую карточку в момент терминального ответа (`c.card` до + `confirm = None`, app.rs:1056-1084) → id/to/amount_wei/chain_id доступны для rich-записи. +- Модель чистая: ни wall-clock, ни ФС (урок Этапа 2) → все timestamps и файл — в main.rs. +- `/data` — персистентный том wallet-образа (mcp/Dockerfile.wallet: `VOLUME /data`, + `ENV RUSTOK_DATA_DIR=/data`, chown rustok; `docker exec` наследует ENV и uid образа). +- Транспорт-кап 64 KiB на строку ответа; сервер капнул 100 исходов ≈ 16 KiB — проходит. +- `on_open` уже гейтит `view != Queue` (:777) — Msg::Open из Activity структурно мёртв; + `on_view` уже отказывает при карточке/get-на-проводе/parked-Get (:600) — Activity + недостижим при открытой карточке теми же существующими гейтами. +- MoveUp/Down двигают queue-selected независимо от view — существующее безвредное + поведение Dashboard/Receive, для Activity не меняется (правило №6). + +## Дизайн — предрешённые решения + +1. **Пайплайн истории: main.rs владеет файлом, часами и dedup-set; модель — чистый + `Vec` + фильтр.** Три потока: + - *Загрузка*: main читает JSONL → `model.set_history(entries)` до цикла; dedup-set = id + загруженных. + - *Решение (rich)*: `apply_resolve` при терминальном исходе кладёт запись (id, state, + to **verbatim-строкой как хранение** — рендер сокращает, amount_wei, chain_id, + tx_hash/reason) в дренаж `take_history_entry()` — БЕЗ unix (у модели нет часов); + main: unix = now → append в файл → `model.push_history(entry)` **в том же + проходе цикла, до рендера** (порядок нормативен). Файл отказал → push всё равно + (session-only деградация, футер-нота). + - *Activity-реплай (server-only)*: `apply_activity` складывает server-исходы в дренаж + `take_server_outcomes()`; main: id уже в dedup-set → **fill-missing merge в памяти** + (/check-2: `ResolveOutcome::AlreadyResolved{state}` несёт только слово — чужое + соединение исполнило item, наша rich-запись без tx_hash; пустые tx_hash/reason + добираются из server-исхода через `model.fill_history_detail(id, …)`, присутствующие + поля НЕ перезаписываются; файл append-only — корректива в файл не пишется, честная + цена угла); новый id → unix = `now.saturating_sub(age_secs)` → append + push. Возраст + дрейфует между поллами — дедуп по id это гасит (канон §3.9). +2. **Хранимое слово состояния = протокольное** (`executed|denied|expired|failed`, словарь + §3.5/§3.9); рендер маппит в человеческие (как Notice::Outcome). Rich-запись конвертирует + DecisionKind → протокольное слово в одной именованной функции (шов, тест). +3. **Файл**: JSONL append-only, unknown-поля игнорятся (мягкая эволюция без version-поля), + битая строка → скип + футер-нота «log partially unreadable». **Загрузка дедупит по id** + (/check-3: две консоли конкурентно аппендят один файл — один server-исход может лечь + двумя строками; newest wins при загрузке). Путь-каскад: + `RUSTOK_CONSOLE_LOG` → `$RUSTOK_DATA_DIR/console-activity.jsonl` → ни того ни другого = + персистентность отключена (session-only, футер-нота). Кап: в памяти/показе newest 500 + **валидных записей** (/check-5: битые строки не считаются и при компакции выбрасываются + — файл самочинится); компакция при загрузке — >1000 валидных → перезапись newest 500 + дедупнутых (tmp рядом + atomic rename, тот же fs); компакция best-effort (dir не + writable → скип, чтение работает). +4. **Scheduler**: `ReadOp::Activity` на том же list-first слоте. `dispatch_read_op`: + `View::Dashboard` → чередование Context/Positions (НЕ меняется); `View::Activity` → + Activity-op; Queue/Receive → None. Та же `STALE_TICKS`-каденция (~30 c; store-чтение + дешевле on-chain, но один канал и один слот — консистентность дороже частоты). Вход в + Activity метит стейл (зеркало Dashboard-ветки `on_view` :610). Все существующие гейты + слота (flush_pending первым, тройная чистота, только из on_reply(List)) — без изменений. +5. **Вид Activity**: строки newest-first (сортировка по unix desc, **вторичный ключ id + asc** — lockout-пачка даёт равные unix, детерминизм зеркалит серверную семантику PR3; + /check-6; модель сортирует при push/set); формат строки: `<возраст> · <исход> · <сумма ETH> → ` + + `tx 0x…кор.` у executed; server-only запись (без to/amount): `<возраст> · <исход> · + (details not recorded)` — колонки не разъезжаются, одна честная форма. Возраст из + `render(_, now_unix)` (уже в сигнатуре) минус unix записи. Фильтр `f` циклит + All→Executed→Denied→Expired→Failed (модельное поле, живёт только в Activity-блоке + map_key); активный фильтр — в шапке списка. Пустые состояния: «no activity yet» / + «no outcomes (filter: f)». Overflow-бюджет — урок Этапа 5: budget один раз, + в цикле `used`-счётчик, резерв маркера «+K more» кроме последней строки. Notice в + Activity не рендерится (как Receive). Скролла НЕТ (решение №3 ниже). +6. **short_addr** (format.rs): `0x` + первые 6 + `…` + последние 4 значащих; юнит-тесты + (длина/крайние). Используется ТОЛЬКО в render_activity — ни на одной + подписывающей/одобряющей поверхности (ТЗ §4.1; карточка/Receive не тронуты). +7. **Ключи**: `h` → Activity из Queue/Dashboard/Receive; из Activity: `a`/Esc → Queue, + `d` → Dashboard, `r` → Receive, `q` — quit, `f` — фильтр. `f` свободна во всех блоках. + +## Скоуп PR — что входит / что ЯВНО не входит + +**Входит:** всё выше + тесты каждого куска red→green + DoD-смоук (pty против proto-2 +Python-стаба, стаб дополняется ответом activity). + +**ЯВНО НЕ входит:** +- core/сервер — ноль изменений (op готов, core#93). +- Канон-док — ноль (клиентское правило уже в §3.8; §3.9 полон). +- **Activity-строка на Dashboard** (ТЗ §2 числит activity в данных Dashboard) — отдельный + мини-круг: у render_dashboard свой overflow-бюджет (зона блокера Этапа 5), не смешивать. + [решение №1] +- Скролл истории [решение №3], поиск, экспорт. +- Реакция вида на `Notice` — не меняется. +- Правка Decision/decision_line (ADR #7) — запрещена этим скоупом. + +## Затронутые файлы + +`console/src/format.rs` (+short_addr) · `protocol.rs` (+Request::Activity, +OutcomeEntry, ++parse_activity) · `transport.rs` (+Request/Reply армы) · `app.rs` (+View::Activity, ++ReadOp::Activity, +history-модель/дренажи, +apply_activity) · `ui.rs` (+render_activity, +tab_line) · `main.rs` (+ключи, +файл-I/O, +дренажи цикла). Тесты — в тех же файлах. + +## Критерии приёмки — «PR готов, когда…» + +1. **Red→green на каждый кусок**: parse_activity (все 4 состояния verbatim, отсутствие + tx_hash/reason = None, пустой массив, ok:false → Unexpected); transport-армы; + scheduler — зеркала тестов Этапа 5 (activity ТОЛЬКО из on_reply(List) при + view==Activity && stale && чистый слот; тик не рождает; Dashboard-ротация не изменилась + — существующие тесты без правок); history (rich при решении с to/amount живой карточки; + server-merge: новый id входит, знакомый — first-write-wins; сортировка unix desc); + файл-I/O (загрузка, append, компакция >1000→500, битая строка → скип+нота, нет пути → + session-only+нота); фильтр-цикл; short_addr; вид (снапшоты строк rich/бедной, + fg-ассерты, пустые состояния, overflow «+K more» на точной границе); tab_line 4 вкладки; + ключи h/f. +2. **Лог переживает retention**: тест/смоук — записи старше окна сервера остаются в виде + (из файла), server-окно их не дублирует. +3. **Инварианты §4**: short_addr только в activity-списке; полные адреса + карточки/Receive/QR не тронуты; fit-гейт карточки не тронут; секретов в + логе нет (лог не содержит PIN/сид — только публичные поля исхода). +4. **Порядок цикла нормативен**: дренаж → stamp → append → push → рендер в одном проходе. +5. **decision_line байт-в-байт**: существующие decision_line/Decision тесты БЕЗ правок. +6. **Существующие тесты: ожидается НОЛЬ правок** (/check-4: таб-тесты — substring-ассерты, + ui.rs:1701 `has_line_with`, :1718 `row.find` — 4-я вкладка аппендится и они выживают; + 4 вкладки ≈ 59 колонок из 80). Любая фактическая правка — поимённо с причиной в отчёте. +7. **Гейты — точные команды CI console** (ci.yml:29/39/48/56): `cargo fmt --all --check` · + `cargo clippy --all-targets -- -D warnings` · `cargo test` · + `cargo deny check advisories licenses bans sources` (/check-1: джоб Advisories/Licenses/ + Bans проверяет все четыре, не только advisories). +8. **DoD-смоук** pty против дополненного стаба: вход → h → вид с историей; решение по + карточке → строка появляется мгновенно (rich); стаб-исход другого происхождения → + появляется после полла (бедная строка); лог-файл на диске; **повторный запуск консоли + подхватывает историю из файла**; f-фильтр работает; stdout чист от JSON в интерактиве. + +## Definition of Done + +merged · ветка удалена · CI зелёный · smoke-скрипт сохранён в .claude/reports/ · трекер +console/workflow-state «Этап 7 done, Фаза 2 ЗАКРЫТА» · ТЗ §9.5 + §2-таблица отметки · +human-plan.html освежён (закрытие сессии) · NEXT = релизный поезд §8. + +## Решения к ратификации (Гейт-1) — предрешены + +1. **Dashboard activity-строка — НЕ в этом круге.** (а) Рекомендация: view-only; + (б) почему: у render_dashboard свой хрупкий overflow-бюджет (место блокера Этапа 5) — + отдельный маленький круг после; Этап 7 в формулировке хэндоффа Капитана строки не + содержит; (в) контраргумент: ТЗ-таблица §2 числит activity в данных Dashboard — + недобранная буква ТЗ; не перевесил: §8 всё равно релизит Фазу 2 пакетом, строка не + двигает time-to-value; (г) цена ошибки: один дополнительный мини-круг; (д) переключатель: + слово Капитана «в этом круге». +2. **Формат/путь лога**: JSONL + каскад RUSTOK_CONSOLE_LOG → $RUSTOK_DATA_DIR/… → + session-only; кап 500/1000 с best-effort компакцией. (а)-(б): том /data — факт образа, + JSONL = скип битой строки без потери файла; (в) контраргумент: sqlite надёжнее — не + перевесил (новая зависимость против append-only журнала, правило №5); (г) цена: формат + приватен консоли, мигрируем свободно; (д) переключатель: потребность в поиске/экспорте. +3. **Скролла нет**: топ-N в бюджет + честный «+K more»; фильтры дают срез; файл = полная + история. (в) контраргумент: длинная история не читается с экрана — не перевесил: + Activity — дисплей-обзор, не аудит-инструмент; (д) переключатель: боль на живом + использовании → мини-круг «скролл». +4. **Та же каденция STALE_TICKS для activity** (не чаще): один канал, один слот, list-first + дороже свежести. Переключатель: жалоба на отставание вида. + +## Тест-план + +Юнит: format (short_addr ×3) · protocol (parse_activity ×5) · transport (армы ×2) · +app-scheduler (×6: view-гейт, stale-гейт, list-first, тик-не-рождает, вход-метит, +Dashboard-ротация нетронута) · app-history (×6: rich-дренаж, server-дренаж, сортировка, +фильтр-цикл, push-порядок, **два rich-решения в одну секунду → id-tiebreak — осознанная +документированная особенность, НЕ хронология: unix — единственный источник времени у +чистой модели, секундная гранулярность неизбежна; находка Гейта-1**) · +main-I/O (×5: load/append/compact/битая/нет-пути) · +ui (×6: снапшоты rich/poor/empty/filter/overflow, tab_line 4 вкладки на 80 колонках — +покрывает /check-7, fg-ассерт) · ключи (×2). +Каждый — red→green (мутация или true-red), пруфы в отчёте. Смоук — критерий 8. + +## Замечено, не трогаю + +1. ТЗ §2-таблица: Dashboard-данные включают activity — если реш. №1 ратифицировано, + отметить строку как «Activity-строка Dashboard — бэклог» при правке ТЗ на закрытии. +2. MoveUp/Down двигают невидимый queue-selected на не-Queue видах — существующее, + безвредное, не трогаю. +3. `console/README`/доки образа не описывают RUSTOK_CONSOLE_LOG — вписать одну строку + доков при релизном поезде §8 (не этот PR). diff --git a/src/app.rs b/src/app.rs index 27fdff3..e0ba804 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,11 +12,12 @@ //! explicit `y` approves; `n`, Esc, Ctrl-C and the expiry deadline all send `deny`. //! Leaving the confirmation without deciding is not offered. +use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::protocol::{ - AuthOutcome, Card, ContextOutcome, GetOutcome, PositionsOutcome, ResolveOutcome, Summary, - TerminalState, WalletContext, + AuthOutcome, Card, ContextOutcome, GetOutcome, OutcomeEntry, OutcomeState, PositionsOutcome, + ResolveOutcome, Summary, TerminalState, WalletContext, }; use crate::transport::{self, Reply, TransportError}; use crate::ui; @@ -178,6 +179,11 @@ pub enum View { /// The wallet's own address + QR. Pure display: signs nothing, sends /// nothing, adds no socket op. Receive, + /// Terminal outcomes — the local log merged with the server's retained + /// window (Stage 7). Pure display with a state filter; addresses here + /// are SHORTENED (`format::short_addr`) — a display list, not a signing + /// surface (ТЗ §4.1). + Activity, } /// The dashboard's positions block — a tri-state, so "still loading" never @@ -265,6 +271,156 @@ pub struct Decision { pub reason: Option, } +/// One row of the console's activity history — the local log's stored form +/// (one JSONL line) and the Activity view's display form, one representation +/// end to end. `to`/`amount_wei`/`chain_id` are `None` on a server-only +/// record: the retained window drops the preview on resolve, so a record +/// first seen via `activity` is honestly poorer than one written at decision +/// time. Unknown fields are ignored on load (soft evolution, no version field). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HistoryEntry { + /// Resolution time, unix seconds — stamped by the run loop (the model has + /// no clock): `now` for a decision made here, `arrival − age_secs` for a + /// server outcome (§3.9). + pub unix: u64, + /// The item's preview id — the dedup key (§3.9). + pub id: String, + /// Terminal state, protocol vocabulary (§3.5/§3.9 words, one end to end). + pub state: OutcomeState, + /// Recipient (EIP-55, stored verbatim) — rich records only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, + /// Native value, decimal wei string — rich records only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount_wei: Option, + /// Chain id — rich records only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chain_id: Option, + /// Executed transaction hash, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tx_hash: Option, + /// Operator-masked failure reason, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl HistoryEntry { + /// Fill this entry's MISSING fields from another record of the same + /// outcome — present fields are never overwritten. The ONE merge seam + /// for every duplicate-id path: the in-session fill + /// ([`Model::fill_history_detail`]) and the load-time dedup (Gate-2 + /// BLOCKER-1: a whole-record replace by timestamp traded a rich record + /// for a poor one written by a second console — the stamps come from + /// different formulas and are not comparable). + pub fn fill_missing_from(&mut self, other: &HistoryEntry) { + if self.to.is_none() { + self.to.clone_from(&other.to); + } + if self.amount_wei.is_none() { + self.amount_wei.clone_from(&other.amount_wei); + } + if self.chain_id.is_none() { + self.chain_id = other.chain_id; + } + if self.tx_hash.is_none() { + self.tx_hash.clone_from(&other.tx_hash); + } + if self.reason.is_none() { + self.reason.clone_from(&other.reason); + } + } +} + +/// A decision made on THIS console, drained by the run loop +/// ([`Model::take_decided_outcome`]) to be stamped (unix = now), appended to +/// the local log, and pushed back via [`Model::push_history`]. The timestamp +/// is deliberately absent — the model has no clock (Stage-2 lesson). Born in +/// `apply_resolve` while the card is still open: the ONLY moment to/amount +/// are known (the server's retained outcome drops the preview). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecidedOutcome { + /// The item's preview id. + pub id: String, + /// Terminal state, protocol vocabulary. + pub state: OutcomeState, + /// Recipient, EIP-55 verbatim (from the open card). + pub to: String, + /// Native value, decimal wei string (from the open card). + pub amount_wei: String, + /// Chain id (from the open card). + pub chain_id: u64, + /// Executed transaction hash, when this console saw it. + pub tx_hash: Option, + /// Failure reason, when this console saw it. + pub reason: Option, +} + +/// The Activity view's outcome filter, cycled by `f`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HistoryFilter { + /// Every outcome. + #[default] + All, + /// Executed only. + Executed, + /// Denied only. + Denied, + /// Expired only. + Expired, + /// Failed only. + Failed, +} + +impl HistoryFilter { + /// The next filter in the `f`-cycle. + #[must_use] + pub fn next(self) -> Self { + match self { + Self::All => Self::Executed, + Self::Executed => Self::Denied, + Self::Denied => Self::Expired, + Self::Expired => Self::Failed, + Self::Failed => Self::All, + } + } + + /// Whether an entry in this state passes the filter. + #[must_use] + pub fn admits(self, state: OutcomeState) -> bool { + match self { + Self::All => true, + Self::Executed => state == OutcomeState::Executed, + Self::Denied => state == OutcomeState::Denied, + Self::Expired => state == OutcomeState::Expired, + Self::Failed => state == OutcomeState::Failed, + } + } + + /// The view-header label. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::All => "all", + Self::Executed => "executed", + Self::Denied => "denied", + Self::Expired => "expired", + Self::Failed => "failed", + } + } +} + +/// The protocol word for a decision made here — the ONE mapping between +/// [`DecisionKind`] and the stored/wire vocabulary (a named seam: a swapped +/// arm must fail a test, not pass every gate green). +fn outcome_state(kind: DecisionKind) -> OutcomeState { + match kind { + DecisionKind::Approved => OutcomeState::Executed, + DecisionKind::Rejected => OutcomeState::Denied, + DecisionKind::Expired => OutcomeState::Expired, + DecisionKind::Failed => OutcomeState::Failed, + } +} + /// An open card **is** the confirmation dialog (`AGENTS.md` #5): it is left by /// approving or denying, never by simply closing. #[derive(Debug)] @@ -394,6 +550,10 @@ pub enum Msg { /// Switch to a view (nav-shell tab). Refused while a card is open or a /// card request exists — see [`Model::on_view`]. View(View), + /// Cycle the Activity view's outcome filter (`f`). A no-op outside the + /// Activity view — the key is only mapped there, but the model is the + /// boundary, not the key map. + Filter, /// Quit. Quit, } @@ -433,8 +593,28 @@ pub struct Model { /// block is flagged stale (Gate-1: named mechanism, not derived from /// `read_age`). Cleared by a successful refresh. context_stale: bool, + /// Activity history, newest first (unix desc, id asc) — fed ONLY by the + /// run loop (log load + stamped pushes); the model never stamps a time + /// (it has no clock). Capped at [`HISTORY_CAP`]. + history: Vec, + /// The Activity view's outcome filter (`f` cycles it). + history_filter: HistoryFilter, + /// A decision made here, waiting for the run loop to stamp and persist + /// it ([`Self::take_decided_outcome`]). + decided: Option, + /// Server outcomes from `activity` replies, waiting for the run loop's + /// dedup/stamp pass ([`Self::take_server_outcomes`]). + server_outcomes: Vec, + /// A static log-degradation note for the Activity footer, set once at + /// startup by the run loop (persistence off / log partially unreadable). + /// `None` when the log is healthy. + history_note: Option, } +/// In-memory/display cap on the activity history (the file compacts at 2× +/// this on load — run-loop side, which is why it is `pub`). +pub const HISTORY_CAP: usize = 500; + /// A parked user intent (no `List` — the poll is suppressed, not queued). /// /// **Not `derive(Debug)`**: `ApprovePin` holds the serialized PIN, and `Zeroizing` @@ -478,6 +658,11 @@ impl Default for Model { read_age: STALE_TICKS, next_read: ReadOp::default(), context_stale: false, + history: Vec::new(), + history_filter: HistoryFilter::default(), + decided: None, + server_outcomes: Vec::new(), + history_note: None, } } } @@ -535,6 +720,82 @@ impl Model { self.decision.take() } + /// The activity history, newest first (unix desc, id asc — display order). + #[must_use] + pub fn history(&self) -> &[HistoryEntry] { + &self.history + } + + /// The Activity view's current outcome filter. + #[must_use] + pub fn history_filter(&self) -> HistoryFilter { + self.history_filter + } + + /// The static log-degradation note for the Activity footer, if any. + #[must_use] + pub fn history_note(&self) -> Option<&str> { + self.history_note.as_deref() + } + + /// Set the log-degradation footer note (run-loop startup, at most once). + pub fn set_history_note(&mut self, note: String) { + self.history_note = Some(note); + } + + /// Seed the history from the local log (run-loop startup; the caller + /// dedups by id — it owns the dedup set). Sorted and capped here so the + /// display invariant never depends on file order. + pub fn set_history(&mut self, entries: Vec) { + self.history = entries; + self.sort_and_cap_history(); + } + + /// Add one stamped entry (run loop only; id uniqueness is the caller's + /// contract — it owns the dedup set). + pub fn push_history(&mut self, entry: HistoryEntry) { + self.history.push(entry); + self.sort_and_cap_history(); + } + + /// Fill MISSING detail on the known entry with `from.id` (spec /check-2), + /// both ways: an item resolved by another connection leaves our rich + /// record without tx_hash/reason (the server window carries them); a + /// server-only record gains to/amount/chain when the same id is later + /// decided here. Present fields are never overwritten (the shared + /// [`HistoryEntry::fill_missing_from`] seam); the file keeps its first + /// record (append-only) — this enriches the session view only. Takes the + /// whole record, not positional Options — five same-typed parameters + /// were a silent-swap hazard (Gate-2 МИНОР-9). + pub fn fill_history_detail(&mut self, from: &HistoryEntry) { + if let Some(entry) = self.history.iter_mut().find(|e| e.id == from.id) { + entry.fill_missing_from(from); + } + } + + /// Drain the decision made here, for the run loop to stamp (unix = now), + /// persist, and push back via [`Self::push_history`] — in the same loop + /// pass, before the next render (the order is normative). + pub fn take_decided_outcome(&mut self) -> Option { + self.decided.take() + } + + /// Drain the server outcomes queued by `activity` replies, for the run + /// loop's dedup/stamp pass (unix = arrival − age, §3.9). + pub fn take_server_outcomes(&mut self) -> Vec { + std::mem::take(&mut self.server_outcomes) + } + + /// Newest first: unix desc, id asc on ties (a lockout batch shares one + /// second — determinism mirrors the server's own tiebreak). Two RICH + /// decisions inside one second tie-break by id too — a documented + /// consequence of whole-second stamps, not chronology (Gate-1 finding). + fn sort_and_cap_history(&mut self) { + self.history + .sort_unstable_by(|a, b| b.unix.cmp(&a.unix).then_with(|| a.id.cmp(&b.id))); + self.history.truncate(HISTORY_CAP); + } + /// Fold one message into the model, returning at most one request to send. pub fn update(&mut self, msg: Msg) -> Option { match msg { @@ -583,6 +844,17 @@ impl Model { self.on_view(view); None } + Msg::Filter => { + if let Phase::Watching { + view: View::Activity, + confirm: None, + .. + } = &self.phase + { + self.history_filter = self.history_filter.next(); + } + None + } Msg::Open => self.on_open(), } } @@ -607,8 +879,8 @@ impl Model { } = &mut self.phase { *current = view; - if view == View::Dashboard { - // Entering the dashboard marks the read data stale: the next + if matches!(view, View::Dashboard | View::Activity) { + // Entering a read-op view marks the read data stale: the next // list reply refreshes it. Bounded structurally — at most one // read-op per list reply (spec /check-5). self.read_age = self.read_age.max(STALE_TICKS); @@ -861,6 +1133,7 @@ impl Model { } Reply::Context(outcome) => self.apply_context(outcome), Reply::Positions(outcome) => self.apply_positions(outcome), + Reply::Activity(outcomes) => self.apply_activity(outcomes), Reply::List(items) => self.apply_list(items), Reply::Get(outcome) => self.apply_get(outcome), Reply::Resolve(outcome) => self.apply_resolve(outcome), @@ -887,11 +1160,15 @@ impl Model { } /// The read-op scheduler (list-first policy, canon §3.8 + the Reviewer's - /// Gate-1 flag): a read-op (`positions` | `context` refresh, alternating) - /// goes out ONLY right after a fresh `list` reply — never from a tick - /// (the tick belongs to `list`), never ahead of a parked user intent - /// (`flush_pending` ran first), never with a card open or a card request - /// anywhere in flight, and only when the read data has gone stale. + /// Gate-1 flag): a read-op goes out ONLY right after a fresh `list` + /// reply — never from a tick (the tick belongs to `list`), never ahead + /// of a parked user intent (`flush_pending` ran first), never with a + /// card open or a card request anywhere in flight, and only when the + /// read data has gone stale. WHICH op is the view's choice: the + /// Dashboard alternates `positions`/`context` (the two-valued + /// [`ReadOp`] state is untouched by Stage 7); the Activity view sends + /// `activity` — it never joins the alternation, so leaving and + /// re-entering the Dashboard resumes exactly where it left off. /// /// Guaranteed and tested: the ORDER OF ORIGINATION — a read-op never cuts /// ahead of a due `list`. Honest limit (Gate-1): once a read-op is on the @@ -899,16 +1176,14 @@ impl Model { /// list polling stalls with it — the same risk class as the post-auth /// `context` fetch accepted since Stage 2, occurring more often here. fn dispatch_read_op(&mut self) -> Option { - if !matches!( - self.phase, + let view = match &self.phase { Phase::Watching { confirm: None, - view: View::Dashboard, + view: view @ (View::Dashboard | View::Activity), .. - } - ) { - return None; - } + } => *view, + _ => return None, + }; // `pending` is None here (flush_pending ran first and found nothing) // and `awaiting_card` cannot coexist with a just-answered list on the // single slot — both checked anyway: the model is the boundary. @@ -917,6 +1192,9 @@ impl Model { } self.read_age = 0; // the cadence counts from dispatch self.in_flight = true; + if view == View::Activity { + return Some(transport::Request::Activity); + } let req = match self.next_read { ReadOp::Positions => transport::Request::Positions, ReadOp::Context => transport::Request::Context, @@ -928,6 +1206,14 @@ impl Model { Some(req) } + /// Fold an `activity` answer: queue the server outcomes for the run + /// loop's dedup/stamp pass ([`Self::take_server_outcomes`]). Deliberately + /// view-independent — history is global state, an in-flight answer + /// landing after a tab switch still counts. + fn apply_activity(&mut self, outcomes: Vec) { + self.server_outcomes.extend(outcomes); + } + /// Fold a `positions` answer — feeds the dashboard block only, gates /// nothing. Deliberately simpler than [`Self::apply_context`]: a /// `wallet_locked` here REPLACES `Loaded` with `Unavailable` instead of @@ -1071,13 +1357,28 @@ impl Model { let detail = tx_hash.clone().or_else(|| reason.clone()); self.decision = Some(Decision { kind, - tx_hash, - reason, + tx_hash: tx_hash.clone(), + reason: reason.clone(), }); if let Phase::Watching { confirm, notice, .. } = &mut self.phase { + if let Some(c) = confirm.as_deref() { + // The card is still open — the ONLY moment to/amount are + // known (the server's retained outcome drops the preview): + // the rich history record is born here, stamped and + // persisted by the run loop (Stage 7). + self.decided = Some(DecidedOutcome { + id: c.card.id.clone(), + state: outcome_state(kind), + to: c.card.to.clone(), + amount_wei: c.card.amount_wei.clone(), + chain_id: c.card.chain_id, + tx_hash, + reason, + }); + } *confirm = None; *notice = Some(Notice::Outcome { kind, detail }); } @@ -2925,4 +3226,252 @@ mod tests { m.update(Msg::View(View::Dashboard)); assert_eq!(view_of(&m), View::Queue, "the card pins the queue view"); } + + // ── Stage 7: the Activity view, its scheduler slot, the history model ── + + fn history_entry(id: &str, unix: u64, state: OutcomeState) -> HistoryEntry { + HistoryEntry { + unix, + id: id.to_owned(), + state, + to: None, + amount_wei: None, + chain_id: None, + tx_hash: None, + reason: None, + } + } + + #[test] + fn the_activity_view_dispatches_the_activity_read_op() { + let mut m = on_dashboard(); + m.update(Msg::View(View::Activity)); + assert!( + matches!(poll_round(&mut m), Some(transport::Request::Activity)), + "on the Activity view the list-first slot carries the activity op" + ); + } + + #[test] + fn the_tick_belongs_to_list_on_the_activity_view_too() { + let mut m = on_dashboard(); + m.update(Msg::View(View::Activity)); + assert!( + matches!(m.update(Msg::Tick), Some(transport::Request::List)), + "a tick never births a read-op — the activity op waits for the list reply" + ); + } + + #[test] + fn entering_activity_marks_the_read_data_stale() { + let mut m = on_dashboard(); + // Consume the born-stale dispatch and hand its answer back: fresh data. + assert!(matches!( + poll_round(&mut m), + Some(transport::Request::Positions) + )); + m.update(Msg::Reply(Reply::Positions(PositionsOutcome::Ok(vec![])))); + assert!(poll_round(&mut m).is_none(), "fresh data: no read-op due"); + m.update(Msg::View(View::Activity)); + assert!( + matches!(poll_round(&mut m), Some(transport::Request::Activity)), + "entering the Activity view marks the data stale — the next list reply fetches" + ); + } + + #[test] + fn activity_never_joins_the_dashboard_alternation() { + let mut m = on_dashboard(); + assert!(matches!( + poll_round(&mut m), + Some(transport::Request::Positions) + )); + m.update(Msg::Reply(Reply::Positions(PositionsOutcome::Ok(vec![])))); + // Visit Activity: its op rides the slot without touching next_read. + m.update(Msg::View(View::Activity)); + assert!(matches!( + poll_round(&mut m), + Some(transport::Request::Activity) + )); + m.update(Msg::Reply(Reply::Activity(vec![]))); + // Back on the Dashboard the alternation resumes exactly where it left. + m.update(Msg::View(View::Dashboard)); + assert!( + matches!(poll_round(&mut m), Some(transport::Request::Context)), + "the two-valued alternation is untouched by the activity slot" + ); + } + + #[test] + fn a_decision_here_births_a_rich_history_record() { + let mut m = watching(vec![summary("a1")]); + assert!(matches!( + m.update(Msg::Open), + Some(transport::Request::Get(_)) + )); + m.update(Msg::Reply(Reply::Get(GetOutcome::Card(card("a1"))))); + assert!(matches!( + m.update(Msg::Approve), + Some(transport::Request::Approve(_)) + )); + m.update(Msg::Reply(Reply::Resolve(ResolveOutcome::Executed { + tx_hash: "0xfeed".to_owned(), + }))); + + let d = m + .take_decided_outcome() + .expect("a rich record is born at decision time, while the card is open"); + assert_eq!(d.id, "a1"); + assert_eq!(d.state, OutcomeState::Executed); + assert_eq!(d.to, "0xabc", "the card's recipient, verbatim"); + assert_eq!(d.amount_wei, "0", "the card's amount"); + assert_eq!(d.chain_id, 1); + assert_eq!(d.tx_hash.as_deref(), Some("0xfeed")); + assert_eq!(d.reason, None); + assert!(m.take_decided_outcome().is_none(), "drained exactly once"); + // The ADR-#7 decision-line drain is a separate, untouched channel. + assert!(m.take_decision().is_some()); + } + + #[test] + fn server_outcomes_queue_for_the_run_loop_and_drain_once() { + let mut m = on_dashboard(); + m.update(Msg::Reply(Reply::Activity(vec![OutcomeEntry { + id: "s1".to_owned(), + state: OutcomeState::Denied, + tx_hash: None, + reason: None, + age_secs: 42, + }]))); + let drained = m.take_server_outcomes(); + assert_eq!(drained.len(), 1); + assert_eq!(drained[0].id, "s1"); + assert!( + m.take_server_outcomes().is_empty(), + "the queue drains exactly once" + ); + } + + #[test] + fn history_sorts_newest_first_ties_by_id_and_caps() { + let mut m = Model::new(); + m.set_history(vec![ + history_entry("b", 100, OutcomeState::Denied), + history_entry("c", 200, OutcomeState::Executed), + history_entry("a", 100, OutcomeState::Denied), + ]); + let ids: Vec<&str> = m.history().iter().map(|e| e.id.as_str()).collect(); + assert_eq!( + ids, + vec!["c", "a", "b"], + "newest first; a shared second falls back to id order" + ); + + let many: Vec = (0..=HISTORY_CAP) + .map(|i| history_entry(&format!("id{i:04}"), i as u64, OutcomeState::Denied)) + .collect(); + m.set_history(many); + assert_eq!( + m.history().len(), + HISTORY_CAP, + "capped at the display limit" + ); + assert!( + m.history().iter().all(|e| e.unix >= 1), + "what fell off the cap is exactly the oldest entry" + ); + } + + #[test] + fn two_rich_decisions_in_one_second_order_by_id_not_chronology() { + // Gate-1 finding, documented on purpose: unix is whole seconds — two + // decisions inside one second CANNOT be ordered by time here; the id + // tiebreak (asc) makes the order deterministic, not chronological. + let mut ids = [uuid_like("f"), uuid_like("0")]; + ids.sort(); + let (small, big) = (ids[0].clone(), ids[1].clone()); + let mut m = Model::new(); + // Decided big-id first, small-id second — same second. + m.push_history(history_entry(&big, 77, OutcomeState::Denied)); + m.push_history(history_entry(&small, 77, OutcomeState::Denied)); + let got: Vec<&str> = m.history().iter().map(|e| e.id.as_str()).collect(); + assert_eq!( + got, + vec![small.as_str(), big.as_str()], + "id order wins inside one second — regardless of decision order" + ); + } + + fn uuid_like(prefix: &str) -> String { + format!("{prefix}1b2c3d-0000-0000-0000-000000000000") + } + + #[test] + fn fill_history_detail_fills_only_the_missing_fields() { + // Gate-2 МИНОР-6: every one of the five fields is exercised in BOTH + // directions — present (never overwritten) and missing (filled), + // reason included with a real value. + let mut m = Model::new(); + let mut base = history_entry("a", 10, OutcomeState::Failed); + base.to = Some("0xTO".to_owned()); + base.reason = Some("broadcast failed".to_owned()); + m.set_history(vec![base]); + + let mut from = history_entry("a", 99, OutcomeState::Failed); + from.to = Some("0xEVIL".to_owned()); + from.amount_wei = Some("5".to_owned()); + from.chain_id = Some(1); + from.tx_hash = Some("0xfeed".to_owned()); + from.reason = Some("a different story".to_owned()); + m.fill_history_detail(&from); + + let e = &m.history()[0]; + assert_eq!( + e.to.as_deref(), + Some("0xTO"), + "a present field is never overwritten" + ); + assert_eq!( + e.reason.as_deref(), + Some("broadcast failed"), + "a present reason is never overwritten" + ); + assert_eq!(e.amount_wei.as_deref(), Some("5"), "a missing field fills"); + assert_eq!(e.chain_id, Some(1)); + assert_eq!(e.tx_hash.as_deref(), Some("0xfeed")); + + // And the reverse direction: a poor base gains a real reason. + let mut m = Model::new(); + m.set_history(vec![history_entry("b", 10, OutcomeState::Failed)]); + let mut from = history_entry("b", 99, OutcomeState::Failed); + from.reason = Some("broadcast failed".to_owned()); + m.fill_history_detail(&from); + assert_eq!( + m.history()[0].reason.as_deref(), + Some("broadcast failed"), + "a missing reason fills with the real value" + ); + } + + #[test] + fn the_filter_cycles_only_on_the_activity_view() { + let mut m = on_dashboard(); + m.update(Msg::Filter); + assert_eq!( + m.history_filter(), + HistoryFilter::All, + "no cycling off the Activity view — the model is the boundary" + ); + m.update(Msg::View(View::Activity)); + m.update(Msg::Filter); + assert_eq!(m.history_filter(), HistoryFilter::Executed); + for _ in 0..4 { + m.update(Msg::Filter); + } + assert_eq!( + m.history_filter(), + HistoryFilter::All, + "the cycle closes back on All" + ); + } } diff --git a/src/format.rs b/src/format.rs index 466da17..3fffb09 100644 --- a/src/format.rs +++ b/src/format.rs @@ -54,6 +54,22 @@ pub fn is_zero_wei(wei: &str) -> bool { !digits.is_empty() && digits.bytes().all(|b| b == b'0') } +/// Shorten an address for a DISPLAY-LIST row: `0x489Fe0…bbbb` (first 6 + last 4 +/// hex digits, EIP-55 casing preserved verbatim). **Never on a signing or +/// approving surface** — the card, From→To and Receive render addresses in full +/// (address poisoning; ТЗ §4.1): this exists for the Activity list only, where +/// the row is display, not a decision. Input without a `0x` prefix, non-ASCII, +/// or too short to save space is returned verbatim (display never crashes). +#[must_use] +pub fn short_addr(addr: &str) -> String { + match addr.strip_prefix("0x") { + Some(hex) if hex.is_ascii() && hex.len() > 12 => { + format!("0x{}…{}", &hex[..6], &hex[hex.len() - 4..]) + } + _ => addr.to_owned(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -94,4 +110,38 @@ mod tests { assert!(!is_zero_wei("10000000000000000")); assert!(!is_zero_wei("")); } + + #[test] + fn short_addr_keeps_head_tail_and_eip55_casing() { + assert_eq!( + short_addr("0x489Fe09Fbb489Fe09Fbb489Fe09Fbb489F9Fbbbb"), + "0x489Fe0…bbbb", + "first 6 + last 4, casing verbatim" + ); + } + + #[test] + fn short_addr_returns_unshortenable_input_verbatim() { + assert_eq!( + short_addr("0x1234567890ab"), + "0x1234567890ab", + "12 hex: nothing saved" + ); + assert_eq!( + short_addr("not-an-address"), + "not-an-address", + "no 0x prefix" + ); + assert_eq!(short_addr(""), ""); + } + + #[test] + fn short_addr_never_panics_on_non_ascii() { + let hostile = "0xдлинная-не-ascii-строка-длиннее-двенадцати"; + assert_eq!( + short_addr(hostile), + hostile, + "non-ASCII input is returned verbatim" + ); + } } diff --git a/src/main.rs b/src/main.rs index e332618..600d2b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,9 @@ //! `2026-07-12-invariant-7-decision-stream`); the exit code reports how the //! session ended (aborted / no-tty / fatal / upgrade). +use std::collections::{HashMap, HashSet}; use std::io::{self, IsTerminal, Stderr, Write as _}; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -23,7 +25,9 @@ use ratatui::crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; -use rustok_console::app::{Decision, DecisionKind, Model, Msg, Phase, View}; +use rustok_console::app::{ + Decision, DecisionKind, HISTORY_CAP, HistoryEntry, Model, Msg, Phase, View, +}; use rustok_console::transport::{Transport, TransportError}; use rustok_console::ui; @@ -112,12 +116,263 @@ fn set_panic_hook() { })); } +/// The run loop's side of the history pipeline (Stage 7): the model has no +/// clock and no filesystem, so stamping, persistence and the id-dedup set +/// (§3.9) live here. +struct HistoryLog { + /// Append target; `None` = session-only (no path configured, or a write + /// failed and persistence was honestly switched off). + path: Option, + /// Every id in the log or recorded this session — the dedup set. + seen: HashSet, + /// Appends since the last compaction — a resident session compacts + /// periodically, not only at startup (Gate-2 МИНОР-3). + appended: usize, +} + +impl HistoryLog { + /// Env wrapper over [`Self::open_at`] — kept apart so the open path is + /// testable without touching the process environment (Gate-2 МИНОР-8). + fn open(model: &mut Model) -> Self { + Self::open_at(history_log_path(), model) + } + + /// Load and merge the file at `path` (or none = session-only), compact + /// it when oversized (best-effort), and seed the model with the entries + /// and a degradation note when there is one. + fn open_at(path: Option, model: &mut Model) -> Self { + let Some(path) = path else { + model.set_history_note( + "history is session-only — set RUSTOK_CONSOLE_LOG or RUSTOK_DATA_DIR to persist" + .to_owned(), + ); + return Self { + path: None, + seen: HashSet::new(), + appended: 0, + }; + }; + let loaded = load_history(&path); + if loaded.unreadable { + // NOT the missing-file case (that is a normal empty history): + // permissions or I/O — a history may exist but cannot be read. + // Say so instead of presenting a healthy empty view (BLOCKER-2). + model.set_history_note( + "activity log could not be read — history may be incomplete".to_owned(), + ); + } else if loaded.skipped > 0 { + model.set_history_note(format!( + "activity log partially unreadable — {} line(s) skipped", + loaded.skipped + )); + } + // The trigger counts RAW lines, not deduped entries — duplicate junk + // must not grow unbounded below the entry count (Gate-2 МИНОР-2). + if loaded.raw_lines > 2 * HISTORY_CAP { + compact_history(&path); + } + let seen = loaded.entries.iter().map(|e| e.id.clone()).collect(); + model.set_history(loaded.entries); + Self { + path: Some(path), + seen, + appended: 0, + } + } + + /// Record one stamped entry: a NEW id is appended to the file (when + /// persisting) and pushed into the model; a known id only fills the + /// missing fields of its existing row (fill-missing merge, spec /check-2 + /// — the file keeps its first record, append-only). A failed write + /// switches persistence off with a visible note — never a crash, never + /// silence. Every [`HISTORY_CAP`] appends the file re-compacts: a + /// resident session must not grow it unboundedly until the next restart + /// (Gate-2 МИНОР-3). + fn record(&mut self, model: &mut Model, entry: HistoryEntry) { + if !self.seen.insert(entry.id.clone()) { + model.fill_history_detail(&entry); + return; + } + if let Some(path) = &self.path { + if append_history_line(path, &entry).is_err() { + self.path = None; + model.set_history_note( + "history is not being persisted — the activity log write failed".to_owned(), + ); + } else { + self.appended += 1; + if self.appended >= HISTORY_CAP { + compact_history(path); + self.appended = 0; + } + } + } + model.push_history(entry); + } +} + +/// The local log's path cascade: an explicit override, else the wallet +/// image's persistent volume (`RUSTOK_DATA_DIR=/data`, Dockerfile.wallet), +/// else nowhere (session-only). +fn history_log_path() -> Option { + if let Ok(explicit) = std::env::var("RUSTOK_CONSOLE_LOG") { + return Some(PathBuf::from(explicit)); + } + std::env::var("RUSTOK_DATA_DIR") + .ok() + .map(|dir| Path::new(&dir).join("console-activity.jsonl")) +} + +/// What [`load_history`] found on disk. +struct LoadedHistory { + /// Merged entries, one per id (file order first-wins; later duplicates + /// only fill missing fields — Gate-2 BLOCKER-1). + entries: Vec, + /// Corrupt (non-UTF8 / non-JSON) lines — counted, noted, never fatal. + skipped: usize, + /// Raw non-empty lines on disk, pre-dedup — the compaction trigger. + raw_lines: usize, + /// The file exists but could NOT be read (permissions, I/O) — distinct + /// from missing (a normal empty history). BLOCKER-2: this surfaces as a + /// note, never as a silently healthy empty view. + unreadable: bool, +} + +/// Load the JSONL log as BYTES — a torn multi-byte write from a killed +/// process must poison one line, not the whole read (BLOCKER-2). Corrupt +/// lines are skipped and counted; duplicate ids merge FIELD-BY-FIELD in +/// file order (the first record wins for present fields, later ones fill +/// gaps — the same [`HistoryEntry::fill_missing_from`] seam as every other +/// duplicate path). BLOCKER-1: the stamps of a rich decision record and a +/// poor server-window record come from different formulas — a timestamp +/// must never decide which record survives whole. +fn load_history(path: &Path) -> LoadedHistory { + let empty = |unreadable| LoadedHistory { + entries: Vec::new(), + skipped: 0, + raw_lines: 0, + unreadable, + }; + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == io::ErrorKind::NotFound => return empty(false), + Err(_) => return empty(true), + }; + let mut skipped = 0usize; + let mut raw_lines = 0usize; + let mut entries: Vec = Vec::new(); + let mut index_of: HashMap = HashMap::new(); + for raw in bytes.split(|b| *b == b'\n') { + if raw.iter().all(u8::is_ascii_whitespace) { + continue; + } + raw_lines += 1; + let parsed = std::str::from_utf8(raw) + .ok() + .and_then(|line| serde_json::from_str::(line).ok()); + let Some(entry) = parsed else { + skipped += 1; + continue; + }; + match index_of.entry(entry.id.clone()) { + std::collections::hash_map::Entry::Occupied(slot) => { + entries[*slot.get()].fill_missing_from(&entry); + } + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(entries.len()); + entries.push(entry); + } + } + } + LoadedHistory { + entries, + skipped, + raw_lines, + unreadable: false, + } +} + +/// Open (creating 0600) for writing. The mode applies only when the file is +/// created — the log carries the wallet's decision history, no reason to be +/// group/world-readable (Gate-2 МИНОР-5). +fn open_log_0600(path: &Path, append: bool) -> io::Result { + use std::os::unix::fs::OpenOptionsExt as _; + std::fs::OpenOptions::new() + .create(true) + .append(append) + .write(true) + .truncate(!append) + .mode(0o600) + .open(path) +} + +/// Serialize log WRITERS across processes: appends and compaction take an +/// exclusive lock on a sidecar lockfile. The sidecar — not the log itself — +/// because compaction replaces the log via rename: a writer blocked on the +/// old inode's lock would wake up owning an unlinked ghost file and write +/// into the void (Gate-2 МИНОР-1 — the TOCTOU race four reviewers hit). +/// Readers do not lock: appends are line-atomic and rename is atomic. +fn lock_log(path: &Path) -> io::Result { + let mut lock_path = path.as_os_str().to_owned(); + lock_path.push(".lock"); + let file = open_log_0600(Path::new(&lock_path), true)?; + file.lock()?; + Ok(file) // released on drop +} + +/// Append one entry as a JSONL line, under the writers' lock. +fn append_history_line(path: &Path, entry: &HistoryEntry) -> io::Result<()> { + let mut line = + serde_json::to_string(entry).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + line.push('\n'); + let _lock = lock_log(path)?; + open_log_0600(path, true)?.write_all(line.as_bytes()) +} + +/// Best-effort compaction: under the writers' lock, RE-READ the file fresh — +/// a snapshot taken before the lock could erase a concurrent append (Gate-2 +/// МИНОР-1) — keep the newest [`HISTORY_CAP`] merged entries, and swap them +/// in atomically. The tmp name carries the pid (МИНОР-4: a shared name +/// would race two consoles starting over the same oversized file). Corrupt +/// and duplicate lines are dropped with the rest (the file heals); any +/// failure leaves the file exactly as it was. +fn compact_history(path: &Path) { + let Ok(_lock) = lock_log(path) else { + return; + }; + let loaded = load_history(path); + if loaded.unreadable { + return; + } + let mut newest = loaded.entries; + newest.sort_unstable_by(|a, b| b.unix.cmp(&a.unix).then_with(|| a.id.cmp(&b.id))); + newest.truncate(HISTORY_CAP); + let mut body = String::new(); + for entry in &newest { + match serde_json::to_string(entry) { + Ok(line) => { + body.push_str(&line); + body.push('\n'); + } + Err(_) => return, + } + } + let tmp = path.with_extension(format!("jsonl.tmp.{}", std::process::id())); + let written = open_log_0600(&tmp, false) + .and_then(|mut f| f.write_all(body.as_bytes())) + .is_ok(); + if written && std::fs::rename(&tmp, path).is_err() { + let _ = std::fs::remove_file(&tmp); + } +} + /// The event loop: draw, read input, drain worker replies, tick the poll. The /// resident console leaves only on quit or a dead connection; each terminal /// decision is streamed to stdout as it happens (when stdout is not a TTY). /// A `Fatal` phase is shown until a keypress, then exits. fn run(mut terminal: Tui, transport: &Transport, stream_decisions: bool) -> u8 { let mut model = Model::new(); + let mut history = HistoryLog::open(&mut model); let mut last_tick = Instant::now(); // Report the terminal size before anything else: the model approves only a @@ -191,6 +446,40 @@ fn run(mut terminal: Tui, transport: &Transport, stream_decisions: bool) -> u8 { { let _ = writeln!(io::stdout(), "{}", decision_line(&decision)); } + // History pipeline (Stage 7): stamp → persist → push back, in the + // SAME pass, before the next render (the order is normative). A + // decision made here is stamped `now`; a server outcome is + // stamped `arrival − age` (§3.9). + if let Some(d) = model.take_decided_outcome() { + history.record( + &mut model, + HistoryEntry { + unix: now_unix(), + id: d.id, + state: d.state, + to: Some(d.to), + amount_wei: Some(d.amount_wei), + chain_id: Some(d.chain_id), + tx_hash: d.tx_hash, + reason: d.reason, + }, + ); + } + for outcome in model.take_server_outcomes() { + history.record( + &mut model, + HistoryEntry { + unix: now_unix().saturating_sub(outcome.age_secs), + id: outcome.id, + state: outcome.state, + to: None, + amount_wei: None, + chain_id: None, + tx_hash: outcome.tx_hash, + reason: outcome.reason, + }, + ); + } } if last_tick.elapsed() >= POLL { @@ -321,6 +610,7 @@ fn map_key(key: &KeyEvent, phase: &Phase) -> Option { KeyCode::Enter => Some(Msg::Open), KeyCode::Char('d') => Some(Msg::View(View::Dashboard)), KeyCode::Char('r') => Some(Msg::View(View::Receive)), + KeyCode::Char('h') => Some(Msg::View(View::Activity)), KeyCode::Char('q') => Some(Msg::Quit), _ => None, }, @@ -334,6 +624,7 @@ fn map_key(key: &KeyEvent, phase: &Phase) -> Option { } => match key.code { KeyCode::Char('a') | KeyCode::Esc => Some(Msg::View(View::Queue)), KeyCode::Char('d') => Some(Msg::View(View::Dashboard)), + KeyCode::Char('h') => Some(Msg::View(View::Activity)), KeyCode::Char('q') => Some(Msg::Quit), _ => None, }, @@ -345,6 +636,20 @@ fn map_key(key: &KeyEvent, phase: &Phase) -> Option { } => match key.code { KeyCode::Char('a') | KeyCode::Esc => Some(Msg::View(View::Queue)), KeyCode::Char('r') => Some(Msg::View(View::Receive)), + KeyCode::Char('h') => Some(Msg::View(View::Activity)), + KeyCode::Char('q') => Some(Msg::Quit), + _ => None, + }, + // Activity is display-only: navigation, the outcome filter, quit. + Phase::Watching { + confirm: None, + view: View::Activity, + .. + } => match key.code { + KeyCode::Char('a') | KeyCode::Esc => Some(Msg::View(View::Queue)), + KeyCode::Char('d') => Some(Msg::View(View::Dashboard)), + KeyCode::Char('r') => Some(Msg::View(View::Receive)), + KeyCode::Char('f') => Some(Msg::Filter), KeyCode::Char('q') => Some(Msg::Quit), _ => None, }, @@ -842,4 +1147,346 @@ mod tests { Some(Msg::View(View::Receive)) )); } + + // ── Stage 7: the Activity view's keys and the history log's file side ── + + fn activitying() -> Phase { + Phase::Watching { + items: vec![], + selected: 0, + confirm: None, + notice: None, + view: View::Activity, + } + } + + #[test] + fn h_reaches_activity_from_every_display_view_and_f_cycles_there() { + for phase in [watching(), receiving(), dashboarding()] { + assert!( + matches!( + map_key(&key(KeyCode::Char('h')), &phase), + Some(Msg::View(View::Activity)) + ), + "h opens the Activity view" + ); + } + assert!(matches!( + map_key(&key(KeyCode::Char('f')), &activitying()), + Some(Msg::Filter) + )); + assert!( + map_key(&key(KeyCode::Char('f')), &watching()).is_none(), + "the filter key exists only on the Activity view" + ); + assert!(matches!( + map_key(&key(KeyCode::Char('a')), &activitying()), + Some(Msg::View(View::Queue)) + )); + assert!(matches!( + map_key(&key(KeyCode::Char('d')), &activitying()), + Some(Msg::View(View::Dashboard)) + )); + assert!(matches!( + map_key(&key(KeyCode::Char('r')), &activitying()), + Some(Msg::View(View::Receive)) + )); + assert!(matches!( + map_key(&key(KeyCode::Char('q')), &activitying()), + Some(Msg::Quit) + )); + } + + use rustok_console::protocol::OutcomeState; + + fn tmp_log(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("rustok_console_hist_{}_{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmp dir"); + dir.join("log.jsonl") + } + + fn hist(id: &str, unix: u64) -> HistoryEntry { + HistoryEntry { + unix, + id: id.to_owned(), + state: OutcomeState::Denied, + to: None, + amount_wei: None, + chain_id: None, + tx_hash: None, + reason: None, + } + } + + #[test] + fn load_merges_duplicate_ids_field_by_field_never_by_timestamp() { + // Gate-2 BLOCKER-1: console P1 decides a card (rich record, unix = + // now); a second long-lived console P2 later sees the same id via + // activity polling (poor record, unix = arrival − age — a DIFFERENT + // formula, not guaranteed smaller). Both lines are in the file. A + // whole-record replace by "bigger unix" trades the rich record for + // the poor one; the load must merge field-by-field instead. + let path = tmp_log("merge_rich_first"); + let mut rich = hist("a", 100); + rich.to = Some("0xTO".to_owned()); + rich.amount_wei = Some("5".to_owned()); + rich.tx_hash = Some("0xfeed".to_owned()); + append_history_line(&path, &rich).expect("append rich"); + append_history_line(&path, &hist("a", 200)).expect("append poor, newer stamp"); + + let loaded = load_history(&path); + assert_eq!(loaded.entries.len(), 1); + assert_eq!( + loaded.entries[0].to.as_deref(), + Some("0xTO"), + "rich details survive a poorer duplicate with a newer stamp" + ); + assert_eq!(loaded.entries[0].tx_hash.as_deref(), Some("0xfeed")); + + // The reverse file order merges too: a poor base gains the rich fields. + let path = tmp_log("merge_poor_first"); + append_history_line(&path, &hist("b", 200)).expect("append poor"); + let mut rich = hist("b", 100); + rich.to = Some("0xTO".to_owned()); + append_history_line(&path, &rich).expect("append rich"); + let loaded = load_history(&path); + assert_eq!( + loaded.entries[0].to.as_deref(), + Some("0xTO"), + "a later rich line fills the poor base's gaps" + ); + } + + #[test] + fn a_torn_non_utf8_tail_never_hides_the_healthy_lines() { + // Gate-2 BLOCKER-2: a process killed mid-append (SIGKILL on the + // container) leaves a torn, possibly non-UTF8 tail. That is line + // corruption — counted and noted — never "the whole history is gone". + let path = tmp_log("torn"); + append_history_line(&path, &hist("good", 10)).expect("append"); + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .and_then(|mut f| f.write_all(b"\xff\xfe{torn")) + .expect("torn tail"); + + let loaded = load_history(&path); + assert_eq!( + loaded.entries.len(), + 1, + "the good line must survive a torn tail" + ); + assert_eq!(loaded.skipped, 1, "the torn tail is counted as corruption"); + } + + #[test] + fn the_log_roundtrips_and_a_duplicate_id_keeps_the_newest_stamp() { + let path = tmp_log("roundtrip"); + append_history_line(&path, &hist("a", 10)).expect("append"); + append_history_line(&path, &hist("b", 20)).expect("append"); + // The same id again — a second console appended the same server + // outcome with a newer stamp (spec /check-3). + append_history_line(&path, &hist("a", 30)).expect("append"); + + let loaded = load_history(&path); + assert_eq!(loaded.skipped, 0); + assert_eq!(loaded.entries.len(), 2, "load dedups by id"); + assert_eq!(loaded.raw_lines, 3, "the raw line count sees every line"); + let a = loaded + .entries + .iter() + .find(|e| e.id == "a") + .expect("a survives"); + assert_eq!( + a.unix, 10, + "the FIRST record in file order stays the base (never a timestamp contest)" + ); + } + + #[test] + fn corrupt_lines_are_skipped_and_counted_never_fatal() { + let path = tmp_log("corrupt"); + append_history_line(&path, &hist("good", 10)).expect("append"); + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .and_then(|mut f| f.write_all(b"{not json\ntail-without-brace")) + .expect("hostile bytes"); + + let loaded = load_history(&path); + assert_eq!(loaded.entries.len(), 1, "the good line survives"); + assert_eq!( + loaded.skipped, 2, + "both hostile lines are counted, not fatal" + ); + // A missing file is a normal empty history — NOT the unreadable case. + let missing = load_history(Path::new("/nonexistent/x.jsonl")); + assert!(missing.entries.is_empty() && !missing.unreadable); + } + + #[test] + fn an_unreadable_file_is_flagged_never_a_healthy_empty_history() { + // Gate-2 BLOCKER-2: permissions/I/O trouble is NOT "no history yet". + let path = tmp_log("unreadable"); + append_history_line(&path, &hist("a", 10)).expect("append"); + let mut perms = std::fs::metadata(&path).expect("meta").permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o000); + std::fs::set_permissions(&path, perms).expect("chmod"); + + let loaded = load_history(&path); + assert!(loaded.unreadable, "a read failure must be flagged"); + + let mut model = Model::new(); + let log = HistoryLog::open_at(Some(path.clone()), &mut model); + assert!( + model + .history_note() + .is_some_and(|n| n.contains("could not be read")), + "open surfaces the unreadable log as a note" + ); + assert!(log.path.is_some(), "appends stay best-effort"); + } + + #[test] + fn open_at_none_is_session_only_and_the_log_file_is_private() { + let mut model = Model::new(); + let log = HistoryLog::open_at(None, &mut model); + assert!(log.path.is_none()); + assert!( + model + .history_note() + .is_some_and(|n| n.contains("session-only")), + "no path configured reads as an explicit note" + ); + + // МИНОР-5: a fresh log file is created 0600 — the wallet's decision + // history is nobody else's business. + let path = tmp_log("private"); + append_history_line(&path, &hist("a", 10)).expect("append"); + let mode = std::os::unix::fs::PermissionsExt::mode( + &std::fs::metadata(&path).expect("meta").permissions(), + ); + assert_eq!(mode & 0o777, 0o600, "the log is owner-only"); + } + + #[test] + fn compaction_keeps_the_newest_cap_and_heals_the_file() { + let path = tmp_log("compact"); + let total = 2 * HISTORY_CAP + 1; + let mut body = String::new(); + for i in 0..total { + body.push_str(&serde_json::to_string(&hist(&format!("id{i:05}"), i as u64)).unwrap()); + body.push('\n'); + } + body.push_str("{corrupt line\n"); + std::fs::write(&path, body).expect("seed file"); + + let loaded = load_history(&path); + assert_eq!(loaded.entries.len(), total); + assert_eq!(loaded.skipped, 1); + compact_history(&path); + + let after = load_history(&path); + assert_eq!( + after.entries.len(), + HISTORY_CAP, + "compaction keeps exactly the cap" + ); + assert_eq!( + after.skipped, 0, + "the corrupt line is gone — the file healed" + ); + assert!( + after + .entries + .iter() + .all(|e| e.unix > (total - 1 - HISTORY_CAP) as u64), + "what was dropped is exactly the oldest tail" + ); + } + + #[test] + fn record_appends_new_ids_and_fills_missing_on_known_ones() { + let path = tmp_log("record"); + let mut model = Model::new(); + let mut log = HistoryLog { + path: Some(path.clone()), + seen: HashSet::new(), + appended: 0, + }; + + log.record(&mut model, hist("a", 10)); + assert_eq!(model.history().len(), 1); + // The same id again, now carrying a tx hash: fill-missing in memory, + // NO second file line (append-only keeps the first record). + let mut richer = hist("a", 11); + richer.tx_hash = Some("0xfeed".to_owned()); + log.record(&mut model, richer); + assert_eq!(model.history().len(), 1, "no duplicate row in the model"); + assert_eq!( + model.history()[0].tx_hash.as_deref(), + Some("0xfeed"), + "the missing detail filled in memory" + ); + let on_disk = load_history(&path); + assert_eq!( + on_disk.entries.len(), + 1, + "the file keeps its first record only" + ); + assert_eq!(on_disk.entries[0].tx_hash, None); + } + + #[test] + fn a_resident_session_compacts_periodically_not_only_at_startup() { + // Gate-2 МИНОР-3: seed a file bloated with duplicate lines of one id, + // arm the append counter one short of the threshold — the very next + // record() must fire a compaction that shrinks the file. + let path = tmp_log("periodic"); + let mut body = String::new(); + for i in 0..600u64 { + body.push_str(&serde_json::to_string(&hist("dup", i)).unwrap()); + body.push('\n'); + } + std::fs::write(&path, body).expect("seed bloated file"); + + let mut model = Model::new(); + let mut log = HistoryLog { + path: Some(path.clone()), + seen: std::iter::once("dup".to_owned()).collect(), + appended: HISTORY_CAP - 1, + }; + log.record(&mut model, hist("fresh", 1000)); + + let after = load_history(&path); + assert_eq!( + after.raw_lines, 2, + "the mid-session compaction collapsed the duplicate bloat" + ); + assert_eq!(log.appended, 0, "the counter re-arms after compacting"); + } + + #[test] + fn a_failed_write_switches_to_session_only_with_a_visible_note() { + let mut model = Model::new(); + let mut log = HistoryLog { + path: Some(PathBuf::from("/nonexistent-dir/x.jsonl")), + seen: HashSet::new(), + appended: 0, + }; + log.record(&mut model, hist("a", 10)); + assert!( + log.path.is_none(), + "persistence switched off after the failure" + ); + assert_eq!(model.history().len(), 1, "the session view still works"); + assert!( + model + .history_note() + .is_some_and(|n| n.contains("not being persisted")), + "the degradation is visible, not silent" + ); + } } diff --git a/src/protocol.rs b/src/protocol.rs index 315d4ee..10a564d 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -68,6 +68,10 @@ pub enum Request<'a> { /// protocol §3.8). Dispatched by the read-op scheduler only right after /// a `list` reply, never ahead of one (the §3.8 client rule). Positions, + /// Ask for the recent terminal outcomes (proto 2+, auth-gated — protocol + /// §3.9): newest first, server-capped at 100. Same scheduler discipline + /// as [`Request::Positions`] — only right after a `list` reply. + Activity, } /// Serialize a request to a single JSON line (no trailing `\n`; the transport adds @@ -304,6 +308,45 @@ pub enum PositionsOutcome { WalletLocked, } +/// The four terminal words an `activity` outcome may carry (protocol §3.9). +/// Deliberately NOT [`TerminalState`]: that enum includes `Pending` for +/// `already_resolved` (§3.5), which §3.9 promises never to send — a reply +/// carrying `"pending"` here must FAIL the parse (default-deny), not slip +/// through. `Serialize` is derived so the console's local activity log stores +/// exactly these protocol words (one vocabulary end to end). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OutcomeState { + /// Signed and broadcast. + Executed, + /// Rejected by the human. + Denied, + /// Expired before a decision. + Expired, + /// Approved, but signing/broadcast failed. + Failed, +} + +/// One retained terminal outcome from `activity` (protocol §3.9), verbatim. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct OutcomeEntry { + /// The resolved item's preview id — stable across polls; the dedup key + /// for the console's local log (§3.9). + pub id: String, + /// Terminal state word (§3.9 — never `"pending"`; see [`OutcomeState`]). + pub state: OutcomeState, + /// The executed transaction's hash (`0x…`) — executed only; on every + /// other state the field is absent on the wire (§3.9) and `None` here. + #[serde(default)] + pub tx_hash: Option, + /// Operator-masked failure reason — failed only; absent otherwise. + #[serde(default)] + pub reason: Option, + /// Seconds since the resolution — a relative age, not a timestamp; the + /// console derives an absolute time locally at arrival (§3.9). + pub age_secs: u64, +} + /// The terminal state carried by an `already_resolved` reply (protocol §3.5). /// Includes `Pending` (I4): another connection is executing this id right now. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] @@ -573,6 +616,35 @@ pub fn parse_positions(line: &str) -> Result { } } +/// Parse an `activity` reply (§3.9). Unlike `context`/`positions` there is NO +/// degradable answer: the op reads only the outcome store — `wallet_locked` +/// is not in its vocabulary (§3.9). We only send `activity` post-auth on a +/// proto-2 session, so `unauthorized`/`protocol_error` (or any other code) +/// mean the channel is not what we negotiated — the same fail-closed class +/// as an unexpected resolve code. +/// +/// # Errors +/// [`ProtocolError::Malformed`] on a wrong shape (including a `"pending"` +/// state — see [`OutcomeState`]); [`ProtocolError::Unexpected`] on `ok:false`. +pub fn parse_activity(line: &str) -> Result, ProtocolError> { + #[derive(Deserialize)] + struct Raw { + ok: bool, + outcomes: Option>, + error: Option, + } + let raw: Raw = parse_line(line)?; + if raw.ok { + raw.outcomes.ok_or_else(|| { + ProtocolError::Malformed("ok activity without an outcomes array".to_owned()) + }) + } else { + Err(ProtocolError::Unexpected(raw.error.unwrap_or_else(|| { + "activity without ok or error".to_owned() + }))) + } +} + /// The fields an `approve` / `deny` reply may carry. `state` means the outcome /// (`executed`/`failed`/`denied`) on an `ok` reply, or the `already_resolved` /// state on an error reply. @@ -1165,4 +1237,86 @@ mod tests { r#"{"op":"positions"}"# ); } + + // ── activity (§3.9, Stage 7) — mirrors the positions parse suite ── + + #[test] + fn parse_activity_carries_all_four_states_verbatim() { + let line = r#"{"ok":true,"outcomes":[ + {"id":"e1","state":"executed","tx_hash":"0xfeed","age_secs":42}, + {"id":"d1","state":"denied","age_secs":120}, + {"id":"x1","state":"expired","age_secs":1800}, + {"id":"f1","state":"failed","reason":"broadcast failed","age_secs":3599}]}"# + .replace('\n', ""); + let outcomes = parse_activity(&line).unwrap(); + assert_eq!(outcomes.len(), 4); + assert_eq!(outcomes[0].id, "e1"); + assert_eq!(outcomes[0].state, OutcomeState::Executed); + assert_eq!(outcomes[0].tx_hash.as_deref(), Some("0xfeed")); + assert_eq!(outcomes[0].reason, None); + assert_eq!(outcomes[0].age_secs, 42); + assert_eq!(outcomes[1].state, OutcomeState::Denied); + assert_eq!( + ( + outcomes[1].tx_hash.as_deref(), + outcomes[1].reason.as_deref() + ), + (None, None), + "absent wire fields read as None, never a fabricated value" + ); + assert_eq!(outcomes[2].state, OutcomeState::Expired); + assert_eq!(outcomes[3].state, OutcomeState::Failed); + assert_eq!(outcomes[3].reason.as_deref(), Some("broadcast failed")); + assert_eq!(outcomes[3].tx_hash, None); + } + + #[test] + fn parse_activity_accepts_an_empty_history() { + let line = r#"{"ok":true,"outcomes":[]}"#; + assert_eq!(parse_activity(line).unwrap(), vec![]); + } + + #[test] + fn parse_activity_never_accepts_a_pending_state() { + // §3.9: only terminal words; "pending" is not in the vocabulary and + // must fail the parse (default-deny), not slip through as data. + let line = r#"{"ok":true,"outcomes":[{"id":"p1","state":"pending","age_secs":1}]}"#; + assert!(matches!( + parse_activity(line), + Err(ProtocolError::Malformed(_)) + )); + } + + #[test] + fn parse_activity_unexpected_errors_fail_closed() { + // §3.9 has no degradable answer (no wallet_locked): any error code + // means the channel is not what we negotiated. + for code in ["unauthorized", "protocol_error", "wallet_locked"] { + let line = format!(r#"{{"ok":false,"error":"{code}"}}"#); + assert!(matches!( + parse_activity(&line), + Err(ProtocolError::Unexpected(_)) + )); + } + } + + #[test] + fn parse_activity_ok_without_array_is_malformed() { + assert!(matches!( + parse_activity(r#"{"ok":true}"#), + Err(ProtocolError::Malformed(_)) + )); + assert!(matches!( + parse_activity("not json"), + Err(ProtocolError::Malformed(_)) + )); + } + + #[test] + fn activity_request_encodes_the_documented_op() { + assert_eq!( + encode_request(&Request::Activity).unwrap(), + r#"{"op":"activity"}"# + ); + } } diff --git a/src/transport.rs b/src/transport.rs index ac2935d..37a325c 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -24,9 +24,9 @@ use std::thread::JoinHandle; use zeroize::Zeroizing; use crate::protocol::{ - self, AuthOutcome, ContextOutcome, GetOutcome, HelloOutcome, PROTO_VERSION, PositionsOutcome, - ResolveOutcome, Summary, encode_request, parse_approve, parse_auth, parse_context, parse_deny, - parse_get, parse_hello, parse_list, parse_positions, + self, AuthOutcome, ContextOutcome, GetOutcome, HelloOutcome, OutcomeEntry, PROTO_VERSION, + PositionsOutcome, ResolveOutcome, Summary, encode_request, parse_activity, parse_approve, + parse_auth, parse_context, parse_deny, parse_get, parse_hello, parse_list, parse_positions, }; /// Informational client id sent in `hello` (the server does not validate it). @@ -56,6 +56,8 @@ pub enum Request { Context, /// Ask for the wallet's own DeFi positions (proto 2+, auth-gated). Positions, + /// Ask for the recent terminal outcomes (proto 2+, auth-gated, §3.9). + Activity, } /// A message from the worker to the MVU layer. @@ -78,6 +80,10 @@ pub enum Reply { Context(ContextOutcome), /// Result of a `positions` (proto 2+). Positions(PositionsOutcome), + /// Result of an `activity` (proto 2+): the retained window, newest first, + /// server-capped at 100 (§3.9). No degradable variant — any error code + /// fails the parse and ends the worker (see `parse_activity`). + Activity(Vec), /// The connection is finished and unusable — the worker has exited. Fatal(TransportError), } @@ -294,6 +300,11 @@ fn serve_one( .map_err(|e| TransportError::Protocol(e.to_string()))?; exchange(writer, reader, &line)? } + Request::Activity => { + let line = encode_request(&protocol::Request::Activity) + .map_err(|e| TransportError::Protocol(e.to_string()))?; + exchange(writer, reader, &line)? + } }; let parsed = match req { Request::Auth(_) => parse_auth(&resp).map(Reply::Auth), @@ -303,6 +314,7 @@ fn serve_one( Request::Deny(_) => parse_deny(&resp).map(Reply::Resolve), Request::Context => parse_context(&resp).map(Reply::Context), Request::Positions => parse_positions(&resp).map(Reply::Positions), + Request::Activity => parse_activity(&resp).map(Reply::Activity), }; parsed.map_err(|e| TransportError::Protocol(e.to_string())) } diff --git a/src/ui.rs b/src/ui.rs index 8e13a3a..3773e94 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,9 +10,10 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap}; use crate::app::{ - AuthError, Confirm, DecisionKind, Model, Notice, Phase, Positions, ResolveError, View, + AuthError, Confirm, DecisionKind, HistoryEntry, Model, Notice, Phase, Positions, ResolveError, + View, }; -use crate::protocol::{Card, Summary}; +use crate::protocol::{Card, OutcomeState, Summary}; use crate::{format, qr, theme}; /// Render the whole screen for the current model. @@ -43,6 +44,7 @@ pub fn render(frame: &mut Frame, model: &Model, now_unix: u64) { ), View::Receive => render_receive(frame, items.len(), model.wallet_address()), View::Dashboard => render_dashboard(frame, items.len(), model), + View::Activity => render_activity(frame, items.len(), model, now_unix), }, Phase::Fatal(err) => render_centered(frame, &err.to_string()), } @@ -73,6 +75,8 @@ fn tab_line(active: View, pending: usize) -> Line<'static> { tab(format!(" Queue·{pending} [a] "), active == View::Queue), Span::raw(" "), tab(" Receive [r] ".to_owned(), active == View::Receive), + Span::raw(" "), + tab(" Activity [h] ".to_owned(), active == View::Activity), ]) } @@ -450,6 +454,120 @@ fn render_receive(frame: &mut Frame, pending: usize, wallet: Option<&str>) { frame.render_widget(Paragraph::new(lines).block(block), chunks[1]); } +/// The Activity view: terminal outcomes newest-first — the local log merged +/// with the server's retained window (Stage 7). Pure display: nothing here +/// signs or gates. Addresses are SHORTENED (`format::short_addr`) — the one +/// display list ТЗ §4.1 allows it on; signing surfaces render in full. Rows +/// that do not fit end with an explicit "+N more" marker, never a silent +/// clip (the Stage-5 budget lesson: exact-fit vs marker split, one budget). +fn render_activity(frame: &mut Frame, pending: usize, model: &Model, now_unix: u64) { + let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(frame.area()); + frame.render_widget(Paragraph::new(tab_line(View::Activity, pending)), chunks[0]); + + let block = themed_block(" Activity "); + let inner = block.inner(chunks[1]); + let height = usize::from(inner.height); + + let filter = model.history_filter(); + let mut lines: Vec> = vec![Line::from(Span::styled( + format!("filter: {} [f cycles]", filter.label()), + theme::label_style(), + ))]; + let note_lines = usize::from(model.history_note().is_some()); + + let rows: Vec<&HistoryEntry> = model + .history() + .iter() + .filter(|e| filter.admits(e.state)) + .collect(); + if rows.is_empty() { + let text = if model.history().is_empty() { + "no activity yet".to_owned() + } else { + format!("no {} outcomes under this filter", filter.label()) + }; + lines.push(Line::from(Span::styled(text, theme::label_style()))); + } else { + // The budget is computed ONCE from what is already reserved (header + + // footer note); the loop below never re-subtracts it. Exact fit shows + // everything; one over shows budget−1 rows + an honest marker. + let budget = height.saturating_sub(lines.len() + note_lines); + let total = rows.len(); + if total <= budget { + lines.extend(rows.iter().map(|e| activity_line(e, now_unix))); + } else if let Some(kept) = budget.checked_sub(1) { + lines.extend(rows[..kept].iter().map(|e| activity_line(e, now_unix))); + lines.push(Line::from(Span::styled( + format!("+{} more (history lives in the local log)", total - kept), + theme::label_style(), + ))); + } + // budget == 0: the block has no visible rows at all — nothing is + // being clipped in silence, the whole list is out of view. + } + if let Some(note) = model.history_note() { + lines.push(Line::from(Span::styled( + note.to_owned(), + theme::high_risk_style(), + ))); + } + frame.render_widget(Paragraph::new(lines).block(block), chunks[1]); +} + +/// One outcome row. A rich record carries the card data written at decision +/// time; a server-only record renders an honest "(details not recorded)" +/// instead of fabricated columns. +fn activity_line(entry: &HistoryEntry, now_unix: u64) -> Line<'static> { + let (word, color) = match entry.state { + OutcomeState::Executed => ("approved", theme::approve()), + OutcomeState::Denied => ("rejected", theme::reject()), + OutcomeState::Expired => ("expired", theme::high_risk()), + OutcomeState::Failed => ("failed", theme::reject()), + }; + let mut spans = vec![ + Span::styled( + format!("{:>7} ", age_label(now_unix, entry.unix)), + theme::label_style(), + ), + Span::styled(format!("{word:<8}"), Style::new().fg(color)), + ]; + match (&entry.to, &entry.amount_wei) { + (Some(to), Some(amount)) => spans.push(Span::raw(format!( + " {} → {}", + format::wei_to_eth(amount), + format::short_addr(to) + ))), + _ => spans.push(Span::styled( + " (details not recorded)".to_owned(), + theme::label_style(), + )), + } + if let Some(tx) = &entry.tx_hash { + spans.push(Span::styled( + format!(" tx {}", format::short_addr(tx)), + theme::label_style(), + )); + } + if let Some(reason) = &entry.reason { + spans.push(Span::styled(format!(" {reason}"), theme::label_style())); + } + Line::from(spans) +} + +/// Compact "how long ago" for a history row (whole units, floor). +fn age_label(now_unix: u64, unix: u64) -> String { + let s = now_unix.saturating_sub(unix); + if s < 60 { + format!("{s}s ago") + } else if s < 3600 { + format!("{}m ago", s / 60) + } else if s < 86_400 { + format!("{}h ago", s / 3600) + } else { + format!("{}d ago", s / 86_400) + } +} + /// The Dashboard: per-chain balance (from `context`), DeFi positions (the /// `positions` read-op), and the "waiting for you" count. Pure display — /// nothing here signs or gates; the values render **verbatim** (`extra` are @@ -2169,4 +2287,189 @@ mod tests { "the active (home) tab is the Dashboard" ); } + + // ── Stage 7: the Activity view ── + + fn history(id: &str, unix: u64, state: OutcomeState) -> HistoryEntry { + HistoryEntry { + unix, + id: id.to_owned(), + state, + to: None, + amount_wei: None, + chain_id: None, + tx_hash: None, + reason: None, + } + } + + fn on_activity(entries: Vec) -> Model { + let mut m = Model::new(); + to_watching(&mut m, vec![]); + m.set_history(entries); + m.update(Msg::View(View::Activity)); + m + } + + #[test] + fn a_rich_row_shows_age_outcome_amount_and_a_shortened_address() { + let mut rich = history("r1", NOW - 120, OutcomeState::Executed); + rich.to = Some("0x489Fe09Fbb489Fe09Fbb489Fe09Fbb489F9Fbbbb".to_owned()); + rich.amount_wei = Some("10000000000000000".to_owned()); + rich.tx_hash = Some("0xfeedfeedfeedfeed".to_owned()); + let m = on_activity(vec![rich]); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with( + &rows, + &[ + "2m ago", + "approved", + "0.01 ETH", + "0x489Fe0…bbbb", + "tx 0xfeedfe…feed" + ] + ), + "age, outcome, human amount, SHORTENED address and tx on one row: {rows:?}" + ); + assert!( + !rows.iter().any(|r| r.contains("0x489Fe09Fbb489Fe09Fbb")), + "the Activity list never carries the full address — display, not signing" + ); + } + + #[test] + fn a_server_only_row_admits_it_has_no_details() { + let m = on_activity(vec![history("s1", NOW - 3600, OutcomeState::Expired)]); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["1h ago", "expired", "(details not recorded)"]), + "a poor record renders honestly, no fabricated columns: {rows:?}" + ); + } + + #[test] + fn an_empty_history_and_an_empty_filter_speak_up() { + let mut m = on_activity(vec![]); + let rows = draw_rows(&m, 80, 24); + assert!(has_line_with(&rows, &["no activity yet"])); + assert!( + has_line_with(&rows, &["filter: all", "[f cycles]"]), + "the filter header names the active filter: {rows:?}" + ); + + // One denied entry, filter cycled to `executed`: nothing matches. + m.set_history(vec![history("d1", NOW - 5, OutcomeState::Denied)]); + m.update(Msg::Filter); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["no executed outcomes under this filter"]), + "an empty filtered view says which filter hides the rows: {rows:?}" + ); + } + + #[test] + fn every_outcome_word_carries_its_semantic_color() { + // Gate-2 НИТ: all four states, not just Denied. + use ratatui::backend::TestBackend; + let m = on_activity(vec![ + history("e1", NOW - 2, OutcomeState::Executed), + history("d1", NOW - 5, OutcomeState::Denied), + history("x1", NOW - 9, OutcomeState::Expired), + history("f1", NOW - 13, OutcomeState::Failed), + ]); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| render(f, &m, NOW)).unwrap(); + let buffer = terminal.backend().buffer(); + for (word, color) in [ + ("approved", theme::approve()), + ("rejected", theme::reject()), + ("expired", theme::high_risk()), + ("failed", theme::reject()), + ] { + let mut found = false; + for y in 0..24u16 { + let row: String = (0..80).map(|x| buffer[(x, y)].symbol()).collect(); + if let Some(at) = row.find(word) { + assert_eq!( + buffer[(u16::try_from(at).unwrap(), y)].style().fg, + Some(color), + "{word} reads in its semantic color" + ); + found = true; + } + } + assert!(found, "the {word} row renders"); + } + } + + #[test] + fn overflowing_history_ends_with_an_honest_marker() { + // 80×24: 1 tab row + 2 borders leave 21 inner rows; the filter header + // takes 1 → a 20-row budget. Exactly 20 entries fit without a marker; + // 21 show 19 + "+2 more" (the Stage-5 exact-fit/marker split). + let exact: Vec = (0u64..20) + .map(|i| history(&format!("e{i:02}"), NOW - i, OutcomeState::Denied)) + .collect(); + let m = on_activity(exact); + let rows = draw_rows(&m, 80, 24); + assert!( + !rows.iter().any(|r| r.contains("more")), + "an exact fit needs no marker: {rows:?}" + ); + + let over: Vec = (0u64..21) + .map(|i| history(&format!("e{i:02}"), NOW - i, OutcomeState::Denied)) + .collect(); + let m = on_activity(over); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["+2 more"]), + "one over budget keeps budget−1 rows and says what is hidden: {rows:?}" + ); + } + + #[test] + fn the_tab_bar_carries_the_activity_tab() { + let mut m = Model::new(); + to_watching(&mut m, vec![]); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["Queue·0 [a]", "Receive [r]", "Activity [h]"]), + "all four tabs share the 80-column header row: {rows:?}" + ); + } + + #[test] + fn the_history_note_renders_as_a_footer() { + let mut m = on_activity(vec![]); + m.set_history_note("history is session-only — set RUSTOK_CONSOLE_LOG".to_owned()); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["session-only"]), + "log degradation is visible on the view: {rows:?}" + ); + } + + #[test] + fn the_note_and_the_overflow_marker_share_the_budget() { + // Gate-2 НИТ: the note reserves its row BEFORE the budget is spent — + // 21 inner rows − header − note = 19 → 18 rows + "+3 more", and the + // note still visible under the marker. + let over: Vec = (0u64..21) + .map(|i| history(&format!("e{i:02}"), NOW - i, OutcomeState::Denied)) + .collect(); + let mut m = on_activity(over); + m.set_history_note("history is session-only".to_owned()); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["+3 more"]), + "the note's reserved row shrinks the list budget by one: {rows:?}" + ); + assert!( + has_line_with(&rows, &["session-only"]), + "the note survives an overflowing list: {rows:?}" + ); + } }