feat: workspace switcher, per-project filters and run stamping (stage 1)
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
7a38cd58aa
commit
2592a23cc0
14 changed files with 554 additions and 9 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
100
lib/data/workspace.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue