From 000d47fb9223d03b5809d3b9e06763bb072198e0 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Tue, 11 Aug 2026 11:57:09 -0400 Subject: [PATCH] feat(tui): scope the activity feed to the current day by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `v` activity feed (#235) showed every cycle the bounded read window held -- 77 of them on the real deployment, going back three weeks. The instruction is that the feed shows TODAY: "what has keel been doing" means today unless asked otherwise, and a fortnight of scrollback is not an answer to it. The scoping lives in the pure layer (`apply_scope`), not in the curses code, and takes its "now" as a parameter so the day boundary is injectable and every test of it is deterministic on whatever day it runs. "Today" is the local CALENDAR day -- derived through `datetime.date` in the same local clock `_stamp` renders timestamps in, so it survives DST (and errs early rather than late in the zones where midnight itself does not exist), and is not a rolling 24 hours, which would put yesterday's 09:00 cycle on screen every morning and drop it every afternoon. `t` inside the overlay cycles today -> 7 days -> all, and the scope resets to today on every open and every close. A widened view answers one question once; it never becomes tomorrow's default. The delicate part is that the deployment runs ONCE A DAY at 09:00, so "today" holds at most one row and none at all before 09:00 -- and a blank panel would be strictly worse than the state-only dashboard this whole feature exists to fix, since a blank panel and a dead agent look identical. So an empty day is never blank. It says, in this order: that keel has not run YET today; the timestamp of the last cycle and how long ago that was; and when the next one is due, inferred from that cycle's own time of day (or that its usual time has passed, which is the case worth acting on). Naming one timestamp is a status line, not a feed -- no historical row is rendered. The read bounds are unchanged (1 MiB / 5000 lines / 200 cycles / 400 events), and their interaction with a day filter is reported rather than glossed: `scope_fully_covered` records whether the window PROVED it reached back past midnight, and both the footer and the empty state say so when it did not, instead of letting an unread morning read as a quiet one. Real deployment log, unchanged on disk, at 11:53 today: scope: today (2026-08-11) · 1 cycle · 76 older hidden · press t to widen ▸ 2026-08-11 09:00:09 paper 0 0 0 0 0 gate rejected: choppy_regime (PAXG-USD) and the same log as it stood at 07:00 this morning, before that cycle ran: keel has not run yet today. Last cycle: 2026-08-10 09:00:01 -- yesterday, 21h 59m ago. Next cycle due today around 09:00 local -- in 2h 00m. Gates: ruff clean, mypy clean, 2664 passed / 1 skipped (from 2607 on main -- 57 new tests, same single expected skip). Co-Authored-By: Claude Opus 5 (1M context) --- keel/commands/activity.py | 429 +++++++++++++++++++++++- keel/commands/tui.py | 92 +++++- tests/commands/test_activity.py | 559 +++++++++++++++++++++++++++++++- tests/commands/test_tui.py | 289 ++++++++++++++++- 4 files changed, 1327 insertions(+), 42 deletions(-) diff --git a/keel/commands/activity.py b/keel/commands/activity.py index 55c4766c..332770d9 100644 --- a/keel/commands/activity.py +++ b/keel/commands/activity.py @@ -50,14 +50,41 @@ are instead gathered into synthetic groups: a contiguous run of uncorrelated events whose neighbours are within `_UNCORRELATED_GAP_SEC` of each other becomes one row, which is what such events actually are -- one failed attempt at something. + +**The default view is TODAY, and that is delicate.** The feed answers "what has keel been doing", +and the honest default answer is "what it has done today" -- an operator opening the dashboard at +lunchtime wants this morning, not a fortnight of scrollback. So `build_activity_feed` scopes to +the local CALENDAR day by default (`scope_start_ts`, midnight-to-now in the same local clock +`_stamp` renders timestamps in -- NOT a rolling 24 hours, which would put "yesterday 09:00" and +"today 09:00" on the same screen every morning and neither on it every afternoon). + +The delicacy is that the real deployment runs ONCE A DAY, at 09:00. "Today" therefore holds at +most one row, and before 09:00 it holds none -- and a blank panel is worse than the dead-looking +dashboard this whole feature exists to fix. Two things follow, and both are load-bearing: + +* `apply_scope` retains the newest cycle that fell OUTSIDE the scope as + `ActivityFeed.last_cycle_before_scope`, so the empty state can say *when keel last ran* and + when the next run is due. That is one status line answering "is it alive", not a history feed -- + the distinction the "today only" requirement actually cares about. +* Scope is a parameter, never a persisted preference. `apply_scope` can widen it to `"7d"` or + `"all"` on demand (the overlay's `t` key), but every fresh open starts at `"today"` again. + +**Today interacts with the bounded read, and the interaction is reported.** The window is a +bounded TAIL, so in principle a day's cycles could sit outside it -- a busy log could push even +this morning past the 1 MiB / 5000-line cap. A feed that filtered such a window to "today" and +came back empty would be asserting something it cannot know. `ActivityFeed.scope_fully_covered` +records whether the window PROVED it reached back past the scope boundary (it did if it saw any +cycle older than that boundary, or if it read the file whole), and `footer_notes` says so out +loud when it did not, rather than letting an unread morning read as a quiet one. """ from __future__ import annotations +import datetime import json import time from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any @@ -121,6 +148,74 @@ _DEFAULT_LOG_PATH = "logs/keel.log" +# -- scope (see the module docstring) ------------------------------------------------------------ + +#: The scopes the feed can be built at, in the order the overlay's `t` key cycles them. `"today"` +#: is first because it is both the default and the state every fresh open returns to. +ACTIVITY_SCOPES: tuple[str, ...] = ("today", "7d", "all") + +#: What `build_activity_feed` scopes to unless told otherwise, and what the overlay reopens at +#: every single time. Deliberately NOT persisted: a widened scope is an answer to one question +#: an operator asked once, not a new default for a dashboard they will next open tomorrow. +DEFAULT_ACTIVITY_SCOPE = "today" + +#: How many local calendar days each bounded scope spans, counting the current one. `"7d"` is +#: therefore today plus the six days before it -- seven DAYS, not seven times 24 hours, for the +#: same reason `"today"` is a calendar day: the deployment's unit of work is one daily cycle, so +#: a boundary that fell mid-morning would split a day's single row off from its own date. +_SCOPE_DAYS: dict[str, int] = {"today": 1, "7d": 7} + + +def normalise_scope(scope: str) -> str: + """Any unrecognised scope collapses to the default rather than raising or filtering to + nothing -- this value can arrive from a caller, and an empty screen is the one outcome this + module is built to never produce.""" + return scope if scope in ACTIVITY_SCOPES else DEFAULT_ACTIVITY_SCOPE + + +def next_activity_scope(scope: str) -> str: + """The scope `t` moves to: `today` -> `7d` -> `all` -> `today`. Cycles rather than toggles so + one key covers all three without a modifier.""" + try: + index = ACTIVITY_SCOPES.index(scope) + except ValueError: + return DEFAULT_ACTIVITY_SCOPE + return ACTIVITY_SCOPES[(index + 1) % len(ACTIVITY_SCOPES)] + + +def scope_start_ts(scope: str, now_ts: float) -> float | None: + """The epoch second a scope begins at -- LOCAL midnight of the first calendar day it covers -- + or `None` for `"all"`, which has no lower bound. + + `now_ts` is a parameter, not a `time.time()` call buried in here, so the boundary is + injectable and every test of it is deterministic on whatever day it happens to run. + + **Local, and derived from the same clock the rows render in.** `datetime.fromtimestamp` with + no tzinfo yields naive LOCAL time and `.timestamp()` converts it back through the local zone, + so the boundary lands on the same civil midnight `_stamp`'s `time.localtime` would print -- + which is the only way "today" can mean what the operator reading the timestamps thinks it + means. + + **DST is handled, and handled in the safe direction.** Going through `date` -> naive midnight + -> `.timestamp()` uses the offset in force on THAT day, so a 23- or 25-hour day still starts + where the civil day starts (a fixed `now_ts - 86400` would drift an hour twice a year). In + the few zones whose transition happens AT midnight, so that 00:00 does not exist, the naive + conversion resolves to the instant an hour before the nominal wall reading -- i.e. slightly + EARLIER than the true day boundary. That direction is deliberate: a boundary that errs early + can only ever include a cycle that belongs to today, never exclude one. + + Never raises: a `now_ts` outside the platform's `time_t` returns `None`, which degrades to an + unfiltered feed rather than an empty one.""" + days = _SCOPE_DAYS.get(scope) + if days is None: + return None + try: + day = datetime.datetime.fromtimestamp(now_ts).date() - datetime.timedelta(days=days - 1) + return datetime.datetime.combine(day, datetime.time.min).timestamp() + except (OSError, OverflowError, ValueError): + return None + + # -- the parsed model ---------------------------------------------------------------------------- @@ -209,7 +304,13 @@ class ActivityFeed: `lines_skipped` counts lines that were read but could not be used -- the partial JSON a crash mid-write leaves behind, a line that is valid JSON but not an object, a record with no usable timestamp. It is surfaced in the overlay rather than swallowed: silently discarding input is - how a feed comes to under-report reality while looking healthy.""" + how a feed comes to under-report reality while looking healthy. + + The `scope_*` fields describe the day-scoping `apply_scope` applied (see the module + docstring). They default to an UNSCOPED feed -- `scope="all"`, nothing hidden, coverage + trivially complete -- so that `feed_from_lines` stays exactly the "parse everything in the + window" function it was, and scoping is a separate, separately-testable pass over its + result.""" status: str source: str @@ -220,6 +321,31 @@ class ActivityFeed: window_truncated: bool = False cycles_dropped: int = 0 + #: Which scope produced `cycles` -- one of `ACTIVITY_SCOPES`. + scope: str = "all" + + #: Local midnight the scope begins at, or `None` for `"all"` (and for a `now_ts` so broken + #: that no boundary could be computed, which degrades to showing everything). + scope_start_ts: float | None = None + + #: The "now" the scope was computed against. Carried so the empty state can say how long ago + #: the last cycle was without re-reading a clock that has moved on since the feed was built. + now_ts: float | None = None + + #: Cycles the window held that fall BEFORE the scope -- hidden, not lost. Nonzero is the + #: normal state of a "today" view on a deployment with any history at all. + cycles_out_of_scope: int = 0 + + #: The newest cycle before the scope boundary, kept for ONE purpose: so an empty "today" can + #: name when keel last ran. That is a status line, not a history feed -- it is what turns a + #: blank panel into "keel has not run yet today; last cycle yesterday 09:00". + last_cycle_before_scope: ActivityCycle | None = None + + #: Whether the bounded read PROVED it reached back past the scope boundary. False means the + #: window may not cover the whole scope, so an empty or short feed cannot be read as "the + #: scope was quiet" -- `footer_notes` and the empty state both say so when it is False. + scope_fully_covered: bool = True + @dataclass(frozen=True) class LogWindow: @@ -688,12 +814,80 @@ def feed_from_lines( ) +def apply_scope( + feed: ActivityFeed, scope: str = DEFAULT_ACTIVITY_SCOPE, *, now_ts: float | None = None +) -> ActivityFeed: + """Narrow an already-built feed to a scope. PURE, and a SEPARATE pass over `feed_from_lines`'s + result rather than a parameter threaded through it -- which keeps "parse the window" and + "decide which days to show" independently testable, and keeps `feed_from_lines` the unscoped + function every existing caller and test already relies on. + + `now_ts` is injected (defaulting to `time.time()` only at this one seam) so every test of the + day boundary is deterministic regardless of the day it runs on. + + Three things are recorded besides the filtered `cycles`, and each exists because dropping it + would let the overlay assert something it cannot know: + + * `cycles_out_of_scope` -- how much was hidden, so the header can say so instead of + implying the window is the whole log. + * `last_cycle_before_scope` -- the NEWEST cycle just outside the boundary, the one fact that + turns an empty "today" into an answer to "is keel alive". + * `scope_fully_covered` -- True if the window contained anything older than the boundary (so + the boundary itself was inside the window and nothing before it can be missing), or if the + read was not truncated at all. False means the bounded tail begins somewhere inside the + scope, and an empty result there means "not seen", not "did not happen". + + Filtering is on `started_ts`, the timestamp the row itself renders, and the boundary is + INCLUSIVE (`>= start`) so a cycle at exactly local midnight belongs to the day beginning + then -- the same convention every calendar uses, and the only one under which two adjacent + days cannot both claim it or both disown it. + + There is deliberately NO upper bound at `now_ts`. "Today" is the calendar day, and in live + use `now_ts` is the current instant, so midnight-to-now and midnight-to-midnight contain + exactly the same records -- the only thing an upper bound could ever exclude is a record + stamped in the future, i.e. one written by a process whose clock is ahead. Hiding that would + be the wrong call in this module of all modules: an operator is far better served by seeing + the anomalous row, with its odd timestamp on display, than by a panel that quietly shrinks + and offers no hint why.""" + scope = normalise_scope(scope) + if now_ts is None: + now_ts = time.time() + start = scope_start_ts(scope, now_ts) + if start is None: + return replace( + feed, + scope=scope, + scope_start_ts=None, + now_ts=now_ts, + cycles_out_of_scope=0, + last_cycle_before_scope=None, + scope_fully_covered=True, + ) + + in_scope = tuple(c for c in feed.cycles if c.started_ts >= start) + # `feed.cycles` is newest-first, so the first cycle below the boundary is the most recent one + # outside the scope -- exactly the "when did it last run" the empty state needs. + before = [c for c in feed.cycles if c.started_ts < start] + return replace( + feed, + cycles=in_scope, + scope=scope, + scope_start_ts=start, + now_ts=now_ts, + cycles_out_of_scope=len(before), + last_cycle_before_scope=before[0] if before else None, + scope_fully_covered=bool(before) or not feed.window_truncated, + ) + + def build_activity_feed( config: Any, *, max_bytes: int = _MAX_BYTES, max_lines: int = _MAX_LINES, max_cycles: int = _MAX_CYCLES, + scope: str = DEFAULT_ACTIVITY_SCOPE, + now_ts: float | None = None, ) -> ActivityFeed: """Resolve the log path from config, read its bounded tail, and build the feed. The thin I/O seam over `feed_from_lines`. @@ -702,21 +896,35 @@ def build_activity_feed( live loop's repaint path, where an escaping exception would take the dashboard down for a problem in a file this process does not own. `read_log_window` already turns every `OSError` into a status; this catches whatever a config object shaped differently than - expected could still throw, and turns it into the same kind of readable sentence.""" + expected could still throw, and turns it into the same kind of readable sentence. + + `scope` defaults to TODAY -- the local calendar day -- because that is what "what has keel + been doing" means to someone opening the dashboard now. `now_ts` is injectable so the day + boundary is a parameter of this call rather than a hidden clock read, which is what makes the + whole scoping layer deterministic under test. A non-ok window is scope-stamped too, so the + overlay's header reads the same whether the log parsed or not.""" + scope = normalise_scope(scope) try: + if now_ts is None: + now_ts = time.time() path = resolve_log_path(config) window = read_log_window(path, max_bytes=max_bytes, max_lines=max_lines) if window.status != "ok": - return ActivityFeed(status=window.status, source=str(path), detail=window.detail) - return feed_from_lines( - window.lines, - source=str(path), - truncated=window.truncated, - max_cycles=max_cycles, - ) + base = ActivityFeed(status=window.status, source=str(path), detail=window.detail) + else: + base = feed_from_lines( + window.lines, + source=str(path), + truncated=window.truncated, + max_cycles=max_cycles, + ) + return apply_scope(base, scope, now_ts=now_ts) except Exception as exc: return ActivityFeed( - status="unreadable", source="", detail=f"{type(exc).__name__}: {str(exc)[:160]}" + status="unreadable", + source="", + detail=f"{type(exc).__name__}: {str(exc)[:160]}", + scope=scope, ) @@ -1010,12 +1218,202 @@ def describe_status(feed: ActivityFeed) -> list[str]: ] +# -- scope rendering, and the empty state that must never be blank ------------------------------- + + +#: What the feed says when it is scoped to everything and STILL has nothing -- a readable log +#: holding no groupable event at all. Distinct from `describe_status`'s cases, which are about the +#: FILE rather than its contents. +_NO_CYCLES_AT_ALL = "No cycles in the window -- the log was read, but held no grouped events." + + +def scope_label(scope: str, start_ts: float | None) -> str: + """The scope named the way an operator would say it, with the date it actually begins at. + Naming the DATE matters: "today" is ambiguous next to a row stamped `2026-08-11 09:00` + unless the header says which day "today" is.""" + if scope == "today": + return f"today ({_safe_strftime('%Y-%m-%d', start_ts, 10)})" if start_ts else "today" + if scope == "7d": + return ( + f"last 7 days (from {_safe_strftime('%Y-%m-%d', start_ts, 10)})" + if start_ts + else "last 7 days" + ) + return "all history in the window" + + +def _scope_noun(scope: str) -> str: + """The scope as it reads mid-sentence -- "not run yet TODAY", "cycles from THE LAST 7 DAYS".""" + return "today" if scope == "today" else "the last 7 days" + + +def scope_headline(feed: ActivityFeed) -> str: + """The one line under the overlay's title: what is being shown, how much of it, how much is + being withheld by the scope, and the key that widens it. All four are needed together -- + "1 cycle" alone would look like a truncated log rather than a deliberate day filter.""" + shown = len(feed.cycles) + parts = [ + f"scope: {scope_label(feed.scope, feed.scope_start_ts)}", + f"{shown} cycle" if shown == 1 else f"{shown} cycles", + ] + if feed.cycles_out_of_scope: + parts.append(f"{feed.cycles_out_of_scope} older hidden") + parts.append("press t to widen") + return " · ".join(parts) + + +def _elapsed_phrase(seconds: float) -> str: + """A duration at the precision an operator actually reads: `47m`, `2h 14m`, `3d 4h`. Never + negative (a clock that stepped backwards reads "less than a minute", not "-3h").""" + minutes = int(max(0.0, seconds) // 60) + if minutes < 1: + return "less than a minute" + if minutes < 60: + return f"{minutes}m" + hours, minutes = divmod(minutes, 60) + if hours < 48: + return f"{hours}h {minutes:02d}m" + days, hours = divmod(hours, 24) + return f"{days}d {hours}h" + + +def _day_phrase(then_ts: float, now_ts: float) -> str: + """`yesterday` / `3 days ago` / `earlier today`, by LOCAL CALENDAR DAY rather than by elapsed + hours -- 23 hours ago can be yesterday or the day before, and the calendar answer is the one + that matches the date printed beside it.""" + try: + delta = ( + datetime.datetime.fromtimestamp(now_ts).date() + - datetime.datetime.fromtimestamp(then_ts).date() + ).days + except (OSError, OverflowError, ValueError): + return "at an unreadable time" + if delta <= 0: + return "earlier today" + if delta == 1: + return "yesterday" + return f"{delta} days ago" + + +def _next_due_lines(last_ts: float, now_ts: float) -> list[str]: + """When the next cycle is expected, inferred from the TIME OF DAY the last one started. + + This is the line that turns "nothing here" into "nothing here YET". The deployment runs once + a day on a fixed schedule, so the previous cycle's wall-clock time is a good estimate of the + next one's, and it is drawn from the log itself rather than from a schedule setting this + module would otherwise have to be taught about (and could then disagree with). + + The two cases are genuinely different news, so they read differently: still to come is + reassurance, already overdue is a prompt to go and look.""" + try: + last = datetime.datetime.fromtimestamp(last_ts) + today = datetime.datetime.fromtimestamp(now_ts).date() + due = datetime.datetime.combine( + today, datetime.time(last.hour, last.minute) + ).timestamp() + except (OSError, OverflowError, ValueError): + return [] + clock = f"{last.hour:02d}:{last.minute:02d}" + if due > now_ts: + return [f"Next cycle due today around {clock} local -- in {_elapsed_phrase(due - now_ts)}."] + return [ + f"Its usual start time today ({clock} local) passed {_elapsed_phrase(now_ts - due)} ago.", + "If no row appears here shortly, check that the agent's schedule is still running.", + ] + + +def _coverage_caveat(scope: str) -> list[str]: + """Said whenever the bounded window could not prove it reached back to the scope boundary. + Without it, "no cycles today" would be claiming the day was quiet when the truth is only that + the read did not go back far enough -- the one way this panel could actively mislead.""" + noun = _scope_noun(scope) + return [ + f"CAVEAT: the bounded read (newest {_MAX_BYTES // 1024} KiB / {_MAX_LINES} lines) begins", + f"inside {noun}, so cycles from {noun} may exist in the log but sit outside what was", + "read. This panel cannot show that nothing happened -- only that it saw nothing.", + ] + + +def describe_empty_scope(feed: ActivityFeed) -> list[str]: + """The lines shown when the log read fine but the SCOPE holds no cycle -- the single most + important thing in the day-scoping change, and the reason it is safe at all. + + On a deployment that runs one cycle a day at 09:00, a "today" view is empty every morning + before 09:00. A blank panel there would be strictly worse than the state dashboard this whole + feature exists to fix, because a blank panel and a dead agent look identical. So this always + answers "is keel alive" first, and it answers it with a FACT -- the timestamp of the last + cycle the window saw -- rather than with reassurance. + + Naming that one timestamp is not a breach of "today only": it is a status line, not a feed. + No historical row is rendered, nothing scrolls, and nothing about what happened on that day + is shown beyond when it began.""" + scope = feed.scope + now_ts = feed.now_ts if feed.now_ts is not None else time.time() + + if scope not in _SCOPE_DAYS or feed.scope_start_ts is None: + return [ + _NO_CYCLES_AT_ALL, + "", + "Every record in the window was either unusable or carried no event that could be", + "grouped into a cycle. The file was read -- there is simply nothing in it to show.", + ] + + last = feed.last_cycle_before_scope + lines: list[str] = [] + + if last is None: + lines.append(f"keel has not run {_scope_noun(scope)}, and this window holds no earlier") + lines.append("cycle either.") + lines.append("") + lines.append("The engine log was read successfully -- it just contains no cycle at all.") + lines.append("That is what a brand-new deployment looks like, and also what one looks") + lines.append("like when `logging.verbose: false` (the default) keeps everything except") + lines.append("errors out of the log.") + else: + lines.append( + "keel has not run yet today." + if scope == "today" + else "keel has not run in the last 7 days." + ) + lines.append( + f"Last cycle: {_stamp(last.started_ts)} -- {_day_phrase(last.started_ts, now_ts)}, " + f"{_elapsed_phrase(now_ts - last.started_ts)} ago." + ) + if scope == "today": + lines.extend(_next_due_lines(last.started_ts, now_ts)) + lines.append("") + lines.append( + f"That one line is all the history this panel shows: it is scoped to " + f"{_scope_noun(scope)}." + ) + + lines.append("Press t to widen the scope: today -> 7 days -> all history in the window.") + + if not feed.scope_fully_covered: + lines.append("") + lines.extend(_coverage_caveat(scope)) + return lines + + def footer_notes(feed: ActivityFeed) -> list[str]: """The honest small print under a rendered feed: what the bounded read did NOT show, and what it could not parse. Reported rather than swallowed -- a feed that quietly under-reports while looking complete is worse than one that admits its window.""" notes: list[str] = [] notes.append(f"source: {feed.source} ({feed.lines_read} records in window)") + if feed.cycles_out_of_scope: + notes.append( + f"scope {scope_label(feed.scope, feed.scope_start_ts)}: " + f"{feed.cycles_out_of_scope} older cycle(s) in the window are hidden -- " + "press t to widen" + ) + if feed.scope in _SCOPE_DAYS and not feed.scope_fully_covered: + noun = _scope_noun(feed.scope) + notes.append( + f"COVERAGE UNPROVEN: the bounded read begins inside {noun}, so earlier cycles from " + f"{noun} may exist in the log but outside what was read -- this is NOT evidence that " + f"{noun} was quiet" + ) if feed.window_truncated: notes.append( f"window BOUNDED: newest {_MAX_BYTES // 1024} KiB / {_MAX_LINES} lines / " @@ -1032,22 +1430,31 @@ def footer_notes(feed: ActivityFeed) -> list[str]: __all__ = [ "ACTIVITY_HEADER", + "ACTIVITY_SCOPES", + "DEFAULT_ACTIVITY_SCOPE", "ActivityCycle", "ActivityEvent", "ActivityFeed", "LogWindow", + "apply_scope", "build_activity_feed", "cycle_style", + "describe_empty_scope", "describe_status", "event_style", "feed_from_lines", "footer_notes", "group_cycles", + "next_activity_scope", + "normalise_scope", "parse_events", "read_log_window", "render_cycle_row", "render_event_detail", "render_event_row", "resolve_log_path", + "scope_headline", + "scope_label", + "scope_start_ts", "summarise_cycle", ] diff --git a/keel/commands/tui.py b/keel/commands/tui.py index c47c5de8..9ed0688a 100644 --- a/keel/commands/tui.py +++ b/keel/commands/tui.py @@ -81,6 +81,17 @@ `activity.py` -- because the log grows without limit and this overlay rebuilds every poll. A dashboard whose responsiveness degrades with how long the deployment has been running would be a worse bug than the one this feature fixes. + +v5 SCOPES that feed to the current local calendar day by default, with `t` inside the overlay +cycling `today` -> `7 days` -> `all`. "What has keel been doing" means today unless asked +otherwise, and a fortnight of scrollback is not an answer to it. Two consequences are handled in +`activity.py` rather than here, and both are the point of the change rather than trimming around +it: the scope is a parameter that RESETS to `today` on every open (a widened view answers one +question once; it does not become tomorrow's default), and an empty "today" -- the normal state +of a once-a-day deployment every morning before 09:00 -- renders `describe_empty_scope`, which +names when keel last ran and when the next cycle is due. A blank panel there would be worse than +the dead-looking state dashboard the whole feature exists to fix, since a blank panel and a dead +agent look exactly alike. """ from __future__ import annotations @@ -97,14 +108,18 @@ from keel.commands._common import DISCLAIMER, _load_cfg, _open_repo from keel.commands.activity import ( ACTIVITY_HEADER, + DEFAULT_ACTIVITY_SCOPE, ActivityFeed, build_activity_feed, cycle_style, + describe_empty_scope, describe_status, event_style, footer_notes, + next_activity_scope, render_cycle_row, render_event_row, + scope_headline, ) from keel.commands.admission import ( DiscoverReport, @@ -458,6 +473,7 @@ def _note(text: str) -> None: _note(" armed, not run, on open -- see 'Discover overlay' below") _row(" v open the activity feed (what keel has been DOING, cycle by cycle)") _note(" reads the engine log, offline -- see 'Activity overlay' below") + _note(" opens scoped to TODAY; press t inside it to widen") lines.append(_blank()) _row("Live balance") _note(" 'live account' shows the REAL account's spendable quote balance (e.g. USDC),") @@ -514,6 +530,16 @@ def _note(text: str) -> None: _note(" declined every setup shows the same zeroes as one that died on day one. This shows") _note(" the narrative instead. A QUIET cycle still gets a row: the run of quiet cycles IS") _note(" the answer to 'is it alive'.") + _note(" SCOPED TO TODAY by default -- the local calendar day, midnight to now, in the same") + _note(" clock the rows are stamped in. 'What has keel been doing' means today unless you") + _note(" ask otherwise; t cycles the scope today -> 7 days -> all, and the scope goes back") + _note(" to today every time the overlay is reopened (a widened view is never remembered).") + _note(" When today holds no cycle yet -- the normal state of a once-a-day deployment every") + _note(" morning before its run -- the panel is NOT blank: it says keel has not run yet") + _note(" today, names when the last cycle was and when the next one is due, and tells you") + _note(" which key widens the window. A quiet cycle that DID run is still a row.") + _note(" If the bounded read cannot prove it reached back to midnight, the footer says so,") + _note(" rather than letting an unread morning read as a quiet one.") _note(" Each row: local time, mode, and sig/blk/ent/exi/err (signals, blocked, entered,") _note(" exited, errors), then what was notable -- a rail veto and its rule, the gate that") _note(" rejected a setup, the reason an entry was not placed. Colour follows the same") @@ -533,8 +559,9 @@ def _note(text: str) -> None: _row(" up / k select the previous (newer) cycle") _row(" down / j select the next (older) cycle") _row(" Enter / Space expand or collapse the selected cycle's events") + _row(" t cycle the scope: today -> last 7 days -> all history in the window") _row(" PgUp / PgDn / Home / End move the selection by a page, or to either end") - _row(" q / Esc / v close activity, back to the dashboard") + _row(" q / Esc / v close activity, back to the dashboard (scope resets to today)") lines.append(_blank()) _row("Safety notes") _note( @@ -796,13 +823,6 @@ def build_discover_overlay( return lines -#: The lines the activity overlay renders when the log parsed fine but held no cycle at all -- -#: distinct from `describe_status`'s cases, which are about the FILE. A readable log with zero -#: cycles in the window is a real, if unusual, state (a log holding only uncorrelated errors from -#: a build predating `cycle_id`, say), and it must say so rather than render an empty screen. -_ACTIVITY_NO_CYCLES = "No cycles in the window -- the log was read, but held no grouped events." - - def _activity_lines( feed: ActivityFeed, *, cursor: int = 0, expanded: frozenset[str] = frozenset() ) -> tuple[list[ScreenLine], int]: @@ -823,7 +843,7 @@ def _activity_lines( Never raises, and never returns an empty body: a broken log renders `describe_status`'s explanation, which is the whole point (a blank overlay would look exactly like the dead dashboard this feature exists to disprove).""" - lines: list[ScreenLine] = [ScreenLine("keel tui -- activity", "heading"), _blank()] + lines: list[ScreenLine] = [ScreenLine("keel tui -- activity", "heading")] # Clamped here as well as in `run_live`, which already clamps it every poll against a feed # that can shrink underneath it. Belt and braces for the same reason `_visible_slice` clamps # its own offset: an out-of-range `cursor` would otherwise mark no row as selected while @@ -832,17 +852,31 @@ def _activity_lines( cursor = max(0, min(cursor, max(0, len(feed.cycles) - 1))) if feed.status != "ok": + # No scope line here on purpose: when the FILE could not be read, "scope: today" would + # invite an operator to press `t`, and widening a window over a log that does not exist + # changes nothing. `describe_status` owns this screen. + lines.append(_blank()) for text in describe_status(feed): lines.append(ScreenLine(text, "warn") if text else _blank()) lines.append(_blank()) lines.append(ScreenLine("Press v or Esc to return to the dashboard.", "muted")) return lines, 0 - lines.append(ScreenLine(ACTIVITY_HEADER, "heading")) + # WHAT is being shown, directly under the title and before what happened in it. Without this + # line a one-row "today" view is indistinguishable from a log that only had one row in it, + # and the `t` key that would settle the question is invisible. + lines.append(ScreenLine(scope_headline(feed), "normal")) + lines.append(_blank()) + + # The column header belongs over columns. When the scope holds no cycle there are none, and + # `describe_empty_scope`'s prose sits directly under the scope line instead. + if feed.cycles: + lines.append(ScreenLine(ACTIVITY_HEADER, "heading")) cursor_line = len(lines) if not feed.cycles: - lines.append(ScreenLine(_ACTIVITY_NO_CYCLES, "warn")) + for text in describe_empty_scope(feed): + lines.append(ScreenLine(text, "warn") if text else _blank()) for index, cycle in enumerate(feed.cycles): is_open = cycle.key in expanded selected = index == cursor @@ -872,8 +906,7 @@ def _activity_lines( lines.append(_blank()) lines.append( ScreenLine( - "up/k down/j move · Enter/Space expand or collapse · PgUp/PgDn/Home/End · " - "q/Esc/v close", + "up/k down/j · Enter/Space expand · t scope · PgUp/PgDn/Home/End · q/Esc/v close", "muted", ) ) @@ -1306,7 +1339,11 @@ def run_live(open_state: OpenState, now_fn: NowFn, interval: float) -> None: toast, never a crash. help/insights/screen/propose all scroll (`up`/`k`, `down`/`j`, `PgUp`/`PgDn`, `Home`/`End`) and close back to normal on `q`/`Esc`/. `activity` binds the same keys to moving its selected row, and adds Enter/Space to expand or - collapse that row's cycle into its individual events. + collapse that row's cycle into its individual events, plus `t` to cycle the day scope + (`today` -> `7d` -> `all`). `t` was free: `q Q h ? i r a f s p d v` are the dashboard's keys, + `k`/`j`/Enter/Space the in-overlay ones, and nothing bound `t` anywhere. The scope is reset to + `today` both when `v` opens the overlay and when any close key leaves it, so it can never + become sticky across visits. `discover` is different on purpose, and is the whole reason this docstring calls out the overlays individually rather than treating them identically: it needs the network, and that @@ -1358,6 +1395,10 @@ def _loop(stdscr: Any) -> None: discover_offset = 0 activity_offset = 0 activity_cursor = 0 + # ALWAYS reset to `today` on open (below), never carried across one -- see this module's + # docstring. A widened scope answers a question the operator asked once; making it sticky + # would quietly turn "what is keel doing" back into "here is a fortnight of scrollback". + activity_scope = DEFAULT_ACTIVITY_SCOPE # Keyed by `ActivityCycle.key`, NOT by row index: the feed is rebuilt from the log every # poll, so a new cycle appearing at the top would silently shift every index down one and # expand the wrong row. The key is stable across rebuilds, so an expanded cycle stays @@ -1491,7 +1532,13 @@ def _loop(stdscr: Any) -> None: # writer -- and turns it into the same kind of readable feed rather than a crash. try: _activity_repo, activity_config = open_state() - activity_feed = build_activity_feed(activity_config) + # `now_ts` comes from the SAME `now_fn` the dashboard clocks everything else + # with, rather than from a `time.time()` inside the feed builder: the day + # boundary is then a value this loop owns and a test can pin, and it can + # never disagree with the timestamps the rest of the screen is showing. + activity_feed = build_activity_feed( + activity_config, scope=activity_scope, now_ts=float(now_fn()) + ) # Re-clamp every poll, not just on a keypress: the feed is rebuilt from a # file another process is writing, so it can SHRINK between polls (a rotation # empties it) and leave the cursor past the end. @@ -1522,12 +1569,20 @@ def _loop(stdscr: Any) -> None: mode = "normal" activity_offset = 0 activity_cursor = 0 + activity_scope = DEFAULT_ACTIVITY_SCOPE activity_expanded = frozenset() elif ch in (10, 13, ord(" "), curses.KEY_ENTER): if 0 <= activity_cursor < len(activity_feed.cycles): activity_expanded = activity_expanded ^ { activity_feed.cycles[activity_cursor].key } + elif ch == ord("t"): + # Widen (or wrap back to today). The cursor and scroll go back to the top + # because every row under them is about to change: leaving the selection on + # row 40 of a scope that now holds one row would land it somewhere arbitrary. + activity_scope = next_activity_scope(activity_scope) + activity_cursor = 0 + activity_offset = 0 else: activity_cursor = _activity_cursor( ch, activity_cursor, height, len(activity_feed.cycles), curses @@ -1628,6 +1683,8 @@ def _loop(stdscr: Any) -> None: mode = "activity" activity_offset = 0 activity_cursor = 0 + # Opens scoped to TODAY every single time, whatever the last visit widened it to. + activity_scope = DEFAULT_ACTIVITY_SCOPE # Opens fully collapsed: the feed's value is the shape of the WHOLE run, and an # overlay that reopened with one cycle already exploded would bury it. activity_expanded = frozenset() @@ -1736,7 +1793,10 @@ def tui_cmd(ctx: click.Context, interval: float, once: bool) -> None: account of what the agent has actually been doing, read (offline, and boundedly) from the structured engine log rather than the DB, and expandable to the individual events inside any cycle. It is the answer to "keel has not traded -- is it even alive?", which the state-only - dashboard cannot give: a quiet cycle still gets a row, and the run of them is the answer. + dashboard cannot give: a quiet cycle still gets a row, and the run of them is the answer. It + opens scoped to TODAY (the local calendar day) and `t` inside it widens to 7 days or to all + the history the bounded read covers; a day with no cycle yet says when keel last ran and when + the next run is due rather than showing an empty panel. None of `screen`/`propose`/`discover` attests, admits, or trades -- `attest` (the human judgment this whole gate rests on) stays deliberately CLI-only, `keel assets attest`. `a` toggles autonomy (turning it OFF is instant, turning it ON needs a typed "yes" at the diff --git a/tests/commands/test_activity.py b/tests/commands/test_activity.py index 570e2fa7..70671b16 100644 --- a/tests/commands/test_activity.py +++ b/tests/commands/test_activity.py @@ -20,6 +20,7 @@ from __future__ import annotations +import datetime import json import time from typing import Any @@ -31,23 +32,32 @@ _MAX_EVENTS_PER_CYCLE, _UNCORRELATED_GAP_SEC, ACTIVITY_HEADER, + ACTIVITY_SCOPES, + DEFAULT_ACTIVITY_SCOPE, ActivityCycle, ActivityEvent, ActivityFeed, _short_num, + _stamp, + apply_scope, build_activity_feed, cycle_style, + describe_empty_scope, describe_status, event_style, feed_from_lines, footer_notes, group_cycles, + next_activity_scope, + normalise_scope, parse_events, read_log_window, render_cycle_row, render_event_detail, render_event_row, resolve_log_path, + scope_headline, + scope_start_ts, summarise_cycle, ) @@ -896,7 +906,10 @@ def test_build_activity_feed_end_to_end(tmp_path: Any) -> None: + "\n" ) - feed = build_activity_feed(_FakeConfig(str(path))) + # `scope="all"`: this test is about the read+parse+group pipeline, and the two fixture cycles + # are deliberately a day apart. The day-scoping that `build_activity_feed` applies by default + # has its own end-to-end test below. + feed = build_activity_feed(_FakeConfig(str(path)), scope="all") assert feed.status == "ok" assert feed.source == str(path.resolve()) @@ -1366,3 +1379,547 @@ def test_a_read_window_holding_no_whole_record_is_not_reported_as_empty(tmp_path text = " ".join(describe_status(feed)) assert "empty" not in text assert "No complete record" in text + + +# ================================================================================================== +# Day scoping -- `apply_scope`, the default `today` view, and the empty state that must never be +# blank. +# +# Every test below pins its own "now" and derives every fixture timestamp from it through +# `_local_midnight`, so the whole section is deterministic in any timezone and on any day it +# happens to run -- which is the entire reason `scope_start_ts`/`apply_scope`/`build_activity_feed` +# take `now_ts` as a parameter instead of reading a clock internally. +# ================================================================================================== + + +def _local_midnight(now_ts: float, days_ago: int = 0) -> float: + """Local midnight `days_ago` calendar days before the local day containing `now_ts`. + + Calendar arithmetic, not `- days * 86400`: that is what makes these fixtures land on the + intended civil day across a DST transition, which is exactly the property the production + boundary claims and these tests would otherwise be unable to hold it to.""" + day = datetime.datetime.fromtimestamp(now_ts).date() - datetime.timedelta(days=days_ago) + return datetime.datetime.combine(day, datetime.time.min).timestamp() + + +#: The instant the scope tests measure from: 14:00 LOCAL on the local day containing a fixed UTC +#: epoch. Anchoring through `_local_midnight` rather than to the raw epoch keeps "today" the same +#: civil day whether the suite runs in UTC, New York or Tokyo. +_SCOPE_NOW = _local_midnight(1_786_212_000.0) + 14 * 3600 + +#: The same day, but 07:00 -- BEFORE the deployment's 09:00 daily cycle. This is the case the +#: whole empty-state design exists for: "today" is legitimately empty every single morning. +_SCOPE_NOW_EARLY = _local_midnight(_SCOPE_NOW) + 7 * 3600 + + +def _cycle_at(ts: float, cycle_id: str) -> list[str]: + """One quiet cycle starting at `ts`.""" + return _quiet_cycle_lines(cycle_id, ts) + + +def _scoped(lines: list[str], scope: str, now_ts: float, **kwargs: Any) -> ActivityFeed: + return apply_scope( + feed_from_lines(lines, source="/tmp/keel.log", **kwargs), scope, now_ts=now_ts + ) + + +# -- the boundary itself --------------------------------------------------------------------------- + + +def test_scope_start_ts_for_today_is_local_midnight_of_the_current_calendar_day() -> None: + start = scope_start_ts("today", _SCOPE_NOW) + + assert start == _local_midnight(_SCOPE_NOW) + # ...and it renders as 00:00:00 in the SAME local clock the rows are stamped in, which is the + # property that makes "today" mean what the operator reading those stamps thinks it means. + assert start is not None + assert time.strftime("%H:%M:%S", time.localtime(start)) == "00:00:00" + + +def test_scope_start_ts_for_today_is_not_a_rolling_24_hours() -> None: + """The distinction the requirement turns on. At 14:00 the boundary is 14 hours back, not 24 -- + a rolling window would put yesterday's 09:00 cycle on screen every morning and drop it every + afternoon, so the same day's feed would change shape depending on when it was opened.""" + start = scope_start_ts("today", _SCOPE_NOW) + + assert start is not None + assert _SCOPE_NOW - start == pytest.approx(14 * 3600, abs=3600) + assert start != _SCOPE_NOW - 86400 + + +def test_scope_start_ts_for_7d_covers_today_plus_the_six_days_before_it() -> None: + start = scope_start_ts("7d", _SCOPE_NOW) + + assert start == _local_midnight(_SCOPE_NOW, days_ago=6) + + +def test_scope_start_ts_for_all_has_no_lower_bound() -> None: + assert scope_start_ts("all", _SCOPE_NOW) is None + + +@pytest.mark.parametrize("bad_now", [float("inf"), float("nan"), 1e30, -1e30]) +def test_scope_start_ts_on_an_unusable_clock_degrades_to_unbounded_not_to_a_crash( + bad_now: float, +) -> None: + """`None` means "show everything". A clock this module cannot read must widen the view, never + empty it -- an empty screen is the one outcome the whole module is built to avoid.""" + assert scope_start_ts("today", bad_now) is None + + +def test_next_activity_scope_cycles_today_then_7d_then_all_then_back() -> None: + assert next_activity_scope("today") == "7d" + assert next_activity_scope("7d") == "all" + assert next_activity_scope("all") == "today" + + +def test_next_activity_scope_of_something_unrecognised_lands_on_the_default() -> None: + assert next_activity_scope("last tuesday") == DEFAULT_ACTIVITY_SCOPE + + +def test_normalise_scope_falls_back_to_today_rather_than_filtering_to_nothing() -> None: + assert normalise_scope("7d") == "7d" + assert normalise_scope("") == DEFAULT_ACTIVITY_SCOPE + assert normalise_scope("yesterday") == DEFAULT_ACTIVITY_SCOPE + + +def test_the_default_scope_is_today() -> None: + """Stated as a test because it is the requirement, not an implementation detail.""" + assert DEFAULT_ACTIVITY_SCOPE == "today" + assert ACTIVITY_SCOPES[0] == "today" + + +# -- what `today` actually shows ------------------------------------------------------------------- + + +def test_today_shows_several_cycles_from_today_and_hides_every_earlier_day() -> None: + lines = [ + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=3) + 9 * 3600, "old-3"), + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=1) + 9 * 3600, "old-1"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today-a"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 11 * 3600, "today-b"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 13 * 3600, "today-c"), + ] + + feed = _scoped(lines, "today", _SCOPE_NOW) + + assert [c.cycle_id for c in feed.cycles] == ["today-c", "today-b", "today-a"] + assert feed.cycles_out_of_scope == 2 + assert feed.scope == "today" + assert feed.scope_start_ts == _local_midnight(_SCOPE_NOW) + # The newest thing OUTSIDE the scope is kept -- and it is the newest, not the oldest. + assert feed.last_cycle_before_scope is not None + assert feed.last_cycle_before_scope.cycle_id == "old-1" + + +def test_today_shows_exactly_one_cycle_when_the_day_holds_exactly_one() -> None: + """The real deployment's normal afternoon: one cycle a day, at 09:00. One row is the correct, + complete answer -- and the header must say "1 cycle", not look like a truncated log.""" + lines = [ + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=1) + 9 * 3600, "yesterday"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today"), + ] + + feed = _scoped(lines, "today", _SCOPE_NOW) + + assert [c.cycle_id for c in feed.cycles] == ["today"] + assert feed.cycles_out_of_scope == 1 + assert "1 cycle" in scope_headline(feed) + assert "1 cycles" not in scope_headline(feed) + + +def test_a_quiet_cycle_that_ran_today_is_still_a_row_not_an_empty_state() -> None: + """A quiet cycle is the positive observation "it looked and there was nothing to do". Scoping + to today must not turn that into a blank day.""" + feed = _scoped(_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today"), "today", _SCOPE_NOW) + + (cycle,) = feed.cycles + assert cycle.is_quiet + assert "quiet -- looked, nothing to do" in render_cycle_row(cycle) + + +def test_today_holds_nothing_before_the_daily_run_but_the_last_run_is_remembered() -> None: + """07:00 on a deployment that runs at 09:00 -- the case that would otherwise render blank.""" + lines = [ + *_cycle_at(_local_midnight(_SCOPE_NOW_EARLY, days_ago=2) + 9 * 3600, "old"), + *_cycle_at(_local_midnight(_SCOPE_NOW_EARLY, days_ago=1) + 9 * 3600, "yesterday"), + ] + + feed = _scoped(lines, "today", _SCOPE_NOW_EARLY) + + assert feed.status == "ok" # the LOG is fine; it is the DAY that is empty + assert feed.cycles == () + assert feed.cycles_out_of_scope == 2 + assert feed.last_cycle_before_scope is not None + assert feed.last_cycle_before_scope.cycle_id == "yesterday" + + +@pytest.mark.parametrize( + ("scope", "expected_ids"), + [ + ("today", ["today-b", "today-a"]), + ("7d", ["today-b", "today-a", "d1", "d3", "d6"]), + ("all", ["today-b", "today-a", "d1", "d3", "d6", "d9", "d40"]), + ], +) +def test_every_scope_returns_the_cycles_it_promises(scope: str, expected_ids: list[str]) -> None: + """One fixture, three scopes, exact counts -- `7d` must include the sixth day back and exclude + the ninth, and `all` must reach the 40-day-old one.""" + lines = [ + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=40) + 9 * 3600, "d40"), + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=9) + 9 * 3600, "d9"), + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=6) + 9 * 3600, "d6"), + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=3) + 9 * 3600, "d3"), + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=1) + 9 * 3600, "d1"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today-a"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 13 * 3600, "today-b"), + ] + + feed = _scoped(lines, scope, _SCOPE_NOW) + + assert [c.cycle_id for c in feed.cycles] == expected_ids + assert feed.cycles_out_of_scope == 7 - len(expected_ids) + + +def test_a_cycle_at_exactly_local_midnight_belongs_to_the_day_that_begins_then() -> None: + """The boundary is inclusive on the lower side, so midnight belongs to the day it starts -- + the calendar's own convention, and the only one under which two adjacent days can neither + both claim a cycle nor both disown it.""" + midnight = _local_midnight(_SCOPE_NOW) + lines = [ + *_cycle_at(midnight - 1, "one-second-before"), + *_cycle_at(midnight, "exactly-midnight"), + ] + + today = _scoped(lines, "today", _SCOPE_NOW) + assert [c.cycle_id for c in today.cycles] == ["exactly-midnight"] + + # ...and the excluded one is not lost, it is the previous day's newest. + assert today.last_cycle_before_scope is not None + assert today.last_cycle_before_scope.cycle_id == "one-second-before" + + # Seen from the following day, the same midnight cycle is out of scope again -- the boundary + # moves with the day rather than the cycle being permanently "today's". + tomorrow = _scoped(lines, "today", _SCOPE_NOW + 86400) + assert tomorrow.cycles == () + assert tomorrow.last_cycle_before_scope is not None + assert tomorrow.last_cycle_before_scope.cycle_id == "exactly-midnight" + + +def test_apply_scope_leaves_a_feed_alone_under_all() -> None: + lines = [ + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=30) + 9 * 3600, "ancient"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today"), + ] + unscoped = feed_from_lines(lines, source="/tmp/keel.log") + + feed = apply_scope(unscoped, "all", now_ts=_SCOPE_NOW) + + assert feed.cycles == unscoped.cycles + assert feed.cycles_out_of_scope == 0 + assert feed.last_cycle_before_scope is None + assert feed.scope_start_ts is None + + +def test_apply_scope_does_not_read_the_clock_when_now_is_given( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The injection is the point: no test in this file may depend on the day it runs on.""" + + def _boom() -> float: # pragma: no cover - being called IS the failure + raise AssertionError("apply_scope read the wall clock instead of using now_ts") + + monkeypatch.setattr(time, "time", _boom) + + feed = _scoped(_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "t"), "today", _SCOPE_NOW) + + assert len(feed.cycles) == 1 + + +def test_apply_scope_on_an_unrecognised_scope_shows_today_rather_than_nothing() -> None: + feed = _scoped(_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "t"), "since tuesday", + _SCOPE_NOW) + + assert feed.scope == "today" + assert len(feed.cycles) == 1 + + +# -- the empty state: the line that answers "is keel alive" ---------------------------------------- + + +def test_empty_today_names_the_last_run_and_when_the_next_one_is_due() -> None: + """The most important wording in this change. Before 09:00 the panel is legitimately empty, + and a blank panel is indistinguishable from a dead agent -- so it must say, in plain words, + that keel has not run YET, when it last ran, and when the next run is expected.""" + lines = _cycle_at(_local_midnight(_SCOPE_NOW_EARLY, days_ago=1) + 9 * 3600, "yesterday") + + feed = _scoped(lines, "today", _SCOPE_NOW_EARLY) + said = describe_empty_scope(feed) + text = " ".join(said) + + assert said # never blank + assert said[0] == "keel has not run yet today." + assert "Last cycle:" in text + assert "yesterday" in text + # The last run's actual stamp, not a vague "recently". + assert feed.last_cycle_before_scope is not None + assert _stamp(feed.last_cycle_before_scope.started_ts) in text + # ...and the forward-looking half: 09:00 is still two hours away at 07:00. + assert "Next cycle due today around 09:00 local" in text + assert "in 2h 00m" in text + assert "Press t to widen the scope" in text + + +def test_empty_today_after_the_usual_time_says_the_run_is_overdue() -> None: + """Different news, so it reads differently: at 14:00 with nothing since yesterday 09:00, the + schedule has been missed and the operator should be told to go and look.""" + lines = _cycle_at(_local_midnight(_SCOPE_NOW, days_ago=1) + 9 * 3600, "yesterday") + + text = " ".join(describe_empty_scope(_scoped(lines, "today", _SCOPE_NOW))) + + assert "keel has not run yet today." in text + assert "Its usual start time today (09:00 local) passed 5h 00m ago." in text + assert "check that the agent's schedule is still running" in text + + +def test_empty_today_with_no_history_at_all_still_explains_itself() -> None: + """A brand-new deployment: no cycle today, and none before it either. There is no last run to + name, so it says so rather than leaving the operator to infer it from silence.""" + feed = ActivityFeed( + status="ok", + source="/tmp/keel.log", + scope="today", + scope_start_ts=_local_midnight(_SCOPE_NOW), + now_ts=_SCOPE_NOW, + ) + + said = describe_empty_scope(feed) + text = " ".join(said) + + assert said + assert "keel has not run today" in text + assert "no earlier" in text + assert "logging.verbose" in text + assert "Press t to widen the scope" in text + + +def test_an_entirely_empty_log_is_still_an_empty_log_under_the_today_default() -> None: + """Scoping must not swallow the FILE-level statuses: an empty log still reports `empty`, with + the same `logging.verbose` advice it always had, not "keel has not run yet today".""" + feed = apply_scope(feed_from_lines([]), "today", now_ts=_SCOPE_NOW) + + assert feed.status == "empty" + assert feed.cycles == () + assert "verbose" in " ".join(describe_status(feed)) + + +@pytest.mark.parametrize("scope", ["today", "7d", "all"]) +def test_the_empty_state_is_never_blank_for_any_scope(scope: str) -> None: + """The invariant the whole design rests on -- there is no combination of scope and history + that renders zero lines.""" + feed = apply_scope(feed_from_lines([], source="/tmp/keel.log"), scope, now_ts=_SCOPE_NOW) + + said = describe_empty_scope(feed) + + assert said + assert any(line.strip() for line in said) + + +def test_describe_empty_scope_under_all_keeps_the_original_no_cycles_wording() -> None: + """`all` is not a day filter, so the answer there is about the LOG holding nothing groupable + -- the pre-scoping wording, unchanged.""" + feed = apply_scope( + ActivityFeed(status="ok", source="/tmp/keel.log"), "all", now_ts=_SCOPE_NOW + ) + + assert "No cycles in the window" in describe_empty_scope(feed)[0] + + +def test_empty_7d_does_not_claim_to_know_when_the_next_run_is_due() -> None: + """The next-run estimate is inferred from a DAILY cadence and only makes sense for today.""" + lines = _cycle_at(_local_midnight(_SCOPE_NOW, days_ago=30) + 9 * 3600, "ancient") + + text = " ".join(describe_empty_scope(_scoped(lines, "7d", _SCOPE_NOW))) + + assert "keel has not run in the last 7 days." in text + assert "Last cycle:" in text + assert "due today" not in text + + +# -- coverage: the bounded window versus the day boundary ------------------------------------------ + + +def test_coverage_is_proven_when_the_window_reaches_past_the_boundary() -> None: + """A window that contains a cycle from BEFORE midnight has demonstrably seen the whole day, + truncated or not.""" + lines = [ + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=1) + 9 * 3600, "yesterday"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today"), + ] + + feed = _scoped(lines, "today", _SCOPE_NOW, truncated=True) + + assert feed.scope_fully_covered + assert not any("COVERAGE UNPROVEN" in note for note in footer_notes(feed)) + + +def test_coverage_is_proven_when_the_whole_file_was_read() -> None: + lines = _cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today") + + feed = _scoped(lines, "today", _SCOPE_NOW, truncated=False) + + assert feed.scope_fully_covered + + +def test_coverage_is_unproven_when_the_bounded_window_begins_inside_today() -> None: + """The interaction the read bounds create: a truncated window whose OLDEST record is already + today cannot show that nothing happened earlier today -- only that it did not see it.""" + lines = [ + *_cycle_at(_local_midnight(_SCOPE_NOW) + 11 * 3600, "today-a"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 13 * 3600, "today-b"), + ] + + feed = _scoped(lines, "today", _SCOPE_NOW, truncated=True) + + assert not feed.scope_fully_covered + notes = footer_notes(feed) + assert any("COVERAGE UNPROVEN" in note for note in notes) + assert any("NOT evidence that today was quiet" in note for note in notes) + + +def test_an_empty_today_in_an_unproven_window_says_so_instead_of_implying_a_quiet_day() -> None: + """The empty state cannot say "nothing happened today" when the read never reached midnight. + Constructed directly, because a window that is both truncated and empty of today's cycles is + exactly the combination a fixture cannot produce by writing a small file.""" + feed = ActivityFeed( + status="ok", + source="/tmp/keel.log", + scope="today", + scope_start_ts=_local_midnight(_SCOPE_NOW), + now_ts=_SCOPE_NOW, + window_truncated=True, + scope_fully_covered=False, + ) + + text = " ".join(describe_empty_scope(feed)) + + assert "CAVEAT" in text + assert "cannot show that nothing happened" in text + + +def test_all_scope_never_reports_coverage_doubt() -> None: + """There is no boundary to fall short of, so the caveat would be meaningless noise.""" + feed = _scoped( + _cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today"), "all", _SCOPE_NOW, + truncated=True, + ) + + assert not any("COVERAGE UNPROVEN" in note for note in footer_notes(feed)) + + +def test_footer_reports_how_much_the_scope_hid() -> None: + lines = [ + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=2) + 9 * 3600, "d2"), + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=1) + 9 * 3600, "d1"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today"), + ] + + notes = " ".join(footer_notes(_scoped(lines, "today", _SCOPE_NOW))) + + assert "2 older cycle(s) in the window are hidden" in notes + assert "press t to widen" in notes + + +# -- the header line ------------------------------------------------------------------------------- + + +def test_scope_headline_names_the_day_the_count_the_hidden_and_the_key() -> None: + lines = [ + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=1) + 9 * 3600, "d1"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today"), + ] + + headline = scope_headline(_scoped(lines, "today", _SCOPE_NOW)) + + assert "scope: today (" in headline + assert time.strftime("%Y-%m-%d", time.localtime(_SCOPE_NOW)) in headline + assert "1 cycle" in headline + assert "1 older hidden" in headline + assert "press t to widen" in headline + # It has to survive an 80-column terminal to be worth writing. + assert len(headline) <= 80 + + +@pytest.mark.parametrize( + ("scope", "fragment"), + [("today", "today ("), ("7d", "last 7 days (from "), ("all", "all history in the window")], +) +def test_scope_headline_names_every_scope(scope: str, fragment: str) -> None: + feed = _scoped(_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "t"), scope, _SCOPE_NOW) + + assert fragment in scope_headline(feed) + + +# -- build_activity_feed: the default really is today ---------------------------------------------- + + +def test_build_activity_feed_defaults_to_today(tmp_path: Any) -> None: + """The requirement, end to end from a real file: only today's cycle comes back, and the one + from yesterday is hidden rather than dropped.""" + path = tmp_path / "keel.log" + path.write_text( + "\n".join( + [ + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=1) + 9 * 3600, "yesterday"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today"), + ] + ) + + "\n" + ) + + feed = build_activity_feed(_FakeConfig(str(path)), now_ts=_SCOPE_NOW) + + assert feed.scope == "today" + assert [c.cycle_id for c in feed.cycles] == ["today"] + assert feed.cycles_out_of_scope == 1 + assert feed.last_cycle_before_scope is not None + assert feed.last_cycle_before_scope.cycle_id == "yesterday" + + +def test_build_activity_feed_widens_on_request(tmp_path: Any) -> None: + path = tmp_path / "keel.log" + path.write_text( + "\n".join( + [ + *_cycle_at(_local_midnight(_SCOPE_NOW, days_ago=1) + 9 * 3600, "yesterday"), + *_cycle_at(_local_midnight(_SCOPE_NOW) + 9 * 3600, "today"), + ] + ) + + "\n" + ) + + feed = build_activity_feed(_FakeConfig(str(path)), scope="all", now_ts=_SCOPE_NOW) + + assert [c.cycle_id for c in feed.cycles] == ["today", "yesterday"] + assert feed.cycles_out_of_scope == 0 + + +def test_build_activity_feed_stamps_the_scope_even_on_a_missing_log(tmp_path: Any) -> None: + """So the overlay's header reads identically whether or not the file was there.""" + feed = build_activity_feed(_FakeConfig(str(tmp_path / "gone.log")), now_ts=_SCOPE_NOW) + + assert feed.status == "missing" + assert feed.scope == "today" + assert describe_status(feed) # the file-level explanation is untouched by scoping + + +def test_today_has_no_upper_bound_so_a_clock_skewed_row_is_shown_not_hidden() -> None: + """A record stamped later today than "now" can only come from a writer whose clock is ahead. + This module shows it -- with its odd timestamp visible -- rather than shrinking the panel and + giving the operator nothing to go on. Pinned as a test because it is a decision, not an + accident: "today" is the calendar day, and `now` is only ever its lower-bound anchor.""" + lines = _cycle_at(_local_midnight(_SCOPE_NOW) + 20 * 3600, "later-today") # 20:00 vs now 14:00 + + feed = _scoped(lines, "today", _SCOPE_NOW) + + assert [c.cycle_id for c in feed.cycles] == ["later-today"] + assert feed.cycles_out_of_scope == 0 diff --git a/tests/commands/test_tui.py b/tests/commands/test_tui.py index e4805f13..4af682c5 100644 --- a/tests/commands/test_tui.py +++ b/tests/commands/test_tui.py @@ -22,7 +22,13 @@ import keel.commands.tui as tui_mod from keel.cli import cli -from keel.commands.activity import ActivityFeed, feed_from_lines +from keel.commands.activity import ( + ACTIVITY_HEADER, + ActivityFeed, + apply_scope, + feed_from_lines, + scope_start_ts, +) from keel.commands.admission import DiscoverReport from keel.commands.insights import ( AccountSummary as InsightsAccountSummary, @@ -2325,24 +2331,36 @@ def _activity_line(event: str, ts: float, cycle_id: str | None = "cyc-1", **fiel #: guard violation, and an entry that was not placed. _ACTIVITY_TS = 1_786_194_006.0 +#: The same fixture re-anchored onto NOW_TS's OWN local calendar day, for the `run_live` tests -- +#: which now build a TODAY-scoped feed, so a fixture stamped 2026-08-08 would (correctly) render +#: the "keel has not run yet today" empty state instead of the rows those tests are about. Both +#: cycles land inside the one local day, two hours apart, so they stay two rows in newest-first +#: order. Derived from `scope_start_ts` and the fixed `NOW_TS` rather than from a live clock, so +#: it is exactly as deterministic as the constant it is built from. +_ACTIVITY_TODAY_TS = (scope_start_ts("today", float(NOW_TS)) or 0.0) + 9 * 3600 + -def _activity_log_lines() -> list[str]: +def _activity_log_lines(base: float = _ACTIVITY_TS, gap: float = 86400.0) -> list[str]: + """A quiet cycle at `base`, then a rail-vetoed one `gap` seconds later. `gap` is a parameter + so the same shapes can be laid out across two days (the default -- what the real deployment + does) or inside one (`_today_activity_log_lines`).""" + later = base + gap return [ - _activity_line("agent.cycle_start", _ACTIVITY_TS, "quiet-1"), - _activity_line("agent.mode_resolved", _ACTIVITY_TS + 1, "quiet-1", mode="paper"), + _activity_line("agent.cycle_start", base, "quiet-1"), + _activity_line("agent.mode_resolved", base + 1, "quiet-1", mode="paper"), _activity_line( "agent.signals_evaluated", - _ACTIVITY_TS + 2, + base + 2, "quiet-1", product="BTC-USD", rule_count=1, signal_count=0, ), - _activity_line("agent.cycle_start", _ACTIVITY_TS + 86400, "veto-1"), - _activity_line("agent.mode_resolved", _ACTIVITY_TS + 86401, "veto-1", mode="paper"), + _activity_line("agent.cycle_start", later, "veto-1"), + _activity_line("agent.mode_resolved", later + 1, "veto-1", mode="paper"), _activity_line( "engine.setup_detected", - _ACTIVITY_TS + 86402, + later + 2, "veto-1", rule="turtle_breakout", product="PAXG-USD", @@ -2354,7 +2372,7 @@ def _activity_log_lines() -> list[str]: ), _activity_line( "agent.signals_evaluated", - _ACTIVITY_TS + 86402, + later + 2, "veto-1", product="PAXG-USD", rule_count=1, @@ -2362,7 +2380,7 @@ def _activity_log_lines() -> list[str]: ), _activity_line( "guards.check_failed", - _ACTIVITY_TS + 86402, + later + 2, "veto-1", product="PAXG-USD", side="BUY", @@ -2373,7 +2391,7 @@ def _activity_log_lines() -> list[str]: ), _activity_line( "agent.enter_evaluated", - _ACTIVITY_TS + 86402, + later + 2, "veto-1", product="PAXG-USD", rule="turtle_breakout", @@ -2385,10 +2403,15 @@ def _activity_log_lines() -> list[str]: ] -def _write_activity_log(tmp_path: Any) -> str: +def _today_activity_log_lines() -> list[str]: + """Both cycles inside NOW_TS's local calendar day -- what the TODAY-scoped overlay shows.""" + return _activity_log_lines(base=_ACTIVITY_TODAY_TS, gap=7200.0) + + +def _write_activity_log(tmp_path: Any, lines: list[str] | None = None) -> str: path = tmp_path / "logs" / "keel.log" path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("\n".join(_activity_log_lines()) + "\n") + path.write_text("\n".join(_today_activity_log_lines() if lines is None else lines) + "\n") return str(path) @@ -2747,7 +2770,7 @@ def test_run_live_activity_survives_a_log_record_whose_timestamp_cannot_be_rende path.write_text( "\n".join( [ - *_activity_log_lines(), + *_today_activity_log_lines(), json.dumps({"ts": 1e20, "event": "agent.cycle_start", "cycle_id": "from-mars"}), ] ) @@ -2768,3 +2791,241 @@ def open_state() -> tuple[Repository, Any]: painted_texts = [call[2] for call in stdscr.calls] assert any("keel tui -- activity" in t for t in painted_texts) assert any("rail veto" in t for t in painted_texts) + + +# -- activity overlay: day scoping --------------------------------------------------------------- +# +# The pure boundary/empty-state logic is exercised exhaustively in +# `tests/commands/test_activity.py`. These tests are about the OVERLAY and the live loop: that the +# scope is visible on screen, that `t` widens it, and that it is reset to `today` on every open. + + +def _yesterday_activity_log_lines() -> list[str]: + """One quiet cycle on the local day BEFORE NOW_TS's -- the history a `today` scope hides. + + The previous day's midnight is taken from `scope_start_ts` rather than by subtracting 86400 + from today's, so this lands on the intended civil day even across a DST transition.""" + ts = (scope_start_ts("today", float(NOW_TS) - 86400) or 0.0) + 9 * 3600 + return [ + _activity_line("agent.cycle_start", ts, "yesterday-1"), + _activity_line("agent.mode_resolved", ts + 1, "yesterday-1", mode="paper"), + # A gate rejection, so this cycle's COLLAPSED row names XLM-USD -- which is what makes + # "is yesterday on screen?" answerable from the painted text alone. A quiet cycle would + # render only "1 products" and be indistinguishable from today's. + _activity_line( + "engine.setup_rejected", + ts + 2, + "yesterday-1", + rule="turtle_breakout", + product="XLM-USD", + gate="choppy_regime", + ), + ] + + +def _scoped_activity_feed(scope: str = "today") -> Any: + """The two-cycles-today fixture plus one from yesterday, scoped as the overlay would.""" + return apply_scope( + feed_from_lines( + [*_yesterday_activity_log_lines(), *_today_activity_log_lines()], + source="/tmp/keel.log", + ), + scope, + now_ts=float(NOW_TS), + ) + + +def test_activity_overlay_header_states_the_scope_under_the_title() -> None: + """A one-row "today" view and a one-row log look identical without this line -- and the key + that would settle it would be invisible.""" + texts = [line.text for line in build_activity_overlay(_scoped_activity_feed())] + + assert texts[0] == "keel tui -- activity" + assert texts[1].startswith("scope: today (") + assert "1 older hidden" in texts[1] + assert "press t to widen" in texts[1] + + +def test_activity_overlay_scoped_to_today_hides_yesterdays_row() -> None: + texts = [line.text for line in build_activity_overlay(_scoped_activity_feed("today"))] + joined = " ".join(texts) + + assert "rail veto: per_asset_concentration_cap" in joined # today's vetoed cycle + assert "XLM-USD" not in joined # yesterday's, which only names XLM + + +def test_activity_overlay_widened_to_all_shows_the_earlier_day_again() -> None: + texts = [line.text for line in build_activity_overlay(_scoped_activity_feed("all"))] + + rows = [t for t in texts if t.startswith((" ▸", ">▸"))] + assert len(rows) == 3 + assert "all history in the window" in texts[1] + + +def test_activity_overlay_with_nothing_today_is_never_blank_and_names_the_last_run() -> None: + """The morning case, on screen: the panel must answer "is keel alive" without a single row.""" + feed = apply_scope( + feed_from_lines(_yesterday_activity_log_lines(), source="/tmp/keel.log"), + "today", + now_ts=float(NOW_TS), + ) + + lines = build_activity_overlay(feed) + texts = [line.text for line in lines] + joined = " ".join(texts) + + assert len(texts) > 3 + assert "keel has not run yet today." in texts + assert "Last cycle:" in joined + assert "yesterday" in joined + assert "Press t to widen the scope" in joined + # The column header is not painted over an empty day -- there are no columns to head. + assert ACTIVITY_HEADER not in texts + + +def test_activity_cursor_line_on_an_empty_scope_stays_in_range() -> None: + feed = apply_scope( + feed_from_lines(_yesterday_activity_log_lines(), source="/tmp/keel.log"), + "today", + now_ts=float(NOW_TS), + ) + + lines, cursor_line = _activity_lines(feed) + + assert 0 <= cursor_line < len(lines) + + +def test_activity_overlay_footer_advertises_the_scope_key() -> None: + footer = [line.text for line in build_activity_overlay(_scoped_activity_feed())][-1] + + assert "t scope" in footer + assert "expand" in footer + assert "close" in footer + + +def test_help_screen_documents_the_scope_and_the_t_key() -> None: + texts = [line.text for line in build_help_screen()] + joined = " ".join(texts) + + assert any(t.strip().startswith("t ") for t in texts) + assert "SCOPED TO TODAY" in joined + assert "today -> last 7 days -> all history in the window" in joined + assert "has not run yet" in joined + assert "reopened" in joined + + +def test_run_live_activity_opens_scoped_to_today( + repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + """The requirement, through the live loop: yesterday's cycle is in the log and is not shown.""" + config = _config( + logging=LoggingConfig( + file=_write_activity_log( + tmp_path, [*_yesterday_activity_log_lines(), *_today_activity_log_lines()] + ) + ) + ) + stdscr = _KeySequenceStdscr(height=40, width=240, keys=[ord("v"), -1]) + + fake_curses = _fake_curses() + fake_curses.wrapper = lambda fn: fn(stdscr) + monkeypatch.setitem(sys.modules, "curses", fake_curses) + + def open_state() -> tuple[Repository, Any]: + return repo, config + + run_live(open_state, lambda: NOW_TS, interval=0.01) + + painted = [call[2] for call in stdscr.calls] + assert any("scope: today (" in t for t in painted) + assert any("rail veto: per_asset_concentration_cap" in t for t in painted) + assert not any("XLM-USD" in t for t in painted) # yesterday's, hidden + assert any("1 older hidden" in t for t in painted) + + +def test_run_live_activity_t_widens_the_scope_to_reveal_the_earlier_day( + repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + """`t` cycles today -> 7d -> all. Two presses reach `all`, and yesterday's cycle appears.""" + config = _config( + logging=LoggingConfig( + file=_write_activity_log( + tmp_path, [*_yesterday_activity_log_lines(), *_today_activity_log_lines()] + ) + ) + ) + stdscr = _KeySequenceStdscr( + height=40, width=240, keys=[ord("v"), ord("t"), ord("t"), -1] + ) + + fake_curses = _fake_curses() + fake_curses.wrapper = lambda fn: fn(stdscr) + monkeypatch.setitem(sys.modules, "curses", fake_curses) + + def open_state() -> tuple[Repository, Any]: + return repo, config + + run_live(open_state, lambda: NOW_TS, interval=0.01) + + painted = [call[2] for call in stdscr.calls] + assert any("scope: last 7 days (from " in t for t in painted) + assert any("all history in the window" in t for t in painted) + assert any("XLM-USD" in t for t in painted) # yesterday's cycle, now in scope + + +def test_run_live_activity_reopens_at_today_after_being_widened( + repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + """A widened scope answers one question once; it must never become the next open's default.""" + config = _config( + logging=LoggingConfig( + file=_write_activity_log( + tmp_path, [*_yesterday_activity_log_lines(), *_today_activity_log_lines()] + ) + ) + ) + # open, widen to 7d, widen to all, close, reopen -- then repaint. + stdscr = _KeySequenceStdscr( + height=40, width=240, keys=[ord("v"), ord("t"), ord("t"), 27, ord("v"), -1] + ) + + fake_curses = _fake_curses() + fake_curses.wrapper = lambda fn: fn(stdscr) + monkeypatch.setitem(sys.modules, "curses", fake_curses) + + def open_state() -> tuple[Repository, Any]: + return repo, config + + run_live(open_state, lambda: NOW_TS, interval=0.01) + + painted = [call[2] for call in stdscr.calls] + all_scope_frame = max(i for i, t in enumerate(painted) if "all history in the window" in t) + reopened_frame = max(i for i, t in enumerate(painted) if "scope: today (" in t) + + # The LAST activity frame painted is a `today` one, i.e. the reopen reset it. + assert reopened_frame > all_scope_frame + + +def test_run_live_activity_paints_the_empty_state_when_today_holds_no_cycle( + repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + """The whole point of the empty state, through the live loop: a log with only older cycles + renders words, not a blank panel -- and those words name when keel last ran.""" + config = _config( + logging=LoggingConfig(file=_write_activity_log(tmp_path, _yesterday_activity_log_lines())) + ) + stdscr = _KeySequenceStdscr(height=24, width=200, keys=[ord("v"), -1]) + + fake_curses = _fake_curses() + fake_curses.wrapper = lambda fn: fn(stdscr) + monkeypatch.setitem(sys.modules, "curses", fake_curses) + + def open_state() -> tuple[Repository, Any]: + return repo, config + + run_live(open_state, lambda: NOW_TS, interval=0.01) + + painted = [call[2] for call in stdscr.calls] + assert any("keel has not run yet today." in t for t in painted) + assert any("Last cycle:" in t for t in painted) + assert any("Press t to widen the scope" in t for t in painted)