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

@ -5,6 +5,16 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep.
## Unreleased ## Unreleased
### Added (detached-runs monitor — T3 parity)
- **Runs page.** A new sidebar destination lists detached invocations
(submitted with `detach: true`) with their phase, current step,
project and a **Cancel** button while pending/running. Workspace-
scoped like Audit and Approvals; polls every 2 s. Detached runs are
opt-in (`detached.enabled`), so the empty state explains how to turn
them on. Inline help doc (DE+EN). Backed by the SDK's
`listInvocations()` + `cancelInvocation()`.
### Added (multi-project, stages ① + ②) ### Added (multi-project, stages ① + ②)
- **Workspace switcher.** The Audit and Approvals AppBars carry a - **Workspace switcher.** The Audit and Approvals AppBars carry a

45
assets/docs/runs.md Normal file
View file

@ -0,0 +1,45 @@
# Runs
The Runs page monitors **detached invocations** — flows submitted
to run in the background rather than being waited on. Each row shows
the flow, its current phase, the step it is on, and the project it
was stamped with.
## What a detached run is
Most flows run *inline*: you start them and wait for the result. A
**detached** run is handed to the hub and returns an id immediately;
the flow keeps running in the background. You come back later to
read its result, follow it live, or cancel it.
Detached invocations are **opt-in**. They only work when the
operator enabled them in the hub config:
```yaml
detached:
enabled: true
```
Until then this page is empty — that is the normal state.
## Phases
- **Pending** — accepted, not yet executing.
- **Running** — a step is executing (the row names it).
- **Succeeded** — finished; the result is retained under the hub's
size/count/TTL limits.
- **Failed** — finished with an error (shown on the row).
- **Cancelled** — stopped by an operator.
## Cancelling
A pending or running detached run carries a **Cancel** button.
Cancelling signals the run to stop; a run that already finished
cannot be cancelled (Studio says so rather than pretending).
## Workspace scope
The page respects the active workspace: with a project selected it
shows only that project's detached runs. Detached results are held
in memory for the current hub process — they are not retained across
a hub restart.

48
assets/docs/runs_de.md Normal file
View file

@ -0,0 +1,48 @@
# Läufe
Die Läufe-Seite überwacht **abgekoppelte Aufrufe** — Flows, die im
Hintergrund laufen, statt auf ihr Ergebnis zu warten. Jede Zeile
zeigt den Flow, seine Phase, den aktuellen Schritt und das Projekt,
mit dem er gestempelt wurde.
## Was ein abgekoppelter Lauf ist
Die meisten Flows laufen *inline*: Man startet sie und wartet auf
das Ergebnis. Ein **abgekoppelter** Lauf wird dem Hub übergeben und
liefert sofort eine Kennung zurück; der Flow läuft im Hintergrund
weiter. Man kommt später zurück, um das Ergebnis zu lesen, dem Lauf
live zu folgen oder ihn abzubrechen.
Abgekoppelte Aufrufe sind **optional**. Sie funktionieren nur, wenn
der Betreiber sie in der Hub-Konfiguration aktiviert hat:
```yaml
detached:
enabled: true
```
Bis dahin ist diese Seite leer — das ist der Normalzustand.
## Phasen
- **Wartet** — angenommen, noch nicht in Ausführung.
- **Läuft** — ein Schritt wird ausgeführt (die Zeile nennt ihn).
- **Erfolgreich** — fertig; das Ergebnis wird unter den
Größen-/Anzahl-/TTL-Grenzen des Hubs aufbewahrt.
- **Fehlgeschlagen** — mit einem Fehler beendet (in der Zeile
angezeigt).
- **Abgebrochen** — von einem Betreiber gestoppt.
## Abbrechen
Ein wartender oder laufender abgekoppelter Lauf trägt einen
**Abbrechen**-Knopf. Der Abbruch signalisiert dem Lauf zu stoppen;
ein bereits beendeter Lauf lässt sich nicht abbrechen (Studio sagt
das ehrlich, statt es vorzutäuschen).
## Arbeitsbereich-Filter
Die Seite berücksichtigt den aktiven Arbeitsbereich: Mit gewähltem
Projekt zeigt sie nur dessen abgekoppelte Läufe. Abgekoppelte
Ergebnisse werden im Speicher des laufenden Hub-Prozesses gehalten —
sie überstehen keinen Hub-Neustart.

View file

