feat: detached-runs monitor page (T3 parity)
Some checks failed
Security / Security check (push) Failing after 1s

New Runs sidebar destination listing detached invocations (detach:true)
with phase, current step, project and a Cancel button while
pending/running. Workspace-scoped like Audit/Approvals, polls every 2s.
Detached runs are opt-in (detached.enabled) — the empty state explains
how to enable them. Inline help doc DE+EN. DetachedRun model +
listDetachedRuns/cancelDetachedRun in HubService, backed by the SDK's
listInvocations()/cancelInvocation(). flutter analyze clean; 29 tests
green (sidebar Y-stability updated for the new destination, model
mapping unit-tested).

Screenshot verification (light+dark) deferred — shared desktop in use.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-12 14:43:37 +02:00
parent 984c91f91d
commit 54ccd3936a
13 changed files with 764 additions and 2 deletions

View file

@ -1017,6 +1017,23 @@ class HubService {
Future<void> reject(String id, String reviewer, String reason) =>
_client.reject(approvalId: id, reviewer: reviewer, reason: reason);
/// Every tracked detached invocation (newest-first). Optionally
/// scoped to one [project]. Empty when detached invocations are
/// disabled or none have run this process.
Future<List<DetachedRun>> listDetachedRuns({String project = ''}) async {
final entries = await _client.listInvocations();
final runs = entries
.where((e) => project.isEmpty || e.project == project)
.map(DetachedRun.fromEntry)
.toList();
return runs;
}
/// Cancel a running/pending detached invocation. Returns true when
/// it was signalled, false if already finished or unknown.
Future<bool> cancelDetachedRun(String id) =>
_client.cancelInvocation(id);
/// Federation satellites currently connected to this hub
/// (primary side). Empty when none are connected.
Future<List<Satellite>> listSatellites() async {
@ -1547,6 +1564,73 @@ class ApprovalRecord {
});
}
/// Lifecycle phase of a detached run, mirroring the wire enum.
enum DetachedPhase { pending, running, succeeded, failed, cancelled, unknown }
/// A detached invocation as shown in the runs monitor.
class DetachedRun {
final String id;
final DetachedPhase phase;
final String flowName;
final String project;
/// Step currently executing (running), else empty.
final String currentStep;
final DateTime? startedAt;
final DateTime? finishedAt;
/// Error message when [phase] is failed, else empty.
final String error;
const DetachedRun({
required this.id,
required this.phase,
required this.flowName,
required this.project,
required this.currentStep,
required this.startedAt,
required this.finishedAt,
required this.error,
});
/// True while the run can still be cancelled.
bool get isCancellable =>
phase == DetachedPhase.pending || phase == DetachedPhase.running;
factory DetachedRun.fromEntry(InvocationEntry e) {
// Map by the proto enum's integer value (PENDING=1 … CANCELLED=5)
// so Studio needn't import the generated enum type.
DetachedPhase mapPhase(int v) {
switch (v) {
case 1:
return DetachedPhase.pending;
case 2:
return DetachedPhase.running;
case 3:
return DetachedPhase.succeeded;
case 4:
return DetachedPhase.failed;
case 5:
return DetachedPhase.cancelled;
default:
return DetachedPhase.unknown;
}
}
final s = e.status;
return DetachedRun(
id: e.invocationId,
phase: mapPhase(s.phase.value),
flowName: e.flowName,
project: e.project,
currentStep: s.currentStep,
startedAt: s.startedAt.isEmpty ? null : DateTime.tryParse(s.startedAt),
finishedAt: s.finishedAt.isEmpty ? null : DateTime.tryParse(s.finishedAt),
error: s.error,
);
}
}
class SystemAiStatus {
final bool enabled;
final String provider;

View file

@ -1667,5 +1667,22 @@
"federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten.",
"workspaceAll": "Alle Projekte",
"workspaceSwitcherTooltip": "Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt",
"workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich."
"workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.",
"runsTitle": "Läufe",
"runsReloadTooltip": "Lauf-Liste neu laden",
"runsEmptyTitle": "Keine abgekoppelten Läufe",
"runsEmptyHint": "Mit detach gestartete Läufe stehen hier, bis sie fertig sind. Abgekoppelte Aufrufe sind optional — dazu detached.enabled in der Betreiber-Konfiguration aktivieren.",
"runsCancelButton": "Abbrechen",
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.",
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},
"runsCancelTooLate": "Der Lauf war schon fertig — nichts abzubrechen.",
"runsCurrentStep": "Schritt: {step}",
"@runsCurrentStep": {"placeholders": {"step": {"type": "String"}}},
"runsPhasePending": "Wartet",
"runsPhaseRunning": "Läuft",
"runsPhaseSucceeded": "Erfolgreich",
"runsPhaseFailed": "Fehlgeschlagen",
"runsPhaseCancelled": "Abgebrochen",
"runsPhaseUnknown": "Unbekannt",
"navRuns": "Läufe"
}

