@githolon/dsl 0.72.1 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dart/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## 0.49.0
2
+
3
+ - **Fix: local-only `autoSync()` deadlocked the multi-workspace plane.** A `local://` session's
4
+ autoSync registered the full cloud machinery (doorbell WebSocket retry loop, fallback timers,
5
+ intent-offer POSTs that could never land) — the future never completed, the runner dispatcher
6
+ starved, sibling queries and `listWorkspaces()` hung, and teardown wedged. `autoSync()` on a
7
+ local-only session now registers nothing and returns immediately; `sync()` is a true no-op
8
+ (`localOnly: true` summary); a local:// holon performs ZERO network attempts. (co2's acceptance
9
+ test implemented verbatim; failed before, passes after.)
10
+ - **New: `acquireSession(workspace, {ifAbsentCredentials})`** — atomic single-flight session
11
+ acquisition: reuses a connected session, joins an in-flight open (concurrent calls yield the
12
+ same handle, one connect), opens local-only for workspaces the plane already knows
13
+ (mounted/parked/resident custody — credentials never invoked), and only calls
14
+ `ifAbsentCredentials` (exactly once) when a genuine cloud open is required.
15
+
1
16
  ## 0.48.1
2
17
 
3
18
  - **Fix: `NomosSession.watchPendingConflicts()` never emitted.** The session overrode every other
@@ -661,6 +661,34 @@ class NomosOfferOutcome {
661
661
  .toList();
662
662
  }
663
663
 
664
+ /// What a GENUINE cloud open needs — returned by an [NomosBridge.acquireSession] `ifAbsentCredentials`
665
+ /// callback, invoked ONLY when the workspace is nowhere on the local plane (never for a local-only or
666
+ /// already-mounted workspace). Mirrors the cloud-shaped subset of [NomosBridge.connect]'s parameters.
667
+ class NomosSessionCredentials {
668
+ /// The cloud base URL the workspace syncs from (e.g. `https://nomos.captainapp.co.uk`).
669
+ final String cloud;
670
+
671
+ /// The verified principal's access token (`x-nomos-auth`), when the workspace requires one.
672
+ final String? authToken;
673
+
674
+ /// The client id for this device's replica (defaults per the runner when omitted).
675
+ final String? clientId;
676
+
677
+ /// The device signing secret (base64, from `authorKeygen`) — the warrant default for every offer.
678
+ final String? authorSecret;
679
+
680
+ /// Open even when the workspace has no remote main yet (an unborn workspace reads empty).
681
+ final bool allowUnborn;
682
+
683
+ const NomosSessionCredentials({
684
+ required this.cloud,
685
+ this.authToken,
686
+ this.clientId,
687
+ this.authorSecret,
688
+ this.allowUnborn = false,
689
+ });
690
+ }
691
+
664
692
  /// THE BRIDGE. Construct over a [BridgeTransport], `await ready`, then `connect(...)` and drive the holon.
