From 7cc8bab9b98543232bdc8ac00eb002296af316d0 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 15 Jul 2026 05:50:24 +0200 Subject: [PATCH] fix(shell): daemon-start and health-poll auth handling; policy panel freshness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/today-pipeline.md | 8 +-- lib/data/hub.dart | 24 +++++++++ lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 +++ lib/l10n/app_localizations_de.dart | 4 ++ lib/l10n/app_localizations_en.dart | 4 ++ lib/main.dart | 75 +++++++++++++++++++++++--- lib/theme/theme.dart | 8 +-- lib/widgets/chain_settings_dialog.dart | 10 +++- lib/widgets/hub_auth_policy_panel.dart | 30 ++++++++++- test/hub_auth_policy_panel_test.dart | 50 +++++++++++++++++ test/hub_banner_test.dart | 37 +++++++++++++ 13 files changed, 242 insertions(+), 16 deletions(-) diff --git a/docs/today-pipeline.md b/docs/today-pipeline.md index bef932e..a67088d 100644 --- a/docs/today-pipeline.md +++ b/docs/today-pipeline.md @@ -24,11 +24,11 @@ feeds the accepted story straight into Studio without a code release. │ 2. PROPOSER tools/today/propose.sh │ │ local System-AI (Ollama by default) drafts 3 story candidates │ │ using tools/today/prompt.template.md + the signal summary → │ -│ ~/.fai/today/proposals/-.yaml │ +│ ~/.chain/today/proposals/-.yaml │ │ │ │ 3. REVIEW + ACCEPT tools/today/accept.sh │ │ operator skims the proposal files, picks one (or none) → │ -│ ~/.fai/today/active.yaml │ +│ ~/.chain/today/active.yaml │ │ │ │ 4. STUDIO lib/pages/store.dart │ │ Studio reads active.yaml at startup; falls back to the │ @@ -43,7 +43,7 @@ agent (macOS), systemd timer (Linux), or `cron` — see ## Schema -`~/.fai/today/active.yaml` (and every proposal file) follows this shape: +`~/.chain/today/active.yaml` (and every proposal file) follows this shape: ```yaml schema: today/v1 @@ -111,6 +111,6 @@ The prompt template (`tools/today/prompt.template.md`) hard-encodes: - A/B-testing or click-through tracking — Ch∆In has no surveillance budget and no telemetry pipeline to feed it into. - Auto-publication to git (operator-curated boundary stays manual). -- Hub-served stories. Studio reads `~/.fai/today/active.yaml` directly +- Hub-served stories. Studio reads `~/.chain/today/active.yaml` directly for now; promoting this to a HubAdmin RPC is a Phase 1 question once multi-tenant Studio appears. diff --git a/lib/data/hub.dart b/lib/data/hub.dart index b5eecc0..46bf27a 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -162,6 +162,7 @@ class HubService { ? await HubAuthToken.read() : authToken as String?; _client = HubClient(endpoint: endpoint, authToken: token); + _lastAuthToken = token; if (persist) { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kHostKey, endpoint.host); @@ -177,6 +178,23 @@ class HubService { await reconnect(_client.endpoint); } + /// The token the live client was built with. The client caches it, + /// so an edit to `~/.chain/hub-auth-token` outside Studio is + /// invisible until a reconnect — see [reloadAuthTokenIfChanged]. + String? _lastAuthToken; + + /// Re-read the token file and reconnect ONLY when its content + /// differs from the token the live client uses. Returns whether a + /// reconnect happened. The shell's health poll calls this on an + /// auth-rejected probe so a token fixed outside Studio (CLI, + /// editor) heals the connection without a restart. + Future reloadAuthTokenIfChanged() async { + final fresh = await HubAuthToken.read(); + if (fresh == _lastAuthToken) return false; + await reconnect(_client.endpoint, authToken: fresh, persist: false); + return true; + } + static const _kThemeKey = 'theme.mode'; static const _kLocaleKey = 'locale.code'; @@ -363,6 +381,7 @@ class HubService { scopeClaim: r.jwt.scopeClaim, ) : null, + reloadRequired: r.reloadRequired, ); } @@ -1685,11 +1704,16 @@ class HubAuthPolicy { /// jwt-rs256 parameters; null for the static validator. final HubJwtValidatorInfo? jwt; + /// True when the on-disk auth config (or its env vars) no longer + /// matches the validator the hub enforces — a pending reload. + final bool reloadRequired; + const HubAuthPolicy({ required this.validator, required this.anonymousAllowed, required this.tokens, this.jwt, + this.reloadRequired = false, }); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9fb299b..eb50312 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -835,6 +835,7 @@ "authPolicyValidatorStatic": "Prüfverfahren: statische Token-Liste", "authPolicyValidatorJwt": "Prüfverfahren: JWT (RS256) über eine externe Identitätsstelle", "authPolicyAnonymous": "Keine Tokens konfiguriert — der Hub akzeptiert anonyme Aufrufe. Für lokales Arbeiten in Ordnung; für den Produktivbetrieb Tokens in ~/.chain/config.yaml einrichten.", + "authPolicyReloadRequired": "Die Konfigurationsdatei (oder eine Umgebungsvariable) wurde seit dem Laden geändert — der Hub erzwingt noch den alten Stand. Unten „Neu laden“ wählen, damit die Änderung wirkt.", "authPolicyNeedsAdmin": "Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.", "authPolicyHubTooOld": "Der verbundene Hub kennt diese Ansicht noch nicht — er ist älter als Studio. Aktualisieren Sie den Hub (chain update apply) und laden Sie neu.", "authPolicyRetry": "Erneut versuchen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 02845a2..bb44a2b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -853,6 +853,7 @@ "authPolicyValidatorStatic": "Validator: static token list", "authPolicyValidatorJwt": "Validator: JWT (RS256) via an external identity provider", "authPolicyAnonymous": "No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.", + "authPolicyReloadRequired": "The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.", "authPolicyNeedsAdmin": "This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.", "authPolicyHubTooOld": "The connected hub does not know this view yet — it is older than Studio. Update the hub (chain update apply) and reload.", "authPolicyRetry": "Retry", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f7c7ca7..47232ad 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2654,6 +2654,12 @@ abstract class AppLocalizations { /// **'No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.'** String get authPolicyAnonymous; + /// No description provided for @authPolicyReloadRequired. + /// + /// In en, this message translates to: + /// **'The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.'** + String get authPolicyReloadRequired; + /// No description provided for @authPolicyNeedsAdmin. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 0e25984..2a7ccc0 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1514,6 +1514,10 @@ class AppLocalizationsDe extends AppLocalizations { String get authPolicyAnonymous => 'Keine Tokens konfiguriert — der Hub akzeptiert anonyme Aufrufe. Für lokales Arbeiten in Ordnung; für den Produktivbetrieb Tokens in ~/.chain/config.yaml einrichten.'; + @override + String get authPolicyReloadRequired => + 'Die Konfigurationsdatei (oder eine Umgebungsvariable) wurde seit dem Laden geändert — der Hub erzwingt noch den alten Stand. Unten „Neu laden“ wählen, damit die Änderung wirkt.'; + @override String get authPolicyNeedsAdmin => 'Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1c1908a..c9a0a47 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1527,6 +1527,10 @@ class AppLocalizationsEn extends AppLocalizations { String get authPolicyAnonymous => 'No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.'; + @override + String get authPolicyReloadRequired => + 'The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.'; + @override String get authPolicyNeedsAdmin => 'This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.'; diff --git a/lib/main.dart b/lib/main.dart index bc52244..335c958 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -270,6 +270,11 @@ class StudioShellState extends State { 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 { @visibleForTesting static Future 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 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 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 _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 { } // 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 { ); } - Future _checkHealth() async { + Future _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 { _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 diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index ee90df5..52f4121 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -265,9 +265,11 @@ class ChainTheme { ), margin: EdgeInsets.zero, ), - // Match FABs to the filled-button accent: Material 3 defaults - // them to primaryContainer, which reads as a washed-out tonal - // surface next to the sky-700 CTAs in the light theme. + // Match FABs to the filled-button accent in BOTH themes: + // Material 3 defaults them to primaryContainer, a tonal + // surface that reads washed-out next to the primary CTAs + // (visually verified light and dark; the dark FAB follows + // the dark filled buttons on purpose). floatingActionButtonTheme: FloatingActionButtonThemeData( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, diff --git a/lib/widgets/chain_settings_dialog.dart b/lib/widgets/chain_settings_dialog.dart index 3a28ae4..2a77dfc 100644 --- a/lib/widgets/chain_settings_dialog.dart +++ b/lib/widgets/chain_settings_dialog.dart @@ -64,6 +64,10 @@ class _FaiSettingsDialogState extends State { /// after save / clear; UI only ever sees the trimmed length. int? _hubAuthTokenChars; + /// Bumped whenever the hub auth token is saved or cleared so the + /// auth-policy panel below re-queries with the new credentials. + int _authPolicyReloadTick = 0; + @override void initState() { super.initState(); @@ -95,6 +99,9 @@ class _FaiSettingsDialogState extends State { await HubService.instance.reloadAuthToken(); await _loadHubAuthToken(); if (!mounted) return; + // The connection's auth just changed — the policy panel below + // must re-query instead of keeping a stale admin-denied hint. + setState(() => _authPolicyReloadTick++); final l = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, @@ -111,6 +118,7 @@ class _FaiSettingsDialogState extends State { await HubService.instance.reloadAuthToken(); await _loadHubAuthToken(); if (!mounted) return; + setState(() => _authPolicyReloadTick++); final l = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, @@ -650,7 +658,7 @@ class _FaiSettingsDialogState extends State { onClear: _clearHubAuthToken, ), const SizedBox(height: ChainSpace.xl), - const HubAuthPolicyPanel(), + HubAuthPolicyPanel(reloadTick: _authPolicyReloadTick), ]; } diff --git a/lib/widgets/hub_auth_policy_panel.dart b/lib/widgets/hub_auth_policy_panel.dart index a1635b6..380412a 100644 --- a/lib/widgets/hub_auth_policy_panel.dart +++ b/lib/widgets/hub_auth_policy_panel.dart @@ -23,7 +23,18 @@ class HubAuthPolicyPanel extends StatefulWidget { @visibleForTesting final Future Function()? reloader; - const HubAuthPolicyPanel({super.key, this.loader, this.reloader}); + /// Bumped by the parent whenever an adjacent action changed the + /// connection's auth state (token saved or cleared in the panel + /// above) — the policy view reloads instead of showing a stale + /// "store an admin token" hint next to the just-stored token. + final int reloadTick; + + const HubAuthPolicyPanel({ + super.key, + this.loader, + this.reloader, + this.reloadTick = 0, + }); @override State createState() => _HubAuthPolicyPanelState(); @@ -42,6 +53,14 @@ class _HubAuthPolicyPanelState extends State { _load(); } + @override + void didUpdateWidget(covariant HubAuthPolicyPanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.reloadTick != oldWidget.reloadTick) { + _load(); + } + } + Future _load() async { setState(() { _loading = true; @@ -193,6 +212,15 @@ class _HubAuthPolicyPanelState extends State { color: theme.colorScheme.tertiary, ), ], + if (p.reloadRequired) ...[ + const SizedBox(height: ChainSpace.xs), + _hintRow( + theme, + Icons.sync_problem_outlined, + l.authPolicyReloadRequired, + color: theme.colorScheme.tertiary, + ), + ], if (p.tokens.isNotEmpty) ...[ const SizedBox(height: ChainSpace.sm), for (final t in p.tokens) _tokenRow(theme, l, t), diff --git a/test/hub_auth_policy_panel_test.dart b/test/hub_auth_policy_panel_test.dart index d909421..72302f4 100644 --- a/test/hub_auth_policy_panel_test.dart +++ b/test/hub_auth_policy_panel_test.dart @@ -156,4 +156,54 @@ void main() { expect(find.textContaining('admin-Recht'), findsOneWidget); expect(find.text('Erneut versuchen'), findsOneWidget); }); + + testWidgets('reload-required policy shows the pending-reload hint', ( + tester, + ) async { + await tester.pumpWidget( + _host( + HubAuthPolicyPanel( + loader: () async => const HubAuthPolicy( + validator: 'static', + anonymousAllowed: false, + tokens: [], + reloadRequired: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.textContaining('erzwingt noch den alten Stand'), + findsOneWidget, + ); + }); + + testWidgets('a bumped reloadTick re-queries the policy', (tester) async { + var loads = 0; + Widget hostWithTick(int tick) => _host( + HubAuthPolicyPanel( + loader: () async { + loads++; + return _staticPolicy(); + }, + reloadTick: tick, + ), + ); + + await tester.pumpWidget(hostWithTick(0)); + await tester.pumpAndSettle(); + expect(loads, 1); + + // Same tick: no reload on unrelated rebuilds. + await tester.pumpWidget(hostWithTick(0)); + await tester.pumpAndSettle(); + expect(loads, 1); + + // Bumped tick (token saved next door): the panel re-queries. + await tester.pumpWidget(hostWithTick(1)); + await tester.pumpAndSettle(); + expect(loads, 2); + }); } diff --git a/test/hub_banner_test.dart b/test/hub_banner_test.dart index 6610a92..47abff4 100644 --- a/test/hub_banner_test.dart +++ b/test/hub_banner_test.dart @@ -33,6 +33,7 @@ Future _pumpShell(WidgetTester tester) async { void main() { tearDown(() { StudioShellState.debugProbeOverride = null; + StudioShellState.debugReloadTokenOverride = null; }); testWidgets('sustained auth rejection points at the token, not the wire', @@ -60,4 +61,40 @@ void main() { expect(find.textContaining("Can't reach"), findsOneWidget); expect(find.textContaining('rejected the sign-in'), findsNothing); }); + + testWidgets('auth-rejected poll re-reads the token file and re-probes', + (tester) async { + // First probe(s) rejected; after the token file "changes" the + // immediate re-probe serves — the operator fixed the token via + // CLI/editor and Studio healed without waiting a poll cycle. + var probes = 0; + var reloads = 0; + StudioShellState.debugProbeOverride = () async { + probes++; + return reloads > 0 ? HubProbeResult.serving : HubProbeResult.authRejected; + }; + StudioShellState.debugReloadTokenOverride = () async { + reloads++; + return true; // token file content changed → reconnected + }; + await _pumpShell(tester); + + expect(reloads, greaterThanOrEqualTo(1)); + // The self-heal re-probe ran within the same tick (probes > + // reloads means at least one immediate retry happened) and the + // banner never surfaces. + expect(probes, greaterThan(reloads)); + expect(find.textContaining('rejected the sign-in'), findsNothing); + expect(find.textContaining("Can't reach"), findsNothing); + }); + + testWidgets('daemonAnswers treats auth-rejected as alive', (tester) async { + StudioShellState.debugProbeOverride = + () async => HubProbeResult.authRejected; + expect(await StudioShellState.daemonAnswers(), isTrue); + + StudioShellState.debugProbeOverride = + () async => HubProbeResult.unreachable; + expect(await StudioShellState.daemonAnswers(), isFalse); + }); }