View file

@ -1706,5 +1706,22 @@
"federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.",
"workspaceAll": "All projects",
"workspaceSwitcherTooltip": "Workspace — filters this view and stamps new runs with the selected project",
"workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area."
"workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.",
"runsTitle": "Runs",
"runsReloadTooltip": "Reload the runs list",
"runsEmptyTitle": "No detached runs",
"runsEmptyHint": "Runs submitted with detach stay here until they finish. Detached invocations are opt-in — enable detached.enabled in the operator config to use them.",
"runsCancelButton": "Cancel",
"runsCancelSignalled": "Cancel signalled for {flow}.",
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},
"runsCancelTooLate": "The run had already finished — nothing to cancel.",
"runsCurrentStep": "Step: {step}",
"@runsCurrentStep": {"placeholders": {"step": {"type": "String"}}},
"runsPhasePending": "Pending",
"runsPhaseRunning": "Running",
"runsPhaseSucceeded": "Succeeded",
"runsPhaseFailed": "Failed",
"runsPhaseCancelled": "Cancelled",
"runsPhaseUnknown": "Unknown",
"navRuns": "Runs"
}

View file

@ -4908,6 +4908,96 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.'**
String get workspaceProtectedHint;
/// No description provided for @runsTitle.
///
/// In en, this message translates to:
/// **'Runs'**
String get runsTitle;
/// No description provided for @runsReloadTooltip.
///
/// In en, this message translates to:
/// **'Reload the runs list'**
String get runsReloadTooltip;
/// No description provided for @runsEmptyTitle.
///
/// In en, this message translates to:
/// **'No detached runs'**
String get runsEmptyTitle;
/// No description provided for @runsEmptyHint.
///
/// In en, this message translates to:
/// **'Runs submitted with detach stay here until they finish. Detached invocations are opt-in — enable detached.enabled in the operator config to use them.'**
String get runsEmptyHint;
/// No description provided for @runsCancelButton.
///
/// In en, this message translates to:
/// **'Cancel'**
String get runsCancelButton;
/// No description provided for @runsCancelSignalled.
///
/// In en, this message translates to:
/// **'Cancel signalled for {flow}.'**
String runsCancelSignalled(String flow);
/// No description provided for @runsCancelTooLate.
///
/// In en, this message translates to:
/// **'The run had already finished — nothing to cancel.'**
String get runsCancelTooLate;
/// No description provided for @runsCurrentStep.
///
/// In en, this message translates to:
/// **'Step: {step}'**
String runsCurrentStep(String step);
/// No description provided for @runsPhasePending.
///
/// In en, this message translates to:
/// **'Pending'**
String get runsPhasePending;
/// No description provided for @runsPhaseRunning.
///
/// In en, this message translates to:
/// **'Running'**
String get runsPhaseRunning;
/// No description provided for @runsPhaseSucceeded.
///
/// In en, this message translates to:
/// **'Succeeded'**
String get runsPhaseSucceeded;
/// No description provided for @runsPhaseFailed.
///
/// In en, this message translates to:
/// **'Failed'**
String get runsPhaseFailed;
/// No description provided for @runsPhaseCancelled.
///
/// In en, this message translates to:
/// **'Cancelled'**
String get runsPhaseCancelled;
/// No description provided for @runsPhaseUnknown.
///
/// In en, this message translates to:
/// **'Unknown'**
String get runsPhaseUnknown;
/// No description provided for @navRuns.
///
/// In en, this message translates to:
/// **'Runs'**
String get navRuns;
}
class _AppLocalizationsDelegate

