fix(shell): daemon-start and health-poll auth handling; policy panel freshness
Some checks failed
Security / Security check (push) Failing after 2s

Review follow-ups on the auth-status work:

- Both daemon-start paths classified an auth-rejected hub as "daemon
  dead" via healthy() and showed a start-failure dialog while the
  shell banner above correctly blamed the token. They now share
  daemonAnswers(): only an unreachable probe counts as down.
- An auth-rejected poll now re-reads ~/.chain/hub-auth-token and
  reconnects when the file changed, so a token fixed outside Studio
  (CLI, editor) heals the connection without a restart — previously
  the client kept the stale in-memory token forever and the banner's
  own advice could not work.
- An endpoint switch resets the failure streak, so a stale in-flight
  probe can no longer let the unreachable banner blame the new
  endpoint for the old one's misses.
- The auth-policy panel re-queries when the hub token is saved or
  cleared in the panel above (reloadTick), instead of keeping a
  stale admin-denied hint; it also renders the hub's new
  reload_required flag as a pending-reload warning (DE+EN).
- today-pipeline.md still documented ~/.fai/today after the rename;
  the FAB theme comment now states the both-themes intent.

flutter analyze clean; 71 tests green including four new ones.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-15 05:50:24 +02:00
parent 2a1cbc82c3
commit 7cc8bab9b9
13 changed files with 242 additions and 16 deletions

View file

@ -270,6 +270,11 @@ class StudioShellState extends State<StudioShell> {
int _failedPolls = 0;
static const int _unreachableThreshold = 3;
/// Endpoint the previous poll ran against; a change resets the
/// failure streak so stale in-flight results never blame the new
/// endpoint (see `_checkHealth`).
String _polledEndpoint = '';
/// Whether to render the unreachable banner. Distinct from
/// `_connected == false` so a single transient miss doesn't
/// flash the banner only sustained failure does.
@ -287,6 +292,44 @@ class StudioShellState extends State<StudioShell> {
@visibleForTesting
static Future<HubProbeResult> Function()? debugProbeOverride;
/// Test-only: replaces the token-file self-heal check (see
/// [_reloadTokenIfChanged]) so widget tests can assert the
/// auth-rejected poll re-reads the token without touching disk.
@visibleForTesting
static Future<bool> Function()? debugReloadTokenOverride;
/// Whether the hub daemon answers at all an auth rejection or a
/// not-serving health state still means the process is alive, just
/// not usable yet; only [HubProbeResult.unreachable] is "down".
/// Used by the daemon-start paths so a wrong token doesn't
/// misreport as "daemon start failed" (the shell banner already
/// explains the token problem).
static Future<bool> daemonAnswers() async {
try {
final probe = debugProbeOverride != null
? await debugProbeOverride!()
: await HubService.instance.probeHealth();
return probe != HubProbeResult.unreachable;
} catch (_) {
return false;
}
}
/// Self-heal after an auth-rejected poll: the operator may have
/// fixed `~/.chain/hub-auth-token` outside Studio (CLI, editor)
/// the client caches the token, so re-read the file and reconnect
/// when it changed. Returns whether a reconnect happened. Inert
/// when the probe is test-overridden, unless the reload override
/// is set too.
static Future<bool> _reloadTokenIfChanged() {
final override = debugReloadTokenOverride;
if (override != null) return override();
if (debugProbeOverride != null) return Future.value(false);
return HubService.instance
.reloadAuthTokenIfChanged()
.catchError((_) => false);
}
/// Current connection state, for descendants (e.g. WelcomePage)
/// that adapt their content to hub availability.
bool? get connected => _connected;
@ -307,9 +350,9 @@ class StudioShellState extends State<StudioShell> {
}
// The start command reported failure but very often the daemon is
// simply already running (port in use). Probe before crying error,
// so "tap to start" on an already-up hub just connects.
final alive =
await HubService.instance.healthy().catchError((_) => false);
// so "tap to start" on an already-up hub just connects. Alive but
// auth-rejected counts as running the banner explains the token.
final alive = await daemonAnswers();
if (!mounted) return;
if (alive) {
await _checkHealth();
@ -422,15 +465,22 @@ class StudioShellState extends State<StudioShell> {
);
}
Future<void> _checkHealth() async {
Future<void> _checkHealth({bool retriedAfterTokenReload = false}) async {
final probe = debugProbeOverride != null
? await debugProbeOverride!()
: await HubService.instance.probeHealth();
if (!mounted) return;
// A failure that raced an endpoint switch (Settings save, channel
// switch) must not inherit the old endpoint's failure streak —
// restart the count so the banner never blames the NEW endpoint
// for the OLD one's misses.
final endpoint = HubService.instance.endpointLabel;
final endpointChanged = endpoint != _polledEndpoint;
_polledEndpoint = endpoint;
final ok = probe == HubProbeResult.serving;
final authRejected = probe == HubProbeResult.authRejected;
final wasUnreachable = _hubUnreachable;
final nextFailed = ok ? 0 : _failedPolls + 1;
final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1);
final connectionChanged = _connected != ok;
final bannerChanged =
wasUnreachable != (nextFailed >= _unreachableThreshold);
@ -444,6 +494,16 @@ class StudioShellState extends State<StudioShell> {
_authRejected = authRejected;
});
}
if (authRejected && !retriedAfterTokenReload) {
// Self-heal: re-read the token file in case it was fixed
// outside Studio; on a real change reconnect + re-probe now
// instead of waiting out the poll interval. One retry per
// poll tick at most.
final changed = await _reloadTokenIfChanged();
if (changed && mounted) {
return _checkHealth(retriedAfterTokenReload: true);
}
}
if (ok) {
try {
final snap = await HubService.instance.channelStatus();
@ -779,8 +839,9 @@ class _SidebarState extends State<_Sidebar>
}
// The daemon may already be running (port in use) probe before
// showing an error, so this just connects on an already-up hub.
final alive =
await HubService.instance.healthy().catchError((_) => false);
// Alive but auth-rejected counts as running the shell banner
// explains the token problem.
final alive = await StudioShellState.daemonAnswers();
if (!context.mounted) return;
if (alive) return;
// Genuinely down: persistent, copyable dialog (a SnackBar flashes