Two findings from the workspace persona review, both rated high:
* Switch race: the hub client re-pointed at a sealed area's hub
before the workspace announced the sealed context, so the 2 s
page pollers (runs, audit) could fetch and render that hub's
data without the sealed marking. Switches now run inside an
explicit switching window: opened before anything touches the
connection, announced optimistically in the identity bar
("switching…" + spinner, leave button hidden), pollers and the
shell health tick pause inside it, and pages drop replies whose
context epoch changed mid-flight. The sealed context is
announced only after the new hub answered healthy.
* Filter loss: entering a sealed area cleared the shared-hub
project filter and returning restored only the endpoint. The
filter is now parked on entry and restored on return; prefs
keep the parked value throughout, so live state and prefs agree
after the round trip (and after a mid-session relaunch).
Guard: workspace_switch_race_test pins both invariants
state-matrix-style against scripted hub + sealed-area fakes —
reconnects may only happen inside an open switch window, pollers
must stay silent inside it, and the filter must survive the round
trip. SealedAreaService gained a debugSetInstance seam so the
suite never scans a real ~/.chain.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
316 lines
11 KiB
Dart
316 lines
11 KiB
Dart
// Workspace — the active project selection + connection shared
|
|
// across pages.
|
|
//
|
|
// 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._();
|
|
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;
|
|
|
|
/// 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;
|
|
|
|
/// True while a sealed-area connection switch (either direction)
|
|
/// is in flight. Set BEFORE the hub client re-points and cleared
|
|
/// only after the new context is fully announced, so pages pause
|
|
/// their pollers and the identity bar can mark the transition —
|
|
/// otherwise a 2 s poll could render the other hub's data under
|
|
/// the old context's marking (privacy race).
|
|
bool _switching = false;
|
|
bool get switching => _switching;
|
|
|
|
/// The area being entered while [switching]; null when the switch
|
|
/// returns to the shared hub. Lets the identity bar announce the
|
|
/// target optimistically ("switching to X…").
|
|
SealedArea? _switchTarget;
|
|
SealedArea? get switchTarget => _switchTarget;
|
|
|
|
/// Monotonic counter bumped each time a connection switch
|
|
/// completes. Pages capture it before an async fetch and drop the
|
|
/// reply when it changed — a response from the previous hub must
|
|
/// never render under the new context's marking.
|
|
int _contextEpoch = 0;
|
|
int get contextEpoch => _contextEpoch;
|
|
|
|
/// 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;
|
|
|
|
/// 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 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;
|
|
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 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;
|
|
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 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;
|
|
_switching = false;
|
|
_switchTarget = null;
|
|
_sharedSlug = null;
|
|
_sharedEndpoint = null;
|
|
_loaded = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 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();
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_prefsKey, slug);
|
|
} catch (_) {
|
|
// 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;
|
|
|
|
// Open the switch window BEFORE anything touches the connection.
|
|
_beginSwitch(area);
|
|
try {
|
|
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. The
|
|
// sealed context was never announced, so only the endpoint
|
|
// needs restoring — filter and marking are untouched.
|
|
await _reconnectShared();
|
|
return _switchResult(
|
|
ok: false,
|
|
started: started,
|
|
error: 'Sealed area "${area.name}" did not respond on '
|
|
'${endpoint.host}:${endpoint.port} after start.',
|
|
);
|
|
}
|
|
|
|
_activeSealed = area;
|
|
// Entering from the shared hub: park the filter so the round
|
|
// trip restores it. (Area→area keeps the first park.)
|
|
_sharedSlug ??= _activeSlug;
|
|
_activeSlug = ''; // the shared-hub filter does not apply here
|
|
await refresh();
|
|
return _switchResult(started: started);
|
|
} finally {
|
|
_endSwitch();
|
|
}
|
|
}
|
|
|
|
/// Return to the shared hub from a sealed area. No-op when already
|
|
/// on the shared hub. Restores the project filter that was active
|
|
/// before the sealed round trip — it was parked, not dropped.
|
|
Future<void> switchToShared() async {
|
|
if (_activeSealed == null) return;
|
|
_beginSwitch(null);
|
|
try {
|
|
await _reconnectShared();
|
|
_activeSealed = null;
|
|
_activeSlug = _sharedSlug ?? '';
|
|
_sharedSlug = null;
|
|
await refresh();
|
|
} finally {
|
|
_endSwitch();
|
|
}
|
|
}
|
|
|
|
/// Point the hub client back at the shared hub (endpoint only —
|
|
/// no context bookkeeping). Shared by [switchToShared] and the
|
|
/// failed-switch rollback.
|
|
Future<void> _reconnectShared() async {
|
|
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();
|
|
}
|
|
}
|
|
|
|
void _beginSwitch(SealedArea? target) {
|
|
_switching = true;
|
|
_switchTarget = target;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _endSwitch() {
|
|
_switching = false;
|
|
_switchTarget = null;
|
|
_contextEpoch++;
|
|
notifyListeners();
|
|
}
|
|
|
|
HubEndpoint? _sharedEndpoint;
|
|
|
|
/// The shared-hub project filter parked during a sealed session,
|
|
/// or null when none is parked. Prefs keep the parked value (they
|
|
/// always describe the SHARED hub's filter), so live state and
|
|
/// prefs agree again after the round trip — and a launch after a
|
|
/// kill inside a sealed area still restores the filter.
|
|
String? _sharedSlug;
|
|
|
|
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);
|
|
}
|
|
}
|