View file

@ -2882,4 +2882,55 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get workspaceProtectedHint =>
'Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.';
@override
String get runsTitle => 'Läufe';
@override
String get runsReloadTooltip => 'Lauf-Liste neu laden';
@override
String get runsEmptyTitle => 'Keine abgekoppelten Läufe';
@override
String get runsEmptyHint =>
'Mit detach gestartete Läufe stehen hier, bis sie fertig sind. Abgekoppelte Aufrufe sind optional — dazu detached.enabled in der Betreiber-Konfiguration aktivieren.';
@override
String get runsCancelButton => 'Abbrechen';
@override
String runsCancelSignalled(String flow) {
return 'Abbruch für $flow ausgelöst.';
}
@override
String get runsCancelTooLate =>
'Der Lauf war schon fertig — nichts abzubrechen.';
@override
String runsCurrentStep(String step) {
return 'Schritt: $step';
}
@override
String get runsPhasePending => 'Wartet';
@override
String get runsPhaseRunning => 'Läuft';
@override
String get runsPhaseSucceeded => 'Erfolgreich';
@override
String get runsPhaseFailed => 'Fehlgeschlagen';
@override
String get runsPhaseCancelled => 'Abgebrochen';
@override
String get runsPhaseUnknown => 'Unbekannt';
@override
String get navRuns => 'Läufe';
}

View file

@ -2885,4 +2885,55 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get workspaceProtectedHint =>
'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.';
@override
String get runsTitle => 'Runs';
@override
String get runsReloadTooltip => 'Reload the runs list';
@override
String get runsEmptyTitle => 'No detached runs';
@override
String get runsEmptyHint =>
'Runs submitted with detach stay here until they finish. Detached invocations are opt-in — enable detached.enabled in the operator config to use them.';
@override
String get runsCancelButton => 'Cancel';
@override
String runsCancelSignalled(String flow) {
return 'Cancel signalled for $flow.';
}
@override
String get runsCancelTooLate =>
'The run had already finished — nothing to cancel.';
@override
String runsCurrentStep(String step) {
return 'Step: $step';
}
@override
String get runsPhasePending => 'Pending';
@override
String get runsPhaseRunning => 'Running';
@override
String get runsPhaseSucceeded => 'Succeeded';
@override
String get runsPhaseFailed => 'Failed';
@override
String get runsPhaseCancelled => 'Cancelled';
@override
String get runsPhaseUnknown => 'Unknown';
@override
String get navRuns => 'Runs';
}

View file

@ -21,6 +21,7 @@ import 'pages/audit.dart';
import 'pages/doctor.dart';
import 'pages/federation.dart';
import 'pages/flows.dart';
import 'pages/runs.dart';
import 'pages/store.dart';
import 'pages/welcome.dart';
import 'theme/theme.dart';
@ -380,6 +381,12 @@ class StudioShellState extends State<StudioShell> {
selectedIcon: Icons.inbox,
page: ApprovalsPage(),
),
_NavPage(
id: 'runs',
icon: Icons.rocket_launch_outlined,
selectedIcon: Icons.rocket_launch,
page: RunsPage(),
),
_NavPage(
id: 'federation',
icon: Icons.hub_outlined,
@ -1641,6 +1648,8 @@ class _NavPage {
return l.navAudit;
case 'approvals':
return l.navApprovals;
case 'runs':
return l.navRuns;
case 'federation':
return l.navFederation;
default:

243
lib/pages/runs.dart Normal file
View file

@ -0,0 +1,243 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
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();
}
class _RunsPageState extends State<RunsPage> {
List<DetachedRun> _runs = const [];
String? _error;
bool _loaded = false;
Timer? _poll;
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 runs = await HubService.instance.listDetachedRuns(
project: Workspace.instance.activeSlug,
);
if (!mounted) return;
setState(() {
_runs = runs;
_error = null;
_loaded = true;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_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));
}
}
@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
? ChainEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
)
: _runs.isEmpty
? ChainEmptyState(
icon: Icons.rocket_launch_outlined,
title: l.runsEmptyTitle,
hint: l.runsEmptyHint,
)
: 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);
}
}
}