chain-studio/lib/pages/runs.dart
flemming-it 28f6fe1a9a feat(approvals,runs): explain approvals in place + one-click hub update (0.81.0)
Approvals: the pending card now shows its full origin — flow, step,
run id (previously dropped at the Dart mapping layer), project, and
requested-at — under an ORIGIN heading, led by a one-line intro strip
that says what the inbox is and what Approve/Reject do. Approve/Reject
buttons carry tooltips; the history dialog gains project + run id.
Fixes the approvals doc drift (title/details/reviewer ->
prompt/show/timeout_seconds). Guard: approvals_origin_test renders the
card via the hermetic fake hub and pins every origin fact.

Runs: the "hub too old" state now leads with an in-place update button
(same `chain update apply` path as the Diagnose page), the Diagnose
deeplink demoted to secondary, with a CLI-absent fallback. Guard: two
new RunsLoadErrorView widget tests.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-26 15:50:41 +02:00

485 lines
17 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/friendly_error.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../main.dart' show StudioShellState;
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
import 'welcome.dart' show showFaiDoc;
/// Detached-runs monitor (T3 parity): the invocations submitted with
/// `detach: true`, with a cancel button while they are still running.
/// Workspace-scoped like Audit and Approvals. Detached invocations are
/// opt-in (`detached.enabled`), so an empty list is the normal case
/// for most operators — the empty state says so plainly.
class RunsPage extends StatefulWidget {
const RunsPage({super.key});
@override
State<RunsPage> createState() => _RunsPageState();
}
/// Why the runs monitor could not load its list. The page used to
/// fold every failure into "hub not reachable", which contradicted
/// the sidebar's green "connected" dot whenever the hub answered
/// with an RPC-level error (usertest finding: a 0.21 hub without
/// the ListInvocations RPC). Top-level so the unit test drives the
/// classification directly.
enum RunsLoadIssue {
/// The hub itself cannot be reached (socket-level failure or
/// gRPC UNAVAILABLE / DEADLINE_EXCEEDED).
unreachable,
/// The hub answered, but its version predates the
/// ListInvocations RPC (gRPC UNIMPLEMENTED) — connected, just
/// too old for this view.
unsupported,
/// The hub answered and refused because detached invocations
/// are switched off (gRPC FAILED_PRECONDITION from the detached
/// gate) — show the regular feature-off empty state, not a
/// connection error.
featureDisabled,
/// Anything else — show the friendly error with copyable detail.
other,
}
/// Classify a [HubService.listDetachedRuns] failure into the view
/// state the page should render. The runs-specific part is the
/// detached feature gate; everything else delegates to the shared
/// page-load classification.
RunsLoadIssue classifyRunsLoadError(Object error) {
if (grpcCodeOf(error) == 9) {
// FAILED_PRECONDITION — the hub's detached gate.
final msg = (grpcMessageOf(error) ?? '').toLowerCase();
return msg.contains('detached')
? RunsLoadIssue.featureDisabled
: RunsLoadIssue.other;
}
return switch (classifyHubLoadError(error)) {
HubLoadIssue.unreachable => RunsLoadIssue.unreachable,
HubLoadIssue.unsupported => RunsLoadIssue.unsupported,
HubLoadIssue.other => RunsLoadIssue.other,
};
}
/// The error rendition of the runs monitor — public + callback-driven
/// so the widget test can pump each variant without a live hub.
class RunsLoadErrorView extends StatelessWidget {
final Object error;
final RunsLoadIssue issue;
/// Opens the runs guide (the feature-off empty state's CTA).
final VoidCallback onOpenGuide;
/// Opens the doctor page (the "hub too old" state's secondary CTA,
/// and the fallback when the update cannot run from here). Null
/// hides the button.
final VoidCallback? onOpenDoctor;
/// Applies the hub update in place (the "hub too old" state's
/// primary CTA). Null falls back to the doctor deeplink only —
/// keeps the widget test's no-hub path unchanged.
final VoidCallback? onUpdateHub;
/// Whether an update is currently being applied — drives the
/// spinner on the primary button.
final bool updating;
const RunsLoadErrorView({
super.key,
required this.error,
required this.issue,
required this.onOpenGuide,
this.onOpenDoctor,
this.onUpdateHub,
this.updating = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
switch (issue) {
case RunsLoadIssue.featureDisabled:
// Same story as the regular feature-off empty state: the
// hub is fine, the operator just has not enabled the
// feature — never claim "not reachable" here.
return ChainEmptyState(
icon: Icons.rocket_launch_outlined,
title: l.runsEmptyTitle,
hint: l.runsEmptyHint,
action: OutlinedButton.icon(
icon: const Icon(Icons.menu_book_outlined, size: 16),
label: Text(l.runsEmptyGuideButton),
onPressed: onOpenGuide,
),
);
case RunsLoadIssue.unsupported:
// The whole point of this state is "your hub is too old" —
// so lead with the fix, not with a detour. The primary
// button updates the hub in place (same `chain update apply`
// path as the Diagnose page); "Diagnose öffnen" stays as the
// secondary escape hatch for when there is no local CLI or
// the release host is unreachable.
return ChainEmptyState(
icon: Icons.system_update_alt_outlined,
title: l.runsHubTooOldTitle,
hint: l.runsHubTooOldHint,
action: onUpdateHub == null
? (onOpenDoctor == null
? null
: OutlinedButton.icon(
icon: const Icon(
Icons.health_and_safety_outlined,
size: 16,
),
label: Text(l.runsHubTooOldButton),
onPressed: onOpenDoctor,
))
: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
onPressed: updating ? null : onUpdateHub,
icon: updating
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt, size: 16),
label: Text(
updating
? l.runsHubUpdateStarted
: l.runsHubUpdateButton,
),
),
if (onOpenDoctor != null) ...[
const SizedBox(height: ChainSpace.sm),
TextButton.icon(
icon: const Icon(
Icons.health_and_safety_outlined,
size: 16,
),
label: Text(l.runsHubTooOldButton),
onPressed: updating ? null : onOpenDoctor,
),
],
],
),
);
case RunsLoadIssue.unreachable:
return ChainEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
);
case RunsLoadIssue.other:
return ChainEmptyState(
icon: Icons.error_outline,
iconColor: theme.colorScheme.error,
title: l.runsLoadFailedTitle,
action: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: ChainErrorBox(error: error, isError: true),
),
);
}
}
}
class _RunsPageState extends State<RunsPage> {
List<DetachedRun> _runs = const [];
bool _detachedEnabled = false;
Object? _error;
RunsLoadIssue _issue = RunsLoadIssue.other;
bool _loaded = false;
Timer? _poll;
bool _updatingHub = false;
final Set<String> _cancelling = <String>{};
@override
void initState() {
super.initState();
Workspace.instance.addListener(_refresh);
Workspace.instance.ensureLoaded();
_refresh();
// A run's phase changes without user action, so poll — same 2 s
// tick the Audit page uses.
_poll = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
}
@override
void dispose() {
Workspace.instance.removeListener(_refresh);
_poll?.cancel();
super.dispose();
}
Future<void> _refresh() async {
try {
final snapshot = await HubService.instance.listDetachedRuns(
project: Workspace.instance.activeSlug,
);
if (!mounted) return;
setState(() {
_runs = snapshot.runs;
_detachedEnabled = snapshot.enabled;
_error = null;
_loaded = true;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_issue = classifyRunsLoadError(e);
_loaded = true;
});
}
}
Future<void> _cancel(DetachedRun run) async {
final l = AppLocalizations.of(context)!;
setState(() => _cancelling.add(run.id));
try {
final ok = await HubService.instance.cancelDetachedRun(run.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok ? l.runsCancelSignalled(run.flowName) : l.runsCancelTooLate,
),
),
);
await _refresh();
} catch (e) {
if (!mounted) return;
showChainErrorSnack(context, 'runs.cancel', e);
} finally {
if (mounted) setState(() => _cancelling.remove(run.id));
}
}
/// Apply the hub update in place from the "hub too old" state.
/// Uses the same `chain update apply --channel <c>` path as the
/// Diagnose page. When there is no local CLI, or the release host
/// offers no update, we hand off to the Diagnose page, which
/// already renders the offline / locate-binary recovery cleanly
/// instead of guessing here.
Future<void> _updateHub() async {
final shell = StudioShellState.of(context);
if (!SystemActions.chainBinaryExists()) {
shell?.navigateTo('doctor');
return;
}
setState(() => _updatingHub = true);
try {
final status = await HubService.instance.checkHubUpdate();
if (!mounted) return;
if (status == null) {
// No update on offer, or the release host is unreachable —
// the Diagnose page states the exact reason.
setState(() => _updatingHub = false);
shell?.navigateTo('doctor');
return;
}
final r = await SystemActions.chainUpdateApply(status.channel);
if (!mounted) return;
setState(() => _updatingHub = false);
if (r.ok) {
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.doctorApplyDone)));
// The CLI restarts the daemon; the 2 s poll picks up the
// now-supported RPC on its own, but nudge it immediately.
await _refresh();
} else {
showChainErrorSnack(
context,
'runs.update',
(r.stderr.isEmpty ? r.stdout : r.stderr).trim(),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _updatingHub = false);
showChainErrorSnack(context, 'runs.update', e);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: Text(l.runsTitle),
actions: [
const ChainWorkspaceSwitcher(),
const SizedBox(width: ChainSpace.md),
IconButton(
icon: const Icon(Icons.help_outline, size: 18),
tooltip: l.helpTooltip,
onPressed: () => showFaiDoc(context, 'runs'),
),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: l.runsReloadTooltip,
onPressed: _refresh,
),
const SizedBox(width: ChainSpace.sm),
],
),
body: !_loaded
? const Center(child: CircularProgressIndicator())
: _error != null && _runs.isEmpty
? RunsLoadErrorView(
error: _error!,
issue: _issue,
onOpenGuide: () => showFaiDoc(context, 'runs'),
onOpenDoctor: () =>
StudioShellState.of(context)?.navigateTo('doctor'),
onUpdateHub: _updateHub,
updating: _updatingHub,
)
: _runs.isEmpty
? ChainEmptyState(
icon: Icons.rocket_launch_outlined,
title: l.runsEmptyTitle,
// The hub reports whether the operator enabled the
// feature — never claim "switched off" while it is on
// and there simply are no runs yet (usertest finding).
hint: _detachedEnabled ? l.runsEmptyEnabledHint : l.runsEmptyHint,
// The guide carries the plain-language explanation plus
// the exact operator steps (config snippet) — a click
// target instead of a raw config key in the hint.
action: OutlinedButton.icon(
icon: const Icon(Icons.menu_book_outlined, size: 16),
label: Text(l.runsEmptyGuideButton),
onPressed: () => showFaiDoc(context, 'runs'),
),
)
: ListView.separated(
padding: const EdgeInsets.all(ChainSpace.lg),
itemCount: _runs.length,
separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.sm),
itemBuilder: (context, i) => _RunRow(
run: _runs[i],
cancelling: _cancelling.contains(_runs[i].id),
onCancel: () => _cancel(_runs[i]),
),
),
);
}
}
class _RunRow extends StatelessWidget {
final DetachedRun run;
final bool cancelling;
final VoidCallback onCancel;
const _RunRow({
required this.run,
required this.cancelling,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final (tone, label) = _phaseChip(l);
return ChainCard(
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
run.flowName.isEmpty ? run.id : run.flowName,
style: theme.textTheme.titleSmall,
),
const SizedBox(width: ChainSpace.sm),
ChainPill(label: label, tone: tone),
],
),
const SizedBox(height: 4),
Text(
_subtitle(l),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (run.error.isNotEmpty) ...[
const SizedBox(height: 4),
SelectableText(
run.error,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
),
),
if (run.isCancellable)
cancelling
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.stop_circle_outlined, size: 16),
label: Text(l.runsCancelButton),
),
],
),
);
}
String _subtitle(AppLocalizations l) {
final parts = <String>[];
if (run.currentStep.isNotEmpty) {
parts.add(l.runsCurrentStep(run.currentStep));
}
if (run.project.isNotEmpty) parts.add(run.project);
parts.add(run.id);
return parts.join(' · ');
}
(ChainPillTone, String) _phaseChip(AppLocalizations l) {
switch (run.phase) {
case DetachedPhase.pending:
return (ChainPillTone.neutral, l.runsPhasePending);
case DetachedPhase.running:
return (ChainPillTone.accent, l.runsPhaseRunning);
case DetachedPhase.succeeded:
return (ChainPillTone.success, l.runsPhaseSucceeded);
case DetachedPhase.failed:
return (ChainPillTone.danger, l.runsPhaseFailed);
case DetachedPhase.cancelled:
return (ChainPillTone.warning, l.runsPhaseCancelled);
case DetachedPhase.unknown:
return (ChainPillTone.neutral, l.runsPhaseUnknown);
}
}
}