@ -1017,6 +1017,23 @@ class HubService {
Future<void> reject(String id, String reviewer, String reason) => Future<void> reject(String id, String reviewer, String reason) =>
_client.reject(approvalId: id, reviewer: reviewer, reason: 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 /// Federation satellites currently connected to this hub
/// (primary side). Empty when none are connected. /// (primary side). Empty when none are connected.
Future<List<Satellite>> listSatellites() async { 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 { class SystemAiStatus {
final bool enabled; final bool enabled;
final String provider; 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.", "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten.",
"workspaceAll": "Alle Projekte", "workspaceAll": "Alle Projekte",
"workspaceSwitcherTooltip": "Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt", "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.", "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.",
"workspaceAll": "All projects", "workspaceAll": "All projects",
"workspaceSwitcherTooltip": "Workspace — filters this view and stamps new runs with the selected project", "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: /// In en, this message translates to:
/// **'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.'** /// **'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.'**
String get workspaceProtectedHint; 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 class _AppLocalizationsDelegate

View file

@ -2882,4 +2882,55 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get workspaceProtectedHint => String get workspaceProtectedHint =>
'Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.'; '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 @override
String get workspaceProtectedHint => String get workspaceProtectedHint =>
'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.'; '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/doctor.dart';
import 'pages/federation.dart'; import 'pages/federation.dart';
import 'pages/flows.dart'; import 'pages/flows.dart';
import 'pages/runs.dart';
import 'pages/store.dart'; import 'pages/store.dart';
import 'pages/welcome.dart'; import 'pages/welcome.dart';
import 'theme/theme.dart'; import 'theme/theme.dart';
@ -380,6 +381,12 @@ class StudioShellState extends State<StudioShell> {
selectedIcon: Icons.inbox, selectedIcon: Icons.inbox,
page: ApprovalsPage(), page: ApprovalsPage(),
), ),
_NavPage(
id: 'runs',
icon: Icons.rocket_launch_outlined,
selectedIcon: Icons.rocket_launch,
page: RunsPage(),
),
_NavPage( _NavPage(
id: 'federation', id: 'federation',
icon: Icons.hub_outlined, icon: Icons.hub_outlined,
@ -1641,6 +1648,8 @@ class _NavPage {
return l.navAudit; return l.navAudit;
case 'approvals': case 'approvals':
return l.navApprovals; return l.navApprovals;
case 'runs':
return l.navRuns;
case 'federation': case 'federation':
return l.navFederation; return l.navFederation;
default: 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);
}
}
}

View file

@ -0,0 +1,96 @@
// DetachedRun model the phase mapping + cancellable predicate that
// drive the runs monitor's chip tone and the cancel-button visibility.
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/data/hub.dart';
// The generated enum type isn't re-exported by the SDK's public
// surface (only the message typedefs are), so build fixtures against
// the generated file directly test-only.
import 'package:chain_client_sdk/src/generated/chain/v1/hub.pb.dart';
InvocationEntry _entry({
required String id,
required int phaseValue,
String flow = 'demo',
String project = 'general',
String step = '',
String error = '',
}) {
final status = InvocationStatus()
..phase = InvocationStatus_Phase.valueOf(phaseValue)!
..currentStep = step
..startedAt = '2026-07-12T10:00:00Z'
..error = error;
return InvocationEntry()
..invocationId = id
..flowName = flow
..project = project
..status = status;
}
void main() {
test('maps each wire phase to the model phase', () {
expect(
DetachedRun.fromEntry(_entry(id: 'a', phaseValue: 1)).phase,
DetachedPhase.pending,
);
expect(
DetachedRun.fromEntry(_entry(id: 'b', phaseValue: 2)).phase,
DetachedPhase.running,
);
expect(
DetachedRun.fromEntry(_entry(id: 'c', phaseValue: 3)).phase,
DetachedPhase.succeeded,
);
expect(
DetachedRun.fromEntry(_entry(id: 'd', phaseValue: 4)).phase,
DetachedPhase.failed,
);
expect(
DetachedRun.fromEntry(_entry(id: 'e', phaseValue: 5)).phase,
DetachedPhase.cancelled,
);
expect(
DetachedRun.fromEntry(_entry(id: 'f', phaseValue: 0)).phase,
DetachedPhase.unknown,
);
});
test('only pending and running runs are cancellable', () {
expect(
DetachedRun.fromEntry(_entry(id: 'a', phaseValue: 1)).isCancellable,
isTrue,
);
expect(
DetachedRun.fromEntry(_entry(id: 'b', phaseValue: 2)).isCancellable,
isTrue,
);
for (final terminal in [3, 4, 5]) {
expect(
DetachedRun.fromEntry(_entry(id: 'x', phaseValue: terminal))
.isCancellable,
isFalse,
);
}
});
test('carries flow name, project, step and error through', () {
final run = DetachedRun.fromEntry(
_entry(
id: 'inv-1',
phaseValue: 4,
flow: 'intake',
project: 'client-a',
step: 'extract',
error: 'boom',
),
);
expect(run.id, 'inv-1');
expect(run.flowName, 'intake');
expect(run.project, 'client-a');
expect(run.currentStep, 'extract');
expect(run.error, 'boom');
expect(run.startedAt, isNotNull);
});
}

View file

@ -23,6 +23,7 @@ const _destinations = <String>[
'flows', 'flows',
'audit', 'audit',
'approvals', 'approvals',
'runs',
'federation', 'federation',
]; ];