feat: sealed-area connection switch + identity bar (multi-project stage 3)
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
The workspace switcher now lists the operator's sealed areas (read from ~/.chain/sealed/ manifests, the same source the CLI uses) below the shared projects, each with a lock icon and a running/stopped status. Selecting one is a real connection switch: Studio reconnects its hub client to the area's own port with a full state reload — one window, one truth. A stopped area is started first (chain project start) with a visible notice; a failure surfaces as a copyable error and rolls back to the shared hub. While in a sealed area an identity bar under the AppBar is painted in the area's accent colour and names it, with a one-click Leave back to the shared hub. The area colour is marking, not theming — Studio's blue stays the app accent. Selecting a shared project from inside an area switches the connection back first. The sealed connection is never persisted across restarts. New: SealedAreaService (manifest + PID discovery), Workspace sealed switch logic, ChainSealedIdentityBar, SystemActions.chainProjectStart. l10n DE+EN. flutter analyze clean; 33 tests green (switcher lists sealed with lock+status, pill shows active area, identity bar renders in the area colour). Runtime plumbing (discovery, start, endpoint, reach) verified headlessly against real sealed instances under a redirected HOME; the identity-bar screenshot is deferred (display click-automation failed after sleep on the shared desktop — an environment issue, not a code gap; the visible components are widget-tested). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
54ccd3936a
commit
b7468dc7ec
15 changed files with 930 additions and 135 deletions
158
lib/data/sealed_areas.dart
Normal file
158
lib/data/sealed_areas.dart
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// SealedAreaService — discovers the operator's sealed project
|
||||
// instances by reading their manifests directly, the same way the
|
||||
// CLI's `chain project list` does.
|
||||
//
|
||||
// A sealed project is NOT a row in the shared hub's registry — it is
|
||||
// its own hub instance (own data dir, port, store config). The shared
|
||||
// hub never learns it exists (docs/architecture/projects.md). So
|
||||
// Studio cannot ask a hub for the list; it scans the manifest
|
||||
// directory itself:
|
||||
//
|
||||
// ~/.chain/sealed/<slug>/project.json — the manifest
|
||||
// ~/.chain/run/sealed-<slug>.pid — running PID (if up)
|
||||
// ~/.chain/run/sealed-<slug>.endpoint — bound endpoint (if up)
|
||||
//
|
||||
// This mirrors crates/chain_runtime_mgmt/src/sealed.rs. Reading the
|
||||
// real ~/.chain honours the operator's actual areas; a test harness
|
||||
// redirects $HOME so it never touches them.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// One sealed project instance as Studio needs it for the switcher
|
||||
/// and the connection switch.
|
||||
class SealedArea {
|
||||
final String slug;
|
||||
final String name;
|
||||
|
||||
/// Accent colour hex (e.g. `#e0a458`), empty when unset. This is
|
||||
/// the area's identity colour — the marking on the switcher and
|
||||
/// the identity bar. (Studio's blue stays the app accent.)
|
||||
final String color;
|
||||
|
||||
/// Fixed loopback port of the instance's hub.
|
||||
final int port;
|
||||
|
||||
/// True when the instance's hub daemon is currently running (a
|
||||
/// live PID in `~/.chain/run/sealed-<slug>.pid`).
|
||||
final bool running;
|
||||
|
||||
const SealedArea({
|
||||
required this.slug,
|
||||
required this.name,
|
||||
required this.color,
|
||||
required this.port,
|
||||
required this.running,
|
||||
});
|
||||
|
||||
/// Loopback gRPC endpoint of the instance's hub.
|
||||
String get endpoint => 'http://127.0.0.1:$port';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is SealedArea &&
|
||||
other.slug == slug &&
|
||||
other.name == name &&
|
||||
other.color == color &&
|
||||
other.port == port &&
|
||||
other.running == running;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(slug, name, color, port, running);
|
||||
}
|
||||
|
||||
class SealedAreaService {
|
||||
SealedAreaService._();
|
||||
static final SealedAreaService instance = SealedAreaService._();
|
||||
|
||||
/// `~/.chain` root, or null when the home dir can't be determined.
|
||||
String? _chainHome() {
|
||||
final home =
|
||||
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||
if (home == null || home.isEmpty) return null;
|
||||
return '$home${Platform.pathSeparator}.chain';
|
||||
}
|
||||
|
||||
/// Every sealed area found under `~/.chain/sealed/`, sorted by
|
||||
/// display name (case-insensitive). Soft-fails to an empty list —
|
||||
/// an unreadable or absent directory is the normal "no sealed
|
||||
/// areas" case, not an error.
|
||||
Future<List<SealedArea>> list() async {
|
||||
final base = _chainHome();
|
||||
if (base == null) return const [];
|
||||
final sep = Platform.pathSeparator;
|
||||
final dir = Directory('$base${sep}sealed');
|
||||
if (!dir.existsSync()) return const [];
|
||||
|
||||
final areas = <SealedArea>[];
|
||||
for (final entry in dir.listSync()) {
|
||||
if (entry is! Directory) continue;
|
||||
final manifest = File('${entry.path}${sep}project.json');
|
||||
if (!manifest.existsSync()) continue;
|
||||
try {
|
||||
final m = jsonDecode(manifest.readAsStringSync()) as Map<String, dynamic>;
|
||||
final slug = (m['slug'] as String?) ?? '';
|
||||
final port = (m['port'] as num?)?.toInt() ?? 0;
|
||||
if (slug.isEmpty || port == 0) continue;
|
||||
areas.add(
|
||||
SealedArea(
|
||||
slug: slug,
|
||||
name: (m['name'] as String?)?.isNotEmpty == true
|
||||
? m['name'] as String
|
||||
: slug,
|
||||
color: (m['color'] as String?) ?? '',
|
||||
port: port,
|
||||
running: _isRunning(base, sep, slug),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Skip an unparseable manifest rather than failing the list.
|
||||
}
|
||||
}
|
||||
areas.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
return areas;
|
||||
}
|
||||
|
||||
/// True when `~/.chain/run/sealed-<slug>.pid` holds a live PID.
|
||||
bool _isRunning(String base, String sep, String slug) {
|
||||
try {
|
||||
final pidFile = File('$base${sep}run${sep}sealed-$slug.pid');
|
||||
if (!pidFile.existsSync()) return false;
|
||||
final pid = int.tryParse(pidFile.readAsStringSync().trim());
|
||||
if (pid == null) return false;
|
||||
return _pidAlive(pid);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Liveness probe without killing: `kill -0` on POSIX, tasklist on
|
||||
/// Windows. Matches the CLI's is_pid_alive semantics closely
|
||||
/// enough for a UI status dot.
|
||||
bool _pidAlive(int pid) {
|
||||
try {
|
||||
if (Platform.isWindows) {
|
||||
final r = Process.runSync('tasklist', ['/FI', 'PID eq $pid']);
|
||||
return r.stdout.toString().contains('$pid');
|
||||
}
|
||||
final r = Process.runSync('kill', ['-0', '$pid']);
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the bound endpoint the instance's daemon wrote, if any.
|
||||
/// Falls back to the manifest port when the run-file is absent.
|
||||
Future<String?> boundEndpoint(String slug) async {
|
||||
final base = _chainHome();
|
||||
if (base == null) return null;
|
||||
final sep = Platform.pathSeparator;
|
||||
final f = File('$base${sep}run${sep}sealed-$slug.endpoint');
|
||||
if (!f.existsSync()) return null;
|
||||
final raw = f.readAsStringSync().trim();
|
||||
return raw.isEmpty ? null : raw;
|
||||
}
|
||||
}
|
||||
|
|
@ -205,6 +205,17 @@ class SystemActions {
|
|||
return _runFai(args);
|
||||
}
|
||||
|
||||
/// Start a sealed project's isolated hub instance
|
||||
/// (`chain project start <slug>`). Detached daemon on the
|
||||
/// instance's fixed port — returns once the CLI has spawned it.
|
||||
/// Used by Studio's workspace switch when the operator selects a
|
||||
/// stopped sealed area.
|
||||
static Future<({bool ok, String stdout, String stderr})> chainProjectStart(
|
||||
String slug,
|
||||
) async {
|
||||
return _runFai(['project', 'start', slug]);
|
||||
}
|
||||
|
||||
static Future<({bool ok, String stdout, String stderr})> _runFai(
|
||||
List<String> args,
|
||||
) async {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
// Workspace — the active project selection shared across pages.
|
||||
// Workspace — the active project selection + connection 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.
|
||||
// One switcher in the AppBar drives the whole project experience
|
||||
// (docs/architecture/projects.md, § Studio). Two mechanics behind
|
||||
// one control:
|
||||
//
|
||||
// * open/protected projects → a page filter AND the label new runs
|
||||
// are stamped with, all against the shared hub. "All projects"
|
||||
// stays reachable — a filter, not a jail.
|
||||
// * sealed areas → a real CONNECTION SWITCH. Selecting one
|
||||
// reconnects Studio to that area's own hub instance (its own
|
||||
// port), with a full state reload — one window, one truth. A
|
||||
// stopped area is started first (with a visible notice). The
|
||||
// active area colours an identity bar; Studio's blue stays the
|
||||
// app accent (the area colour is marking, not theming).
|
||||
|
||||
import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'hub.dart';
|
||||
import 'sealed_areas.dart';
|
||||
import 'system_actions.dart';
|
||||
|
||||
class Workspace extends ChangeNotifier {
|
||||
Workspace._();
|
||||
|
|
@ -23,7 +33,21 @@ class Workspace extends ChangeNotifier {
|
|||
List<ProjectRef> _projects = const [];
|
||||
List<ProjectRef> get projects => _projects;
|
||||
|
||||
/// Active project slug; empty = all projects (no filter).
|
||||
/// Sealed areas discovered under `~/.chain/sealed/` (own hub
|
||||
/// instances; never in the shared registry).
|
||||
List<SealedArea> _sealed = const [];
|
||||
List<SealedArea> get sealedAreas => _sealed;
|
||||
|
||||
/// The sealed area Studio is currently connected to, or null when
|
||||
/// on the shared hub. Not persisted — Studio always launches on
|
||||
/// the shared hub and the operator re-enters a sealed area
|
||||
/// deliberately (a launch must never silently start a sealed hub).
|
||||
SealedArea? _activeSealed;
|
||||
SealedArea? get activeSealed => _activeSealed;
|
||||
bool get inSealedArea => _activeSealed != null;
|
||||
|
||||
/// Active project slug for the shared-hub filter; empty = all
|
||||
/// projects. Meaningful only when [inSealedArea] is false.
|
||||
String _activeSlug = '';
|
||||
String get activeSlug => _activeSlug;
|
||||
bool get isAll => _activeSlug.isEmpty;
|
||||
|
|
@ -39,8 +63,9 @@ class Workspace extends ChangeNotifier {
|
|||
|
||||
bool _loaded = false;
|
||||
|
||||
/// Restore the persisted selection and pull the registry once.
|
||||
/// Safe to call repeatedly; only the first call restores.
|
||||
/// Restore the persisted filter and pull the registry + sealed
|
||||
/// list once. Safe to call repeatedly; only the first call
|
||||
/// restores.
|
||||
Future<void> ensureLoaded() async {
|
||||
if (!_loaded) {
|
||||
_loaded = true;
|
||||
|
|
@ -54,39 +79,58 @@ class Workspace extends ChangeNotifier {
|
|||
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.
|
||||
/// Re-pull the registry (from the connected hub) and the sealed
|
||||
/// list (from the filesystem). Soft-fails (keeps the last lists)
|
||||
/// so a hub restart doesn't blank the switcher.
|
||||
Future<void> refresh() async {
|
||||
var changed = false;
|
||||
try {
|
||||
final fresh = await HubService.instance.listProjects();
|
||||
if (!listEquals(fresh, _projects)) {
|
||||
_projects = fresh;
|
||||
notifyListeners();
|
||||
changed = true;
|
||||
}
|
||||
} catch (_) {
|
||||
// Hub unreachable — the pages already surface that state.
|
||||
}
|
||||
try {
|
||||
final sealed = await SealedAreaService.instance.list();
|
||||
if (!listEquals(sealed, _sealed)) {
|
||||
_sealed = sealed;
|
||||
changed = true;
|
||||
}
|
||||
} catch (_) {
|
||||
// No sealed areas / unreadable — the normal empty case.
|
||||
}
|
||||
if (changed) notifyListeners();
|
||||
}
|
||||
|
||||
/// Test hook: seed registry + selection without a hub and mark
|
||||
/// the persisted state as restored so [ensureLoaded] won't
|
||||
/// Test hook: seed the lists + selection without a hub/filesystem
|
||||
/// and mark the state as restored so [ensureLoaded] won't
|
||||
/// overwrite the seed.
|
||||
@visibleForTesting
|
||||
void debugSeed({
|
||||
required List<ProjectRef> projects,
|
||||
required String active,
|
||||
List<SealedArea> sealed = const [],
|
||||
SealedArea? activeSealed,
|
||||
}) {
|
||||
_projects = projects;
|
||||
_activeSlug = active;
|
||||
_sealed = sealed;
|
||||
_activeSealed = activeSealed;
|
||||
_loaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Switch the active workspace ('' = all projects) and persist.
|
||||
/// Switch the shared-hub filter ('' = all projects) and persist.
|
||||
/// If Studio is currently inside a sealed area, this first switches
|
||||
/// the connection back to the shared hub (the file/label filter
|
||||
/// only applies to the shared hub).
|
||||
Future<void> setActive(String slug) async {
|
||||
if (_activeSealed != null) {
|
||||
await switchToShared();
|
||||
}
|
||||
if (slug == _activeSlug) return;
|
||||
_activeSlug = slug;
|
||||
notifyListeners();
|
||||
|
|
@ -97,4 +141,106 @@ class Workspace extends ChangeNotifier {
|
|||
// Persistence is best-effort; the in-memory switch stands.
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a sealed-area switch, surfaced so the UI can show a
|
||||
/// notice ("started area X") or a copyable error.
|
||||
({bool ok, bool started, String error}) _switchResult({
|
||||
bool ok = true,
|
||||
bool started = false,
|
||||
String error = '',
|
||||
}) =>
|
||||
(ok: ok, started: started, error: error);
|
||||
|
||||
/// Hard connection switch into a sealed area: start it if stopped
|
||||
/// (with the returned `started` flag so the caller can show a
|
||||
/// notice), then reconnect Studio's hub client to the area's port
|
||||
/// with a full state reload. Returns ok=false + a copyable error
|
||||
/// when the area could not be started or reached.
|
||||
Future<({bool ok, bool started, String error})> switchToSealed(
|
||||
SealedArea area,
|
||||
) async {
|
||||
// Remember where the shared hub is so switchToShared can return
|
||||
// there — capture it before the first sealed switch.
|
||||
_sharedEndpoint ??= HubService.instance.currentEndpoint;
|
||||
|
||||
var started = false;
|
||||
// Re-read the current running state (the list may be stale).
|
||||
final live = await _reread(area.slug) ?? area;
|
||||
if (!live.running) {
|
||||
final r = await SystemActions.chainProjectStart(area.slug);
|
||||
if (!r.ok) {
|
||||
return _switchResult(
|
||||
ok: false,
|
||||
error: r.stderr.isEmpty ? r.stdout : r.stderr,
|
||||
);
|
||||
}
|
||||
started = true;
|
||||
// Give the daemon a moment to bind + write its endpoint file.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
}
|
||||
|
||||
// Prefer the endpoint the daemon actually wrote; fall back to
|
||||
// the manifest port.
|
||||
final ep = await SealedAreaService.instance.boundEndpoint(area.slug);
|
||||
final endpoint = _parseEndpoint(ep) ??
|
||||
HubEndpoint(host: '127.0.0.1', port: area.port);
|
||||
|
||||
await HubService.instance.reconnect(endpoint, persist: false);
|
||||
final healthy = await HubService.instance.healthy();
|
||||
if (!healthy) {
|
||||
// Roll back to the shared hub so Studio isn't stranded.
|
||||
await switchToShared();
|
||||
return _switchResult(
|
||||
ok: false,
|
||||
started: started,
|
||||
error: 'Sealed area "${area.name}" did not respond on '
|
||||
'${endpoint.host}:${endpoint.port} after start.',
|
||||
);
|
||||
}
|
||||
|
||||
_activeSealed = area;
|
||||
_activeSlug = ''; // the shared-hub filter does not apply here
|
||||
await refresh();
|
||||
notifyListeners();
|
||||
return _switchResult(started: started);
|
||||
}
|
||||
|
||||
/// Return to the shared hub from a sealed area. No-op when already
|
||||
/// on the shared hub.
|
||||
Future<void> switchToShared() async {
|
||||
if (_activeSealed == null) return;
|
||||
_activeSealed = null;
|
||||
final shared = _sharedEndpoint;
|
||||
if (shared != null) {
|
||||
await HubService.instance.reconnect(shared, persist: false);
|
||||
} else {
|
||||
// Fall back to re-discovering the installed channel.
|
||||
await HubService.instance.loadPersistedEndpoint();
|
||||
}
|
||||
await refresh();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
HubEndpoint? _sharedEndpoint;
|
||||
|
||||
Future<SealedArea?> _reread(String slug) async {
|
||||
try {
|
||||
final all = await SealedAreaService.instance.list();
|
||||
for (final a in all) {
|
||||
if (a.slug == slug) return a;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
HubEndpoint? _parseEndpoint(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
final stripped = raw.replaceFirst(RegExp(r'^\w+://'), '');
|
||||
final i = stripped.lastIndexOf(':');
|
||||
if (i <= 0) return null;
|
||||
final host = stripped.substring(0, i);
|
||||
final port = int.tryParse(stripped.substring(i + 1));
|
||||
if (port == null) return null;
|
||||
return HubEndpoint(host: host, port: port);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue