feat: workspace switcher, per-project filters and run stamping (stage 1)
Some checks failed
Security / Security check (push) Failing after 2s

Multi-project stage 1 against the shared hub (platform design
docs/architecture/projects.md, § Studio):

- ChainWorkspaceSwitcher in the Audit + Approvals AppBars: lists the
  registry (colour dot per project, shield for protected, honesty
  tooltip), 'All projects' stays reachable — a filter, not a jail.
  Selection is persisted and shared via the Workspace notifier.
- Audit page: list query AND live stream re-scoped hub-side on switch.
- Approvals page: pending + history scoped; the sidebar badge counts
  the active workspace's pending approvals.
- Flow runs are stamped with the active workspace; a flow file
  carrying its own project: keeps it (file wins, CLI semantics).
- Data layer: listProjects/ProjectRef; project fields on AuditEvent,
  PendingApproval(+Record), SavedFlow; project params through
  HubService. l10n DE+EN. Widget tests for the switcher contract.

Visual verification (light+dark screenshots) still pending — the
shared desktop was in active use; code paths are covered by
flutter test (26 green).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-12 13:49:51 +02:00
parent 7a38cd58aa
commit 2592a23cc0
14 changed files with 554 additions and 9 deletions

View file

@ -16,6 +16,7 @@ import 'dart:typed_data';
import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart' as editor;
import 'hub.dart';
import 'workspace.dart';
class StudioFlowRunDriver implements editor.FlowRunDriver {
StudioFlowRunDriver();
@ -27,11 +28,31 @@ class StudioFlowRunDriver implements editor.FlowRunDriver {
required Map<String, Uint8List> fileInputs,
required Map<String, String> fileMimes,
}) async {
// Stamp the run with the active workspace but the file wins:
// a flow carrying its own `project:` keeps it (same semantics
// as the CLI), so the override is only sent when the flow file
// has none. Resolution order hub-side is request > file > general.
var project = Workspace.instance.activeSlug;
if (project.isNotEmpty) {
try {
final flows = await HubService.instance.listFlows();
for (final f in flows) {
if (f.name == flowName && f.project.isNotEmpty) {
project = '';
break;
}
}
} catch (_) {
// Lookup failed keep the workspace label. An unreachable
// hub fails the run itself a moment later anyway.
}
}
final outputs = await HubService.instance.runSavedFlow(
name: flowName,
textInputs: textInputs,
fileInputs: fileInputs,
fileMimeTypes: fileMimes,
project: project,
);
final mapped = <String, editor.FlowOutputValue>{};
for (final entry in outputs.entries) {

View file

@ -808,6 +808,25 @@ class HubService {
return (name: r.name, version: r.version);
}
/// The shared hub's project registry (`general` first) — the
/// lightweight labels grouping flows, runs, approvals and audit
/// events. Sealed areas never appear here: they are their own
/// hub instance Studio connects to directly (stage 3).
Future<List<ProjectRef>> listProjects() async {
final projects = await _client.listProjects();
return projects
.map(
(p) => ProjectRef(
slug: p.slug,
name: p.name,
color: p.color,
description: p.description,
isolation: p.isolation,
),
)
.toList();
}
/// Saved flows known to the hub.
Future<List<SavedFlow>> listFlows() async {
final flows = await _client.listFlows();
@ -818,6 +837,7 @@ class HubService {
path: f.path,
sizeBytes: f.sizeBytes.toInt(),
requiredCapabilities: List<String>.from(f.requiredCapabilities),
project: f.project,
),
)
.toList()
@ -849,12 +869,14 @@ class HubService {
Map<String, String> textInputs = const {},
Map<String, Uint8List> fileInputs = const {},
Map<String, String> fileMimeTypes = const {},
String project = '',
}) async {
final r = await _client.runSavedFlow(
name: name,
textInputs: textInputs,
fileInputs: fileInputs,
fileMimeTypes: fileMimeTypes,
project: project,
);
return {
for (final entry in r.outputs.entries)
@ -874,9 +896,10 @@ class HubService {
Stream<AuditEvent> streamEvents({
int backfill = 0,
List<String> types = const [],
String project = '',
}) {
return _client
.streamEvents(backfill: backfill, types: types)
.streamEvents(backfill: backfill, types: types, project: project)
.map(
(e) => AuditEvent(
eventId: e.eventId,
@ -891,6 +914,7 @@ class HubService {
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
error: e.error.isEmpty ? null : e.error,
detail: e.detail.isEmpty ? null : e.detail,
project: e.project,
),
);
}
@ -898,8 +922,13 @@ class HubService {
Future<List<AuditEvent>> recentEvents({
int limit = 50,
List<String> types = const [],
String project = '',
}) async {
final events = await _client.eventLog(limit: limit, types: types);
final events = await _client.eventLog(
limit: limit,
types: types,
project: project,
);
return events
.map(
(e) => AuditEvent(
@ -915,13 +944,17 @@ class HubService {
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
error: e.error.isEmpty ? null : e.error,
detail: e.detail.isEmpty ? null : e.detail,
project: e.project,
),
)
.toList();
}
Future<List<PendingApproval>> pendingApprovals() async {
final entries = await _client.listApprovals(statuses: ['pending']);
Future<List<PendingApproval>> pendingApprovals({String project = ''}) async {
final entries = await _client.listApprovals(
statuses: ['pending'],
project: project,
);
return entries
.map(
(e) => PendingApproval(
@ -934,6 +967,7 @@ class HubService {
expiresAt: e.expiresAt.isEmpty
? null
: DateTime.tryParse(e.expiresAt),
project: e.project,
),
)
.toList();
@ -946,10 +980,12 @@ class HubService {
Future<List<ApprovalRecord>> listApprovalsRecords({
List<String> statuses = const [],
int limit = 200,
String project = '',
}) async {
final entries = await _client.listApprovals(
statuses: statuses,
limit: limit,
project: project,
);
return entries
.map(
@ -969,6 +1005,7 @@ class HubService {
: DateTime.tryParse(e.decidedAt),
decidedBy: e.decidedBy,
reason: e.reason,
project: e.project,
),
)
.toList();
@ -1231,6 +1268,43 @@ class ModuleSummary {
});
}
/// One registered project (shared-hub registry row): the stable
/// slug plus renamable presentation metadata. `isolation` is
/// `open` (pure label) or `protected` (logically separated no
/// hard process barrier; every UI keeps that honest wording).
class ProjectRef {
final String slug;
final String name;
/// Accent colour hex (e.g. `#8b7cf6`), empty when unset. Used
/// as a marking dot never as the app theme.
final String color;
final String description;
final String isolation;
const ProjectRef({
required this.slug,
required this.name,
this.color = '',
this.description = '',
this.isolation = 'open',
});
bool get isProtected => isolation == 'protected';
@override
bool operator ==(Object other) =>
other is ProjectRef &&
other.slug == slug &&
other.name == name &&
other.color == color &&
other.description == description &&
other.isolation == isolation;
@override
int get hashCode => Object.hash(slug, name, color, description, isolation);
}
class SavedFlow {
final String name;
final String path;
@ -1242,11 +1316,17 @@ class SavedFlow {
/// resolves to an installed module).
final List<String> requiredCapabilities;
/// The flow file's own top-level `project:` slug; empty when
/// the YAML has none (a run then lands in the caller's
/// project, then `general`).
final String project;
const SavedFlow({
required this.name,
required this.path,
required this.sizeBytes,
required this.requiredCapabilities,
this.project = '',
});
}
@ -1335,6 +1415,10 @@ class AuditEvent {
/// Surfaced verbatim in the audit drill-down dialog.
final String? detail;
/// Project slug the run was stamped with (bound into the V4
/// audit hash). Empty on rows pre-dating the project migration.
final String project;
const AuditEvent({
required this.eventId,
required this.timestamp,
@ -1348,6 +1432,7 @@ class AuditEvent {
this.durationMs,
this.error,
this.detail,
this.project = '',
});
}
@ -1360,6 +1445,9 @@ class PendingApproval {
final DateTime createdAt;
final DateTime? expiresAt;
/// Project slug the requesting run was stamped with.
final String project;
const PendingApproval({
required this.id,
required this.flowName,
@ -1368,6 +1456,7 @@ class PendingApproval {
this.showPreview,
required this.createdAt,
this.expiresAt,
this.project = '',
});
}
@ -1439,6 +1528,9 @@ class ApprovalRecord {
/// Reject reason empty for approve / pending / expired.
final String reason;
/// Project slug the requesting run was stamped with.
final String project;
const ApprovalRecord({
required this.id,
required this.flowName,
@ -1451,6 +1543,7 @@ class ApprovalRecord {
required this.decidedAt,
required this.decidedBy,
required this.reason,
this.project = '',
});
}

100
lib/data/workspace.dart Normal file
View file

@ -0,0 +1,100 @@
// Workspace the active project selection shared across pages.
//
// One switcher in the AppBar drives two things at once (see
// docs/architecture/projects.md in the platform repo, § Studio):
// the page filter (audit, approvals) AND the label new runs are
// stamped with. "All projects" stays reachable the selection is
// a filter, not a jail. Sealed areas are NOT listed here; they are
// their own hub instance and arrive with the stage-3 connection
// switch.
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'hub.dart';
class Workspace extends ChangeNotifier {
Workspace._();
static final Workspace instance = Workspace._();
static const _prefsKey = 'workspace.active';
/// Registry projects (`general` first, as ordered by the hub).
List<ProjectRef> _projects = const [];
List<ProjectRef> get projects => _projects;
/// Active project slug; empty = all projects (no filter).
String _activeSlug = '';
String get activeSlug => _activeSlug;
bool get isAll => _activeSlug.isEmpty;
/// The active project's registry entry, or null for "all".
ProjectRef? get active {
if (_activeSlug.isEmpty) return null;
for (final p in _projects) {
if (p.slug == _activeSlug) return p;
}
return null;
}
bool _loaded = false;
/// Restore the persisted selection and pull the registry once.
/// Safe to call repeatedly; only the first call restores.
Future<void> ensureLoaded() async {
if (!_loaded) {
_loaded = true;
try {
final prefs = await SharedPreferences.getInstance();
_activeSlug = prefs.getString(_prefsKey) ?? '';
} catch (_) {
// No persisted selection start on "all projects".
}
}
await refresh();
}
/// Re-pull the registry from the hub. Soft-fails (keeps the
/// last list) so a hub restart doesn't blank the switcher; an
/// active slug that vanished from the registry stays selected
/// its historical rows remain filterable, which is the honest
/// behaviour for an audit surface.
Future<void> refresh() async {
try {
final fresh = await HubService.instance.listProjects();
if (!listEquals(fresh, _projects)) {
_projects = fresh;
notifyListeners();
}
} catch (_) {
// Hub unreachable the pages already surface that state.
}
}
/// Test hook: seed registry + selection without a hub and mark
/// the persisted state as restored so [ensureLoaded] won't
/// overwrite the seed.
@visibleForTesting
void debugSeed({
required List<ProjectRef> projects,
required String active,
}) {
_projects = projects;
_activeSlug = active;
_loaded = true;
notifyListeners();
}
/// Switch the active workspace ('' = all projects) and persist.
Future<void> setActive(String slug) async {
if (slug == _activeSlug) return;
_activeSlug = slug;
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, slug);
} catch (_) {
// Persistence is best-effort; the in-memory switch stands.
}
}
}

View file

@ -1664,5 +1664,8 @@
"federationTokenLabel": "Bootstrap-Token (einmalig)",
"federationConfigLabel": "Satelliten-Konfiguration (in den Satelliten einfügen)",
"federationCopied": "In die Zwischenablage kopiert",
"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",
"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."
}

View file

@ -1703,5 +1703,8 @@
"federationTokenLabel": "Bootstrap token (single use)",
"federationConfigLabel": "Satellite config (paste into the satellite)",
"federationCopied": "Copied to clipboard",
"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",
"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."
}

View file

@ -4890,6 +4890,24 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite\'s first connect.'**
String get federationEnrollmentHint;
/// No description provided for @workspaceAll.
///
/// In en, this message translates to:
/// **'All projects'**
String get workspaceAll;
/// No description provided for @workspaceSwitcherTooltip.
///
/// In en, this message translates to:
/// **'Workspace — filters this view and stamps new runs with the selected project'**
String get workspaceSwitcherTooltip;
/// No description provided for @workspaceProtectedHint.
///
/// 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;
}
class _AppLocalizationsDelegate

View file

@ -2871,4 +2871,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get federationEnrollmentHint =>
'Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten.';
@override
String get workspaceAll => 'Alle Projekte';
@override
String get workspaceSwitcherTooltip =>
'Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt';
@override
String get workspaceProtectedHint =>
'Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.';
}

View file

@ -2874,4 +2874,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get federationEnrollmentHint =>
'Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite\'s first connect.';
@override
String get workspaceAll => 'All projects';
@override
String get workspaceSwitcherTooltip =>
'Workspace — filters this view and stamps new runs with the selected project';
@override
String get workspaceProtectedHint =>
'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.';
}

View file

@ -12,6 +12,7 @@ import 'package:flutter/services.dart';
import 'data/chain_log.dart';
import 'data/error_presentation.dart';
import 'data/hub.dart';
import 'data/workspace.dart';
import 'data/system_actions.dart';
import 'data/theme_plugin.dart';
import 'l10n/app_localizations.dart';
@ -425,7 +426,11 @@ class StudioShellState extends State<StudioShell> {
}
} catch (_) {/* best-effort */}
try {
final pending = await HubService.instance.pendingApprovals();
// The badge counts the active workspace's pending approvals
// (empty slug = all projects), matching the Approvals page.
final pending = await HubService.instance.pendingApprovals(
project: Workspace.instance.activeSlug,
);
if (!mounted) return;
if (_pendingApprovals != pending.length) {
setState(() => _pendingApprovals = pending.length);

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
@ -63,23 +64,30 @@ class _ApprovalsPageState extends State<ApprovalsPage>
void initState() {
super.initState();
_tab = TabController(length: 2, vsync: this);
Workspace.instance.addListener(_refresh);
Workspace.instance.ensureLoaded();
_refresh();
}
@override
void dispose() {
Workspace.instance.removeListener(_refresh);
_tab.dispose();
super.dispose();
}
void _refresh() {
if (!mounted) return;
final project = Workspace.instance.activeSlug;
setState(() {
_pendingFuture = HubService.instance.listApprovalsRecords(
statuses: const ['pending'],
project: project,
);
_historyFuture = HubService.instance.listApprovalsRecords(
statuses: const ['approved', 'rejected', 'expired'],
limit: 200,
project: project,
);
});
}
@ -256,6 +264,8 @@ class _ApprovalsPageState extends State<ApprovalsPage>
],
),
actions: [
const ChainWorkspaceSwitcher(),
const SizedBox(width: ChainSpace.md),
IconButton(
icon: const Icon(Icons.help_outline, size: 18),
tooltip: AppLocalizations.of(context)!.helpTooltip,

View file

@ -6,6 +6,7 @@ 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/theme.dart';
import '../theme/tokens.dart';
@ -38,6 +39,8 @@ class _AuditPageState extends State<AuditPage> {
@override
void initState() {
super.initState();
Workspace.instance.addListener(_onWorkspaceChanged);
Workspace.instance.ensureLoaded();
_refresh();
_poller = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
_subscribeLive();
@ -45,15 +48,26 @@ class _AuditPageState extends State<AuditPage> {
@override
void dispose() {
Workspace.instance.removeListener(_onWorkspaceChanged);
_poller?.cancel();
_nudgeDebounce?.cancel();
_eventSub?.cancel();
super.dispose();
}
/// The workspace filter is applied hub-side, so both the list
/// query and the live stream have to be re-established.
void _onWorkspaceChanged() {
if (!mounted) return;
_refresh();
_subscribeLive();
}
void _subscribeLive() {
_eventSub?.cancel();
_eventSub = HubService.instance.streamEvents(backfill: 0).listen(
_eventSub = HubService.instance
.streamEvents(backfill: 0, project: Workspace.instance.activeSlug)
.listen(
(_) => _nudge(),
// On a persistent error the 2 s poll keeps the page fresh, so we
// do nothing. On a clean close (hub restart / stream end) try to
@ -108,7 +122,10 @@ class _AuditPageState extends State<AuditPage> {
Future<void> _refresh() async {
try {
final events = await HubService.instance.recentEvents(limit: 100);
final events = await HubService.instance.recentEvents(
limit: 100,
project: Workspace.instance.activeSlug,
);
if (!mounted) return;
setState(() {
_events = events;
@ -136,6 +153,8 @@ class _AuditPageState extends State<AuditPage> {
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.auditTitle),
actions: [
const ChainWorkspaceSwitcher(),
const SizedBox(width: ChainSpace.md),
Padding(
padding: const EdgeInsets.only(right: ChainSpace.lg),
child: _FilterChips(

View file

@ -0,0 +1,163 @@
// ChainWorkspaceSwitcher the AppBar workspace (project) control.
//
// One control, two mechanics (docs/architecture/projects.md,
// § Studio): for open/protected projects the choice sets the page
// filter AND the label new runs are stamped with. "All projects"
// stays reachable a filter, not a jail. Sealed areas never
// appear here (own hub instance; stage-3 connection switch).
//
// The project colour is a marking dot only Studio's blue stays
// the app accent (registered design deviation).
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart';
class ChainWorkspaceSwitcher extends StatelessWidget {
const ChainWorkspaceSwitcher({super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Workspace.instance,
builder: (context, _) {
final l = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final ws = Workspace.instance;
final active = ws.active;
final label = ws.isAll
? l.workspaceAll
: (active?.name ?? ws.activeSlug);
return Tooltip(
message: l.workspaceSwitcherTooltip,
child: PopupMenuButton<String>(
onOpened: ws.refresh,
onSelected: ws.setActive,
itemBuilder: (context) => [
PopupMenuItem(
value: '',
child: Row(
children: [
Icon(
Icons.grid_view_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(l.workspaceAll),
],
),
),
if (ws.projects.isNotEmpty) const PopupMenuDivider(),
for (final p in ws.projects)
PopupMenuItem(
value: p.slug,
child: Row(
children: [
_ProjectDot(project: p),
const SizedBox(width: ChainSpace.sm),
Flexible(
child: Text(p.name, overflow: TextOverflow.ellipsis),
),
if (p.isProtected) ...[
const SizedBox(width: ChainSpace.sm),
Tooltip(
message: l.workspaceProtectedHint,
child: Icon(
Icons.shield_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.md,
vertical: 5,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(ChainRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (active != null) ...[
_ProjectDot(project: active),
const SizedBox(width: 6),
] else ...[
Icon(
Icons.grid_view_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
],
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium,
),
),
if (active?.isProtected ?? false) ...[
const SizedBox(width: 4),
Icon(
Icons.shield_outlined,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
],
const SizedBox(width: 2),
Icon(
Icons.arrow_drop_down,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
);
},
);
}
}
/// The project's registry colour as a small marking dot. Falls
/// back to the theme's outline colour when the project has none.
class _ProjectDot extends StatelessWidget {
final ProjectRef project;
const _ProjectDot({required this.project});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _parseHex(project.color) ?? theme.colorScheme.outline,
shape: BoxShape.circle,
),
);
}
static Color? _parseHex(String hex) {
final h = hex.replaceFirst('#', '');
if (h.length != 6) return null;
final v = int.tryParse(h, radix: 16);
if (v == null) return null;
return Color(0xFF000000 | v);
}
}

View file

@ -19,3 +19,4 @@ export 'chain_settings_dialog.dart';
export 'chain_stores_dialog.dart';
export 'chain_status_dot.dart';
export 'chain_system_ai_editor.dart';
export 'chain_workspace_switcher.dart';