WatchLoop Event Extensions
Extend the WatchLoop's typed event system with two changes:
-
New scan-started event — emitted at the beginning of each scan cycle (both scheduled ticks and event-driven rescans). Payload is minimal (empty or with a trigger field). This lets the dashboard transition to "scanning..." state without guessing.
-
Add trigger field to scan-complete event — discriminated union: "poll" for scheduled tick completions, "event" for session-completion-driven rescans. The dashboard uses this to decide whether to reset the countdown (poll resets, event does not).
Both changes go through the existing WatchLoopEventMap type interface and the emitTyped() helper. The WatchLoopLike interface must be extended to expose the new event in its type signature (it inherits from the event map, so this should be automatic).
Dashboard Countdown State Machine
Add countdown state management to the dashboard's App component:
State: A three-state machine:
- counting — active countdown, displays
{N}s where N decrements each second
- scanning — active scan in progress, displays
scanning...
- stopped — watch loop off, displays
stopped ({N}s) where N is the configured interval
Transitions:
started event → enter counting state, initialize counter to configured interval
scan-started event → enter scanning state, pause countdown
scan-complete event (trigger=poll) → enter counting state, reset counter to configured interval
scan-complete event (trigger=event) → no state change, countdown continues
stopped event → enter stopped state, clear countdown timer
Timer: An independent 1-second setInterval that decrements the counter while in counting state. Clamps at 0 (does not go negative). Created when entering counting state, cleared when leaving it.
Config plumbing: The configured interval (from cli.interval, default 60) needs to reach the countdown logic. It's already available in the dashboard command where WatchLoop is constructed — pass it through to App or derive it from the started event payload (which already includes intervalSeconds).
ThreePanelLayout Display Update
Replace the static watchRunning boolean prop and its "watch: running" / "watch: stopped" text rendering with a new prop that carries the pre-formatted countdown display string. The color logic remains: green when the loop is running (counting or scanning states), red when stopped.
The ThreePanelLayout component becomes simpler — it receives a display string and a color, not a boolean. The state machine in App decides what string to show.
Test Coverage
Unit tests for:
- Countdown decrement: Given an initial value, verify the counter decrements each second and clamps at 0
- Scan-complete reset: Verify poll-triggered completions reset the counter; event-triggered do not
- Display state machine: Verify correct display strings for each state (counting →
{N}s, scanning → scanning..., stopped → stopped ({N}s))
- WatchLoop events: Verify
scan-started is emitted at the correct point; verify scan-complete carries the correct trigger value for both poll and event paths
User Story
- As a dashboard user, I want to see a live countdown to the next heartbeat scan, so that I know when the pipeline will next check for work.
- As a dashboard user, I want to see a "scanning..." indicator when a scan is actively running, so that I know the system is working and not stalled.
- As a dashboard user, I want to see the configured interval when the watch loop is stopped (e.g. "stopped (60s)"), so that I know what the heartbeat would be if I started it.
What to Build
WatchLoop Event Extensions
Extend the WatchLoop's typed event system with two changes:
-
New scan-started event — emitted at the beginning of each scan cycle (both scheduled ticks and event-driven rescans). Payload is minimal (empty or with a trigger field). This lets the dashboard transition to "scanning..." state without guessing.
-
Add trigger field to scan-complete event — discriminated union: "poll" for scheduled tick completions, "event" for session-completion-driven rescans. The dashboard uses this to decide whether to reset the countdown (poll resets, event does not).
Both changes go through the existing WatchLoopEventMap type interface and the emitTyped() helper. The WatchLoopLike interface must be extended to expose the new event in its type signature (it inherits from the event map, so this should be automatic).
Dashboard Countdown State Machine
Add countdown state management to the dashboard's App component:
State: A three-state machine:
- counting — active countdown, displays
{N}s where N decrements each second
- scanning — active scan in progress, displays
scanning...
- stopped — watch loop off, displays
stopped ({N}s) where N is the configured interval
Transitions:
started event → enter counting state, initialize counter to configured interval
scan-started event → enter scanning state, pause countdown
scan-complete event (trigger=poll) → enter counting state, reset counter to configured interval
scan-complete event (trigger=event) → no state change, countdown continues
stopped event → enter stopped state, clear countdown timer
Timer: An independent 1-second setInterval that decrements the counter while in counting state. Clamps at 0 (does not go negative). Created when entering counting state, cleared when leaving it.
Config plumbing: The configured interval (from cli.interval, default 60) needs to reach the countdown logic. It's already available in the dashboard command where WatchLoop is constructed — pass it through to App or derive it from the started event payload (which already includes intervalSeconds).
ThreePanelLayout Display Update
Replace the static watchRunning boolean prop and its "watch: running" / "watch: stopped" text rendering with a new prop that carries the pre-formatted countdown display string. The color logic remains: green when the loop is running (counting or scanning states), red when stopped.
The ThreePanelLayout component becomes simpler — it receives a display string and a color, not a boolean. The state machine in App decides what string to show.
Test Coverage
Unit tests for:
- Countdown decrement: Given an initial value, verify the counter decrements each second and clamps at 0
- Scan-complete reset: Verify poll-triggered completions reset the counter; event-triggered do not
- Display state machine: Verify correct display strings for each state (counting →
{N}s, scanning → scanning..., stopped → stopped ({N}s))
- WatchLoop events: Verify
scan-started is emitted at the correct point; verify scan-complete carries the correct trigger value for both poll and event paths
Acceptance Criteria
Epic: #537
WatchLoop Event Extensions
Extend the WatchLoop's typed event system with two changes:
New
scan-startedevent — emitted at the beginning of each scan cycle (both scheduled ticks and event-driven rescans). Payload is minimal (empty or with a trigger field). This lets the dashboard transition to "scanning..." state without guessing.Add
triggerfield toscan-completeevent — discriminated union:"poll"for scheduled tick completions,"event"for session-completion-driven rescans. The dashboard uses this to decide whether to reset the countdown (poll resets, event does not).Both changes go through the existing
WatchLoopEventMaptype interface and theemitTyped()helper. TheWatchLoopLikeinterface must be extended to expose the new event in its type signature (it inherits from the event map, so this should be automatic).Dashboard Countdown State Machine
Add countdown state management to the dashboard's App component:
State: A three-state machine:
{N}swhere N decrements each secondscanning...stopped ({N}s)where N is the configured intervalTransitions:
startedevent → entercountingstate, initialize counter to configured intervalscan-startedevent → enterscanningstate, pause countdownscan-completeevent (trigger=poll) → entercountingstate, reset counter to configured intervalscan-completeevent (trigger=event) → no state change, countdown continuesstoppedevent → enterstoppedstate, clear countdown timerTimer: An independent 1-second
setIntervalthat decrements the counter while incountingstate. Clamps at 0 (does not go negative). Created when enteringcountingstate, cleared when leaving it.Config plumbing: The configured interval (from
cli.interval, default 60) needs to reach the countdown logic. It's already available in the dashboard command where WatchLoop is constructed — pass it through to App or derive it from thestartedevent payload (which already includesintervalSeconds).ThreePanelLayout Display Update
Replace the static
watchRunningboolean prop and its"watch: running"/"watch: stopped"text rendering with a new prop that carries the pre-formatted countdown display string. The color logic remains: green when the loop is running (counting or scanning states), red when stopped.The ThreePanelLayout component becomes simpler — it receives a display string and a color, not a boolean. The state machine in App decides what string to show.
Test Coverage
Unit tests for:
{N}s, scanning →scanning..., stopped →stopped ({N}s))scan-startedis emitted at the correct point; verifyscan-completecarries the correcttriggervalue for both poll and event pathsUser Story
What to Build
WatchLoop Event Extensions
Extend the WatchLoop's typed event system with two changes:
New
scan-startedevent — emitted at the beginning of each scan cycle (both scheduled ticks and event-driven rescans). Payload is minimal (empty or with a trigger field). This lets the dashboard transition to "scanning..." state without guessing.Add
triggerfield toscan-completeevent — discriminated union:"poll"for scheduled tick completions,"event"for session-completion-driven rescans. The dashboard uses this to decide whether to reset the countdown (poll resets, event does not).Both changes go through the existing
WatchLoopEventMaptype interface and theemitTyped()helper. TheWatchLoopLikeinterface must be extended to expose the new event in its type signature (it inherits from the event map, so this should be automatic).Dashboard Countdown State Machine
Add countdown state management to the dashboard's App component:
State: A three-state machine:
{N}swhere N decrements each secondscanning...stopped ({N}s)where N is the configured intervalTransitions:
startedevent → entercountingstate, initialize counter to configured intervalscan-startedevent → enterscanningstate, pause countdownscan-completeevent (trigger=poll) → entercountingstate, reset counter to configured intervalscan-completeevent (trigger=event) → no state change, countdown continuesstoppedevent → enterstoppedstate, clear countdown timerTimer: An independent 1-second
setIntervalthat decrements the counter while incountingstate. Clamps at 0 (does not go negative). Created when enteringcountingstate, cleared when leaving it.Config plumbing: The configured interval (from
cli.interval, default 60) needs to reach the countdown logic. It's already available in the dashboard command where WatchLoop is constructed — pass it through to App or derive it from thestartedevent payload (which already includesintervalSeconds).ThreePanelLayout Display Update
Replace the static
watchRunningboolean prop and its"watch: running"/"watch: stopped"text rendering with a new prop that carries the pre-formatted countdown display string. The color logic remains: green when the loop is running (counting or scanning states), red when stopped.The ThreePanelLayout component becomes simpler — it receives a display string and a color, not a boolean. The state machine in App decides what string to show.
Test Coverage
Unit tests for:
{N}s, scanning →scanning..., stopped →stopped ({N}s))scan-startedis emitted at the correct point; verifyscan-completecarries the correcttriggervalue for both poll and event pathsAcceptance Criteria
scan-startedevent before each scan cycle beginsscan-completeevent payload includestrigger: "poll" | "event"field{N}s) that decrements each second after a poll-triggered scan completesscanning...in green when a scan is in progressstopped ({N}s)in red when the watch loop is off, where N is the configured intervalEpic: #537