1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
//! Live daemon polling for the `tm projects` TUI (#2118, live-refresh + activity
//! wiring #2119).
//!
//! Why: the run loop repeats one async step on its `--interval-ms` cadence —
//! probe daemon health, self-heal the URL on failure, pull the registry-B
//! project list plus the fleet-by-project session groups, fetch the selected
//! session's live activity, and project all three into the pure shapes
//! [`super::state`] renders. Mirrors `tui::coordinator::poll::coord_poll_daemon`
//! so every TUI screen self-heals the daemon URL identically. Fetching the
//! fleet ONCE per tick (rather than per-project) keeps the project/session
//! refresh to two HTTP calls regardless of project count; the activity fetch
//! is a third call, made only when a session is selected (DOC-35 §5.4).
//! What: [`project_ctl_poll_daemon`] (health → rediscover-on-fail → registry
//! list + fleet → map → selected-session activity via [`refresh_activity`] →
//! selected-project Deliverables (+ Milestones when the view is open) via
//! [`refresh_deliverables`], DOC-35 §10.6/§10.8, #2383; on daemon-down clears
//! the project/session state — which in turn clears `activity` too, since a
//! daemon-down poll has no session left selected to attribute it to; see
//! [`refresh_activity`]'s own doc for the narrower "fetch failed but the
//! daemon is otherwise up" case that DOES keep the last-known activity,
//! marked stale). The pure DTO → row projections ([`rows::project_to_row`],
//! [`rows::live_session_rows`], [`rows::session_to_row`],
//! [`rows::activity_from_response`]) live in the [`rows`] submodule (split
//! out by #2476 to keep this file under the 500-SLOC production cap) and are
//! re-exported here so existing callers keep using `poll::project_to_row`
//! etc. A PERSISTENT non-transport `/activity` fetch failure for the
//! selected session (an older daemon lacking the endpoint, a GC'd session, a
//! malformed body) is classified by [`classify_activity_error`] and tracked
//! by [`super::state::ActivityFailureStreak`] so the pane can eventually
//! report it explicitly instead of rendering "loading…" forever (#2470); see
//! [`apply_activity_outcome`] for the pure state-transition rules.
//! Test: `tests` (split into the sibling `tests.rs` file, a recognized
//! test-file path under the 1500-SLOC test cap, to keep THIS file under the
//! 500-SLOC production cap once the #2470 tests were added) covers the
//! daemon-down / stale-keep branches against a guaranteed-dead client
//! (mirroring `tui::coordinator::poll::tests::poll_marks_unreachable_clears_sessions`)
//! plus the failure-classification and failure-streak state machine;
//! [`rows`]'s own `tests` covers the pure projections; the daemon-UP path is
//! exercised manually / by launching the TUI.
pub use ;
// Only reachable from `super::tests` (this module's daemon-down test
// fixtures) and `super::super::tests` (the project_ctl-level integration
// tests) — both `#[cfg(test)]`-gated, so a plain `cargo build` never sees a
// caller and would otherwise warn on the re-export.
pub use session_to_row;
use crate;
use crateProject;
// #9556: the one pin-aware re-discovery step every TUI poll shares.
use craterediscover_daemon as rediscover;
use ActivityInfo;
use ;
/// Refresh [`ProjectCtlState`] from one daemon poll.
///
/// Why: keeps the poll logic out of the key-driven run loop so the loop can
/// re-poll on its timer (and, after a mutating action, on demand) without
/// duplicating the health/rediscover/fetch sequence. Note this function never
/// touches [`ProjectCtlState::pending_confirm`] — the confirmation gate pins a
/// target session id at the moment it opens (DOC-35 §5.2) and a poll racing
/// with an open confirm modal must not reassign or clear it.
/// What: probes health; if the daemon looks down, re-resolves the URL from
/// the lock file via [`rediscover`] (never an explicit, pinned URL — #9556)
/// and retries one health probe. When
/// reachable it pulls `GET /api/v1/projects` and
/// `GET /api/v1/sessions/managed/fleet`, merges them into `state.projects` /
/// `state.sessions_by_project`; on a transport error or an unreachable daemon
/// it clears both and sets `daemon_reachable = false`. Always re-syncs both
/// navigation models so a shrunk list never leaves a selection past the end;
/// the Sessions-pane selection is PRESERVED across a refresh (only an
/// explicit project switch resets it — see
/// [`ProjectCtlState::on_project_selection_changed`]). Finally refreshes
/// [`ProjectCtlState::activity`] for whichever session is selected AFTER the
/// project/session refresh above (see [`refresh_activity`]).
/// Test: `poll_marks_unreachable_clears_state` drives the full-poll
/// daemon-down branch; `rediscover_keeps_explicit_url` covers the #9556 pin;
/// `poll_never_touches_pending_confirm` (in
/// `super::tests`) covers the confirm-gate invariant, `poll_never_closes_an_open_deliverable_view`
/// covers the Deliverable-view invariant, and `poll_doesnt_touch_open_config_form`
/// covers the SAME invariant for the #2120 config form (never reassigned,
/// closed, or have its in-progress unsaved field edits clobbered by a
/// racing poll — the config form has no live daemon-fed content to refresh
/// mid-edit, unlike the Deliverable view, so a poll's only correct action
/// toward an open form is to leave it alone entirely); the daemon-up path
/// requires a live daemon and is exercised manually.
pub async
/// Refresh [`ProjectCtlState::deliverables`] for the currently selected
/// project, and — while [`ProjectCtlState::deliverable_view`] is open —
/// [`super::state::DeliverableView::milestones`] too (DOC-35 §10.6/§10.8,
/// #2383).
///
/// Why: split out of [`project_ctl_poll_daemon`] for the same reason
/// [`refresh_activity`] is — one focused function per "what does this fetch,
/// under what conditions" concern. This is the poll loop's ONE additional
/// steady-state call (Deliverables for the selected project, mirroring how
/// `refresh_activity` scopes its own fetch to the selected session) plus a
/// SECOND call that only fires while the view is open (Milestones) — bounded
/// by "is a modal open", never by session/project count, so it never becomes
/// an O(n) per-session loop. Runs AFTER the project/session refresh so
/// `state.selected_project_name()` reflects the just-synced navigation.
/// What: no project selected, or the daemon unreachable → resets
/// `state.deliverables` to `None` (Unknown; matching `state.projects`/
/// `sessions_by_project`'s own daemon-down handling in this function — every
/// session row also vanishes from the Sessions pane in that case, so the
/// glyph question is moot). A selected project with the daemon reachable →
/// fetches `list_deliverables`; on success replaces `state.deliverables`
/// with `Some(list)`. **On a transient fetch error, `state.deliverables` is
/// left UNCHANGED** (neither cleared nor reset to `None`) — mirrors
/// [`refresh_activity`]'s stale-keep pattern: a `None` (still-unknown) stays
/// `None`, and a `Some(last-known-good-list)` stays exactly that, so
/// [`ProjectCtlState::deliverable_link_state`] keeps resolving previously-
/// resolved sessions correctly instead of flipping them to a false
/// "dangling" reading on one bad poll (review finding on #2383's initial
/// PR). When [`ProjectCtlState::deliverable_view`] is `Some` for the SAME
/// project, also fetches `list_milestones` and updates its `milestones`
/// field in place (leaving `deliverables` alone —
/// [`ProjectCtlState::open_deliverable_view`] seeded it, and this function's
/// own `state.deliverables` update above keeps it current on every
/// subsequent tick via [`sync_open_view_deliverables`]).
/// Test: `refresh_deliverables_clears_when_no_project_selected`,
/// `refresh_deliverables_clears_on_daemon_down`,
/// `refresh_deliverables_keeps_stale_list_on_transient_fetch_failure`.
async
/// Keep an open [`super::state::DeliverableView`]'s `deliverables` in sync
/// with [`ProjectCtlState::deliverables`] on every tick, when the view is
/// still scoped to `project_name`.
///
/// Why: [`ProjectCtlState::open_deliverable_view`] seeds the view from
/// whatever `state.deliverables` held at the moment it opened; without this,
/// a status change (e.g. an operator running `tm projects deliverables
/// set-status` in another terminal) would never appear in an already-open
/// view until it was closed and reopened. Only syncs on `Some` — a `None`
/// (Unknown, e.g. this tick's fetch failed) leaves the view's last-known
/// list on screen rather than blanking it, the same stale-keep principle
/// [`refresh_deliverables`] applies to `state.deliverables` itself.
/// Refresh [`ProjectCtlState::activity`] for the currently selected session
/// (DOC-35 §5.4, #2119).
///
/// Why: split out of [`project_ctl_poll_daemon`] so the "no session selected"
/// / "daemon down, keep last known" / "fetch failed, keep last known" /
/// "fetch succeeded" branches are each one arm instead of nested inside the
/// larger poll function. Runs AFTER the project/session refresh above so
/// `state.selected_session()` reflects the just-synced navigation, not a
/// pre-refresh selection that may have shrunk out of range.
/// What: no selection → clears `state.activity` and the failure streak. A
/// daemon-unreachable poll is treated as a [`ActivityFetchFailure::Transport`]
/// outcome (the whole daemon looks down, same as a connect/timeout error);
/// otherwise the fetch's `Result` is classified via
/// [`classify_activity_error`] and handed to [`apply_activity_outcome`],
/// which owns the actual stale-keep / failure-streak / unavailable rules
/// (#2470).
/// Test: `refresh_activity_marks_existing_activity_stale_on_fetch_failure`,
/// `refresh_activity_clears_when_no_session_is_selected`;
/// [`apply_activity_outcome`]'s own doc covers the failure-streak rules.
async
/// Coarse classification of a `/activity` fetch failure (#2470).
///
/// Why: distinguishes "the whole daemon looks down or is restarting"
/// (transport — connection refused/timeout) from "the daemon answered, but
/// THIS session's activity fetch is itself broken" (non-transport — an HTTP
/// error status or an undecodable body: an older daemon lacking the
/// endpoint, a GC'd session, a malformed payload). Only the latter should
/// ever advance [`super::state::ActivityFailureStreak`] — a transport
/// failure means the daemon itself is unreachable, which the existing
/// stale-keep path already covers on its own, and flapping connectivity must
/// never be mistaken for "this session's activity is permanently gone".
/// What: [`classify_activity_error`] walks the `anyhow::Error` cause chain
/// for a [`reqwest::Error`] whose `is_connect()` or `is_timeout()` is `true`
/// → [`Self::Transport`]; anything else (a status/body error from
/// [`crate::client::http_client::error::response_or_body_error`], a decode
/// error, or no `reqwest::Error` in the chain at all) → [`Self::NonTransport`].
/// Test: `classify_status_error_is_non_transport`,
/// `classify_connect_error_is_transport` (in `tests`, below).
/// See [`ActivityFetchFailure`]'s own doc.
/// Apply one `/activity` fetch's outcome to `state.activity` and
/// `state.activity_failures` (#2470).
///
/// Why: pulled out of [`refresh_activity`] as a pure, synchronously testable
/// function — no daemon, no async, no `reqwest` types in its signature — so
/// the exact state-transition rules (stale-keep vs. reset vs. crossing the
/// unavailable threshold) are unit-testable with typed inputs, mirroring how
/// [`rows::activity_from_response`] keeps the pure DTO projection out of the
/// async fetch.
/// What: `Ok(resp)` → replaces `state.activity` with a fresh, non-stale
/// [`ActivityInfo`] and resets the failure streak
/// ([`super::state::ActivityFailureStreak::reset`]).
/// `Err(ActivityFetchFailure::NonTransport)` → records one failure in the
/// streak for `session_id` ([`super::state::ActivityFailureStreak::record_failure`]);
/// once that streak crosses [`super::state::ACTIVITY_UNAVAILABLE_THRESHOLD`],
/// [`ProjectCtlState::activity_unavailable_for_selected`] flips to `true`
/// and the Activity pane renders "unavailable" instead of "loading…"/stale
/// data. `Err(ActivityFetchFailure::Transport)` → never touches the streak.
/// Both `Err` arms then apply the SAME stale-keep-or-clear rule to
/// `state.activity` itself: mark `stale = true` if it already targets
/// `session_id`, else clear it (nothing recent enough to show as stale) —
/// unchanged from the pre-#2470 behavior.
/// Test: `apply_activity_outcome_success_after_failures_resets_streak_and_shows_data`,
/// `apply_activity_outcome_non_transport_failures_reach_unavailable_threshold`,
/// `apply_activity_outcome_transport_failure_never_advances_streak`,
/// `apply_activity_outcome_session_switch_starts_a_fresh_streak`.
/// Fetch the registry project list and the fleet session groups, merged.
///
/// Why: one place owns the two-call fetch + merge so [`project_ctl_poll_daemon`]
/// stays a thin health/rediscover wrapper.
/// What: GETs `registry_list_projects` and `fleet_managed_sessions`, then
/// builds the `Vec<ProjectRow>` (registry order, counts from the matching
/// fleet group), the `sessions_by_project` map (every fleet group, even a
/// project the registry list omitted — defensive against a transient
/// registry/fleet mismatch) via [`live_session_rows`] (which drops
/// decommissioned sessions, #2476), and a `name -> Project` map of the FULL
/// records `registry_list_projects` already returned (DOC-35 §6, #2120) —
/// the config form needs the full record to seed its baseline values;
/// retaining it here (rather than re-fetching per-project when the form
/// opens) costs nothing, since `projects` was already in hand before being
/// projected down to `ProjectRow`.
async