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

@ -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 = '',
});
}