665
693
  class NomosBridge {
666
694
  final BridgeTransport _transport;
@@ -708,6 +736,9 @@ class NomosBridge {
708
736
  // caches the per-workspace [NomosSession] handle so `bridge.session(ws)` is stable + cheap.
709
737
  final Set<String> _sessionNames = {};
710
738
  final Map<String, NomosSession> _sessionViews = {};
739
+ // [acquireSession]'s per-workspace single-flight: workspace → the in-flight open every concurrent
740
+ // acquirer joins (removed when it completes; a live session then hits the fast path instead).
741
+ final Map<String, Future<NomosSession>> _acquiring = {};
711
742
  int _seq = 0;
712
743
  final String _traceId = _newTraceId();
713
744
  late final StreamSubscription<Map<String, Object?>> _sub;
@@ -1205,6 +1236,60 @@ class NomosBridge {
1205
1236
  return session(ws);
1206
1237
  }
1207
1238
 
1239
+ /// ACQUIRE A SESSION ATOMICALLY — the plug-and-play multi-workspace open (co2 DX ask). Semantics:
1240
+ /// • an already-connected session is returned as-is (no work, no credentials);
1241
+ /// • a workspace the local plane already holds (mounted by another path, LRU-parked, or with resident
1242
+ /// custody — e.g. a locally-born child) is opened LOCAL-ONLY, in place (no cloud, no credentials);
1243
+ /// • two concurrent [acquireSession] calls for the same workspace SINGLE-FLIGHT: the second joins the
1244
+ /// first's in-flight open and both yield the SAME [NomosSession] — one connect, never two;
1245
+ /// • ONLY when a genuine cloud open is required (the workspace is nowhere on this device) is
1246
+ /// [ifAbsentCredentials] invoked — exactly once — and the connect made with what it returns.
1247
+ /// Omitting [ifAbsentCredentials] makes a genuinely-absent workspace a named [StateError] instead of a
1248
+ /// silent cloudless mount.
1249
+ Future<NomosSession> acquireSession(String workspace,
1250
+ {Future<NomosSessionCredentials> Function()? ifAbsentCredentials}) {
1251
+ // Fast path: a live session needs no runner round-trip (and never asks for credentials).
1252
+ if (_sessionNames.contains(workspace)) return Future.value(session(workspace));
1253
+ // SINGLE-FLIGHT: one in-flight open per workspace per plane; concurrent callers join it.
1254
+ return _acquiring.putIfAbsent(workspace, () async {
1255
+ try {
1256
+ if (_sessionNames.contains(workspace)) return session(workspace);
1257
+ // Consult the plane's TRUTHFUL registry (mounted ∪ parked ∪ resident custody — the runner's
1258
+ // realm.list()): anything the device already holds opens locally, no cloud, no credentials.
1259
+ var resident = false;
1260
+ try {
1261
+ resident = (await listWorkspaces())
1262
+ .any((w) => w['workspace']?.toString() == workspace);
1263
+ } catch (_) {
1264
+ // a pre-connect plane has no registry yet — fall through to the credentials decision below.
1265
+ }
1266
+ if (resident) {
1267
+ await connect(workspace: workspace, session: workspace, allowUnborn: true);
1268
+ return session(workspace);
1269
+ }
1270
+ if (ifAbsentCredentials == null) {
1271
+ throw StateError(
1272
+ 'acquireSession("$workspace"): the workspace is not on this device\'s plane and no '
1273
+ 'ifAbsentCredentials was given — a cloud open is genuinely required');
1274
+ }
1275
+ final c = await ifAbsentCredentials();
1276
+ await connect(
1277
+ cloud: c.cloud,
1278
+ workspace: workspace,
1279
+ session: workspace,
1280
+ clientId: c.clientId,
1281
+ authToken: c.authToken,
1282
+ authorSecret: c.authorSecret,
1283
+ allowUnborn: c.allowUnborn,
1284
+ );
1285
+ return session(workspace);
1286
+ } finally {
1287
+ // completed (either way) — the next acquire re-evaluates fresh (a live session hits the fast path).
1288
+ _acquiring.remove(workspace);
1289
+ }
1290
+ });
1291
+ }
1292
+
1208
1293
  /// Reserve a KERNEL-MINTED typed id (`<TypeTag>_<uuidv7>`) via the holon's front-door mint — for app-side
1209
1294
  /// references (routing keys, optimistic UI). `.creates` payloads still carry NO id: the LAW mints those
1210
1295
  /// (see [offerCreates], which returns them).
@@ -1947,6 +2032,13 @@ class NomosSession extends NomosBridge {
1947
2032
  @override
1948
2033
  NomosSession session(String workspace) => parent.session(workspace);
1949
2034
 
2035
+ /// An acquired session mounts on the SHARED plane — delegate to the parent (the plane owns the
2036
+ /// single-flight registry; a session never owns mounts).
2037
+ @override
2038
+ Future<NomosSession> acquireSession(String workspace,
2039
+ {Future<NomosSessionCredentials> Function()? ifAbsentCredentials}) =>
2040
+ parent.acquireSession(workspace, ifAbsentCredentials: ifAbsentCredentials);
2041
+
1950
2042
  /// A followed ref mounts on the SHARED plane — delegate to the parent (a session never owns mounts).
1951
2043
  @override
1952
2044
  Future<NomosSession> follow(NomosRef ref,
package/dart/pubspec.yaml CHANGED
@@ -11,7 +11,7 @@ description: >-
11
11
  Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
12
12
  code-generated per-domain aggregate + directive-payload types. (De)serializes
13
13
  the EXACT serde JSON the Rust kernel + projection produce/consume.
14
- version: 0.48.1
14
+ version: 0.49.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -0,0 +1,152 @@
1
+ // acquireSession — the ATOMIC session-open (co2 DX ask): reuses a mounted session, opens a plane-resident
2
+ // (parked / resident-custody) workspace locally, JOINS an in-flight open (single-flight per workspace), and
3
+ // invokes the credentials callback ONLY when a cloud open is genuinely required. Proven hermetically over a
4
+ // fake transport (the same style as webview_bridge_test.dart) — the live-runner leg rides
5
+ // cluster_local_autosync_test.dart.
6
+ import 'dart:async';
7
+
8
+ import 'package:nomos_client/nomos_client.dart';
9
+ import 'package:test/test.dart';
10
+
11
+ /// A fake runner: answers connect (after a real async gap, so concurrency is observable) and
12
+ /// listWorkspaces (from [resident]); records every request.
13
+ class FakeRunner implements BridgeTransport {
14
+ final _incoming = StreamController<Map<String, Object?>>.broadcast();
15
+ final List<Map<String, Object?>> sent = [];
16
+ final List<String> resident; // workspaces the plane already holds (realm.list())
17
+
18
+ FakeRunner({this.resident = const []}) {
19
+ scheduleMicrotask(
20
+ () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
21
+ }
22
+
23
+ int get connectCount => sent.where((m) => m['op'] == 'connect').length;
24
+ Map<String, Object?> connectArgs(int i) =>
25
+ (sent.where((m) => m['op'] == 'connect').elementAt(i)['args'] as Map)
26
+ .cast<String, Object?>();
27
+
28
+ @override
29
+ Stream<Map<String, Object?>> get messages => _incoming.stream;
30
+
31
+ @override
32
+ void send(Map<String, Object?> message) {
33
+ sent.add(message);
34
+ final id = message['id'] as int;
35
+ final op = message['op'] as String;
36
+ final args = (message['args'] as Map).cast<String, Object?>();
37
+ Future<void>.delayed(const Duration(milliseconds: 5), () {
38
+ Object? result;
39
+ switch (op) {
40
+ case 'connect':
41
+ result = {'workspace': args['workspace']};
42
+ break;
43
+ case 'listWorkspaces':
44
+ result = {
45
+ 'workspaces': [
46
+ for (final ws in resident)
47
+ {
48
+ 'session': ws,
49
+ 'workspace': ws,
50
+ 'mounted': false,
51
+ 'parked': true,
52
+ 'syncPosture': 'local-only'
53
+ }
54
+ ]
55
+ };
56
+ break;
57
+ default:
58
+ result = {};
59
+ }
60
+ _incoming
61
+ .add({'v': kWebviewBridgeVersion, 'id': id, 'ok': true, 'result': result});
62
+ });
63
+ }
64
+ }
65
+
66
+ void main() {
67
+ test('an already-connected session is reused — credentials NEVER invoked, no second connect', () async {
68
+ final runner = FakeRunner();
69
+ final bridge = NomosBridge(runner);
70
+ await bridge.ready;
71
+ await bridge.connect(workspace: 'home', session: 'home');
72
+ expect(runner.connectCount, 1);
73
+
74
+ final s = await bridge.acquireSession('home',
75
+ ifAbsentCredentials: () async =>
76
+ fail('credentials must not be requested for a mounted session'));
77
+ expect(identical(s, bridge.session('home')), isTrue);
78
+ expect(runner.connectCount, 1, reason: 'no re-connect for a live session');
79
+ await bridge.dispose();
80
+ });
81
+
82
+ test('a plane-resident (parked) workspace opens LOCALLY — no cloud, credentials NOT invoked', () async {
83
+ final runner = FakeRunner(resident: ['estate']);
84
+ final bridge = NomosBridge(runner);
85
+ await bridge.ready;
86
+
87
+ var asked = false;
88
+ final s = await bridge.acquireSession('estate', ifAbsentCredentials: () async {
89
+ asked = true;
90
+ return const NomosSessionCredentials(cloud: 'https://never.invalid');
91
+ });
92
+ expect(asked, isFalse, reason: 'a resident workspace never asks for credentials');
93
+ expect(s.workspace, 'estate');
94
+ expect(runner.connectCount, 1);
95
+ expect(runner.connectArgs(0).containsKey('cloud'), isFalse,
96
+ reason: 'a resident open is local-only — no cloud in the connect');
97
+ await bridge.dispose();
98
+ });
99
+
100
+ test('concurrent acquisition SINGLE-FLIGHTS: one open, one credentials call, the same session', () async {
101
+ final runner = FakeRunner();
102
+ final bridge = NomosBridge(runner);
103
+ await bridge.ready;
104
+
105
+ var credentialCalls = 0;
106
+ Future<NomosSessionCredentials> creds() async {
107
+ credentialCalls++;
108
+ await Future<void>.delayed(const Duration(milliseconds: 10));
109
+ return const NomosSessionCredentials(
110
+ cloud: 'https://cloud.example', authToken: 'tok', allowUnborn: true);
111
+ }
112
+
113
+ final results = await Future.wait([
114
+ bridge.acquireSession('cloudy', ifAbsentCredentials: creds),
115
+ bridge.acquireSession('cloudy', ifAbsentCredentials: creds),
116
+ bridge.acquireSession('cloudy', ifAbsentCredentials: creds),
117
+ ]);
118
+ expect(credentialCalls, 1, reason: 'the credentials callback fires exactly once');
119
+ expect(runner.connectCount, 1, reason: 'ONE connect for three concurrent acquires');
120
+ expect(identical(results[0], results[1]), isTrue);
121
+ expect(identical(results[1], results[2]), isTrue);
122
+ final args = runner.connectArgs(0);
123
+ expect(args['cloud'], 'https://cloud.example');
124
+ expect(args['authToken'], 'tok');
125
+ expect(args['allowUnborn'], isTrue);
126
+ await bridge.dispose();
127
+ });
128
+
129
+ test('genuinely absent + no credentials callback → a named StateError (never a silent cloudless mount)',
130
+ () async {
131
+ final runner = FakeRunner();
132
+ final bridge = NomosBridge(runner);
133
+ await bridge.ready;
134
+ await expectLater(bridge.acquireSession('nowhere'), throwsStateError);
135
+ expect(runner.connectCount, 0);
136
+ await bridge.dispose();
137
+ });
138
+
139
+ test('a NomosSession delegates acquireSession to the parent plane (one registry)', () async {
140
+ final runner = FakeRunner();
141
+ final bridge = NomosBridge(runner);
142
+ await bridge.ready;
143
+ await bridge.connect(workspace: 'home', session: 'home');
144
+ await bridge.connect(workspace: 'estate', session: 'estate');
145
+
146
+ final viaSession = await bridge.session('home').acquireSession('estate',
147
+ ifAbsentCredentials: () async =>
148
+ fail('estate is already mounted — no credentials'));
149
+ expect(identical(viaSession, bridge.session('estate')), isTrue);
150
+ await bridge.dispose();
151
+ });
152
+ }
@@ -0,0 +1,119 @@
1
+ // CO2 ACCEPTANCE — local-only autoSync must not block the multi-workspace plane (their report, verbatim):
2
+ // - Open multiple local-only sessions with NomosTestHarness.cluster.
3
+ // - await homeSession.autoSync() completes promptly.
4
+ // - await bridge.listWorkspaces() and a query on a sibling estate session still complete.
5
+ // - stopAutoSync() and harness disposal complete.
6
+ // - No local://.../intent-offers fetch attempts occur.
7
+ // autoSync() REGISTERS background behavior and returns immediately; for a local-only (local://) session it
8
+ // performs NO network work and must not monopolize the runner request dispatcher.
9
+ @TestOn('vm')
10
+ library;
11
+
12
+ import 'dart:io';
13
+
14
+ import 'package:nomos_client/nomos_headless.dart';
15
+ import 'package:test/test.dart';
16
+
17
+ String get _root => '${Directory.current.path}/../..';
18
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
19
+ String get _guestbookDeploy => '$_root/examples/guestbook/build/guestbook.deploy.json';
20
+ String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
21
+
22
+ String? _missingPrereq() {
23
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
24
+ if (!File(_guestbookDeploy).existsSync()) {
25
+ return 'compile the guestbook example: cd examples/guestbook && npx nomos-compile — missing $_guestbookDeploy';
26
+ }
27
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
28
+ !File('$_runtimeDir/packages.json').existsSync()) {
29
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
30
+ }
31
+ return null;
32
+ }
33
+
34
+ void main() {
35
+ test(
36
+ 'local-only autoSync() returns promptly, never fetches, and does not block the plane (co2 acceptance)',
37
+ () async {
38
+ final skip = _missingPrereq();
39
+ if (skip != null) {
40
+ markTestSkipped(skip);
41
+ return;
42
+ }
43
+
44
+ // 1. Open multiple local-only sessions with NomosTestHarness.cluster (four, one bridge).
45
+ final names = ['home', 'estate-a', 'estate-b', 'estate-c'];
46
+ final diagnostics = <String>[];
47
+ final harness = await NomosTestHarness.cluster(
48
+ workspaces: {for (final ws in names) ws: [_guestbookDeploy]},
49
+ nodeEntry: _repoEntry,
50
+ runtimeDir: _runtimeDir,
51
+ onDiagnostic: diagnostics.add,
52
+ );
53
+ try {
54
+ final home = harness.session('home');
55
+
56
+ // A realistic home holds local writes (ahead of any base) — this is what drives the up-leg machinery.
57
+ await home.offerDirective(
58
+ domain: 'guestbook',
59
+ directiveId: 'signGuestbook',
60
+ payload: {
61
+ 'author': 'ada',
62
+ 'message': 'offline write before autoSync',
63
+ 'mood': 'delighted',
64
+ 'signedAt': '2026-07-11T00:00:00Z',
65
+ },
66
+ actor: 'ada',
67
+ );
68
+
69
+ // 2. await homeSession.autoSync() completes promptly.
70
+ await home.autoSync().timeout(const Duration(seconds: 10),
71
+ onTimeout: () => throw StateError(
72
+ 'autoSync() on a local-only session did not complete within 10s (the reported deadlock)'));
73
+
74
+ // 3. await bridge.listWorkspaces() and a query on a sibling estate session still complete.
75
+ final listed = await harness.bridge.listWorkspaces().timeout(
76
+ const Duration(seconds: 10),
77
+ onTimeout: () => throw StateError(
78
+ 'listWorkspaces() hung after a local-only autoSync (dispatcher monopolized)'));
79
+ expect(listed.map((w) => w['session'].toString()).toSet(),
80
+ containsAll(names.toSet()));
81
+ final sibling = await harness
82
+ .session('estate-a')
83
+ .query('entriesByAuthor', {'author': 'nobody'}).timeout(
84
+ const Duration(seconds: 10),
85
+ onTimeout: () => throw StateError(
86
+ 'a sibling estate query hung after a local-only autoSync'));
87
+ expect(sibling, isEmpty);
88
+
89
+ // Give any wrongly-registered background sync machinery time to fire so its fetch attempts would show.
90
+ await Future<void>.delayed(const Duration(seconds: 2));
91
+
92
+ // 4. stopAutoSync() and harness disposal complete (disposal asserted in finally / teardown).
93
+ await home.stopAutoSync().timeout(const Duration(seconds: 10),
94
+ onTimeout: () =>
95
+ throw StateError('stopAutoSync() hung on a local-only session'));
96
+
97
+ // Bonus (live-runner leg of the acquireSession DX ask): a mounted local workspace is acquired
98
+ // WITHOUT credentials, and yields the same cached session handle.
99
+ final acquired = await harness.bridge.acquireSession('estate-b',
100
+ ifAbsentCredentials: () async =>
101
+ fail('estate-b is mounted on the plane — credentials must not be requested'));
102
+ expect(identical(acquired, harness.session('estate-b')), isTrue);
103
+
104
+ // 5. No local://.../intent-offers fetch attempts occur (attempted-and-refused still counts as attempted).
105
+ final offending = diagnostics
106
+ .where((l) => l.contains('intent-offers') || l.contains('refusing network fetch'))
107
+ .toList();
108
+ expect(offending, isEmpty,
109
+ reason:
110
+ 'a local-only session must perform NO network work — saw:\n${offending.join('\n')}');
111
+ } finally {
112
+ await harness
113
+ .dispose()
114
+ .timeout(const Duration(seconds: 20), onTimeout: () {
115
+ throw StateError('harness disposal hung (the reported teardown hang)');
116
+ });
117
+ }
118
+ }, timeout: const Timeout(Duration(minutes: 3)));
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.72.1",
3
+ "version": "0.73.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -904,7 +904,7 @@ const NOMOS_TYPES_DEP = "^0.3.0";
904
904
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
905
905
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
906
906
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
907
- const NOMOS_CLIENT_DEP = "^0.48.1";
907
+ const NOMOS_CLIENT_DEP = "^0.49.0";
908
908
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
909
909
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
910
910
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).