@githolon/dsl 0.59.1 → 0.60.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,9 @@
1
+ ## 0.39.0
2
+ - `connect(maxMounted: …)` — the realm's mounted-workspace budget (LRU park/remount above it); useful on memory-constrained devices holding many local workspaces.
3
+ - MULTI-WORKSPACE BY DEFAULT IN TESTS: `NomosTestHarness.ephemeral` now connects a DECOY session alongside the workspace under test, and `harness.bridge` is a `NomosSession` HANDLE pinned to it (existing tenant tests work unchanged). Sole-session inference therefore never silently passes under the harness — an op that forgot its session refuses in your first test with the same `multiple sessions connected — pass session` a real multi-workspace app raises. Opt out with `decoySession: false`.
4
+ - `NomosBridge.open(...)` — connect + pinned `NomosSession` handle in one call (`final home = await bridge.open(workspace: …); HomeClient(home)`): the recommended plane/handle shape; the bare bridge methods remain one-workspace sugar.
5
+ - Multi-session compat surface: `watchCompatibility` takes an optional `session`; `NomosSession` pins its compat watch, `compat`, and `onLawChanged` to ITS workspace (per-session `lawChanged` scoping — previously a multi-session plane's compat verdict was whichever session synced last, and a session's compat watch refused on a >1-session plane).
6
+
1
7
  ## 0.38.0
2
8
  - SPLIT: the pure data core (wire types, offer codec, NomosReads, aggregate meta, schema validation, error info) moved to the new `nomos_types` package; `nomos_client` depends on and re-exports it, so existing imports are unchanged. Frontend/UI code can now depend on `nomos_types` alone.
3
9
 
@@ -43,8 +43,15 @@ class NomosTestHarnessException implements Exception {
43
43
  String toString() => 'NomosTestHarnessException: $message';
44
44
  }
45
45
 
46
+ /// The decoy session [NomosTestHarness.ephemeral] connects alongside the workspace under test — it exists
47
+ /// so the plane is ALWAYS multi-session in tests (sole-session inference never silently passes).
48
+ const String _decoyWorkspace = '__nomos-decoy';
49
+
46
50
  class NomosTestHarness {
47
51
  /// The connected bridge — hand it to the generated typed clients (the same surface the app UI uses).
52
+ /// For [ephemeral] this is a [NomosSession] HANDLE pinned to [workspace] (the plane underneath also
53
+ /// holds a decoy session, so unpinned plane-level calls refuse the way they would in a real
54
+ /// multi-workspace app); for [cluster] it is the shared plane — name sessions via [session].
48
55
  final NomosBridge bridge;
49
56
 
50
57
  /// The local workspace name the law was installed into.
@@ -129,6 +136,7 @@ class NomosTestHarness {
129
136
  void Function(Future<void> Function())? registerTearDown,
130
137
  void Function(String line)? onDiagnostic,
131
138
  Duration readyTimeout = const Duration(seconds: 30),
139
+ bool decoySession = true,
132
140
  }) async {
133
141
  if (deployJsonPaths.isEmpty) {
134
142
  throw NomosTestHarnessException(
@@ -178,19 +186,36 @@ class NomosTestHarness {
178
186
  },
179
187
  onDiagnostic: onDiagnostic,
180
188
  );
181
- final bridge = NomosBridge(transport);
182
- await bridge.ready.timeout(readyTimeout,
189
+ final plane = NomosBridge(transport);
190
+ await plane.ready.timeout(readyTimeout,
183
191
  onTimeout: () => throw NomosTestHarnessException(
184
192
  'the offline runner never signalled ready within $readyTimeout '
185
193
  '(is `node` on PATH and ~/.holon/runtime warm?)'));
186
- await bridge.connect(
194
+ await plane.connect(
187
195
  cloud: 'https://offline.invalid', // ignored: the offline runner serves everything locally
188
196
  workspace: workspace,
189
197
  clientId: clientId,
190
198
  allowUnborn: true,
191
199
  );
200
+ // THE DECOY SESSION (multi-workspace by default in tests): sole-session inference is how
201
+ // single-workspace assumptions ship — with one session connected, an op that forgot its session
202
+ // silently works in tests and only refuses in production when the app connects its second
203
+ // workspace. The decoy keeps the plane at ≥2 sessions so any unpinned op refuses HERE, in the
204
+ // dev's first test. [bridge] is a SESSION HANDLE pinned to [workspace], so tenant code written
205
+ // against the harness keeps working unchanged; only code that grabs the raw plane and assumes
206
+ // single-session trips. Opt out with `decoySession: false` (please don't).
207
+ if (decoySession) {
208
+ await plane.connect(
209
+ cloud: 'https://offline.invalid',
210
+ workspace: _decoyWorkspace,
211
+ session: _decoyWorkspace,
212
+ clientId: '$clientId-decoy',
213
+ allowUnborn: true,
214
+ restoreFrom: base64Encode(await File(snapshot).readAsBytes()),
215
+ );
216
+ }
192
217
  final harness = NomosTestHarness._(
193
- bridge, workspace, transport, workdir, onDiagnostic, [workspace],
218
+ plane.session(workspace), workspace, transport, workdir, onDiagnostic, [workspace],
194
219
  frameworkHash: info['frameworkHash']?.toString(),
195
220
  lawHashes: {
196
221
  workspace: ((info['lawHashes'] as List?) ?? const [])
@@ -626,6 +626,10 @@ class NomosBridge {
626
626
  final Map<String, StreamController<Map<String, Object?>>> _compatWatches = {};
627
627
  // COMPAT SUPERPOWER (#72b): the last computed verdict + the live `lawChanged` event stream.
628
628
  NomosCompat? _lastCompat;
629
+ // per-session compat state — a multi-session plane syncs law changes on MANY workspaces; a
630
+ // [NomosSession]'s compat/onLawChanged must answer for ITS workspace, not whichever synced last.
631
+ final Map<String, NomosCompat> _lastCompatBySession = {};
632
+ final Map<String, StreamController<NomosCompat>> _lawChangedBySession = {};
629
633
  final StreamController<NomosCompat> _lawChanged =
630
634
  StreamController<NomosCompat>.broadcast();
631
635
  final StreamController<Map<String, Object?>> _syncEvents =
@@ -728,7 +732,10 @@ class NomosBridge {
728
732
  final c = NomosCompat.fromJson((m['compat'] as Map?)?.cast<String, Object?>());
729
733
  if (c != null) {
730
734
  _lastCompat = c;
735
+ final sess = m['session'] as String?;
736
+ if (sess != null) _lastCompatBySession[sess] = c;
731
737
  if (!_lawChanged.isClosed) _lawChanged.add(c);
738
+ if (sess != null) _lawChangedBySession[sess]?.add(c);
732
739
  }
733
740
  return;
734
741
  }
@@ -856,7 +863,8 @@ class NomosBridge {
856
863
  String? restoreFrom,
857
864
  bool allowUnborn = false,
858
865
  bool recover = false,
859
- String? session}) async {
866
+ String? session,
867
+ int? maxMounted}) async {
860
868
  final ws = session ?? workspace;
861
869
  _sessionNames.add(ws);
862
870
  final r = _asMap(await _call('connect', {
@@ -875,6 +883,8 @@ class NomosBridge {
875
883
  if (authorSecret != null) 'authorSecret': authorSecret,
876
884
  if (restoreFrom != null) 'restoreFrom': restoreFrom,
877
885
  if (allowUnborn) 'allowUnborn': true,
886
+ // the realm's mounted-workspace budget (LRU park/remount above it) — first connect wins.
887
+ if (maxMounted != null) 'maxMounted': maxMounted,
878
888
  // RECOVER-IN-PLACE over a corrupt restoreFrom snapshot: salvage local-only writes + DLQ, refold fresh
879
889
  // from cloud, re-offer the salvaged work. The report lands on [lastRecovery]. Nothing local is lost.
880
890
  if (recover) 'recover': true,
@@ -1494,6 +1504,33 @@ class NomosBridge {
1494
1504
  return v;
1495
1505
  }
1496
1506
 
1507
+ /// CONNECT AS A HANDLE (the recommended multi-workspace shape): [connect] + a [NomosSession] pinned to
1508
+ /// the opened session in one call — `final home = await bridge.open(workspace: 'home-…');` then
1509
+ /// `HomeClient(home)`. The bare [NomosBridge] methods remain the ONE-workspace sugar over an inferred
1510
+ /// default session; an app that opens a second workspace should hold handles, not call the plane.
1511
+ Future<NomosSession> open(
1512
+ {String? cloud,
1513
+ required String workspace,
1514
+ String? clientId,
1515
+ String? authToken,
1516
+ String? authorSecret,
1517
+ String? restoreFrom,
1518
+ bool allowUnborn = false,
1519
+ bool recover = false,
1520
+ String? session}) async {
1521
+ await connect(
1522
+ cloud: cloud,
1523
+ workspace: workspace,
1524
+ clientId: clientId,
1525
+ authToken: authToken,
1526
+ authorSecret: authorSecret,
1527
+ restoreFrom: restoreFrom,
1528
+ allowUnborn: allowUnborn,
1529
+ recover: recover,
1530
+ session: session);
1531
+ return this.session(session ?? workspace);
1532
+ }
1533
+
1497
1534
  /// The last computed compat verdict (set by [checkCompat] / a `lawChanged` event). Null until bind.
1498
1535
  NomosCompat? get compat => _lastCompat;
1499
1536
 
@@ -1513,7 +1550,8 @@ class NomosBridge {
1513
1550
  /// workspace's installed law changes (another peer installs/upgrades/retires a domain and it
1514
1551
  /// syncs in). The verdict carries `offerKnown`. The app degrades/prompts on `ok:false`.
1515
1552
  Stream<Map<String, Object?>> watchCompatibility(
1516
- Map<String, Object?> requirement) {
1553
+ Map<String, Object?> requirement,
1554
+ [String? session]) {
1517
1555
  final subId = 'c${++_seq}';
1518
1556
  late final StreamController<Map<String, Object?>> ctrl;
1519
1557
  ctrl = StreamController<Map<String, Object?>>(
@@ -1521,6 +1559,7 @@ class NomosBridge {
1521
1559
  'subId': subId,
1522
1560
  'kind': 'compat',
1523
1561
  'requirement': requirement,
1562
+ if (session != null) 'session': session,
1524
1563
  }),
1525
1564
  onCancel: () async {
1526
1565
  _compatWatches.remove(subId);
@@ -1656,14 +1695,20 @@ class NomosSession extends NomosBridge {
1656
1695
  parent.watchSpatial(spatialId, bbox, session ?? workspace);
1657
1696
  @override
1658
1697
  Stream<Map<String, Object?>> watchCompatibility(
1659
- Map<String, Object?> requirement) =>
1660
- parent.watchCompatibility(requirement);
1698
+ Map<String, Object?> requirement,
1699
+ [String? session]) =>
1700
+ parent.watchCompatibility(requirement, session ?? workspace);
1661
1701
 
1662
1702
  // Live snapshots / streams reflect the shared plane — read them off the parent's controllers.
1703
+ // compat/onLawChanged are LAW-scoped, so a session answers for ITS workspace (falling back to the
1704
+ // plane-wide value when the runner's events carry no session key — older bundles).
1663
1705
  @override
1664
- NomosCompat? get compat => parent.compat;
1706
+ NomosCompat? get compat =>
1707
+ parent._lastCompatBySession[workspace] ?? parent.compat;
1665
1708
  @override
1666
- Stream<NomosCompat> get onLawChanged => parent.onLawChanged;
1709
+ Stream<NomosCompat> get onLawChanged => (parent._lawChangedBySession[
1710
+ workspace] ??=
1711
+ StreamController<NomosCompat>.broadcast()).stream;
1667
1712
  @override
1668
1713
  Map<String, Object?>? get lastRecovery => parent.lastRecovery;
1669
1714
  @override
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.38.0
14
+ version: 0.39.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -11,6 +11,7 @@ library;
11
11
 
12
12
  import 'dart:io';
13
13
 
14
+ import 'package:nomos_client/nomos_client.dart';
14
15
  import 'package:nomos_client/nomos_headless.dart';
15
16
  import 'package:test/test.dart';
16
17
 
@@ -82,6 +83,30 @@ void main() {
82
83
  stdout.writeln('offline harness green: ws=${harness.workspace}');
83
84
  }, timeout: const Timeout(Duration(minutes: 2)));
84
85
 
86
+ test('DECOY: the harness plane is multi-session — an unpinned plane op refuses like production', () async {
87
+ final skip = _missingPrereq();
88
+ if (skip != null) {
89
+ markTestSkipped(skip);
90
+ return;
91
+ }
92
+ final harness = await NomosTestHarness.ephemeral(
93
+ deployJsonPaths: [_guestbookDeployJson],
94
+ nodeEntry: _repoEntry,
95
+ runtimeDir: _runtimeDir,
96
+ registerTearDown: addTearDown,
97
+ );
98
+ // harness.bridge is a SESSION HANDLE — pinned ops work as before…
99
+ expect(harness.bridge, isA<NomosSession>());
100
+ expect(await harness.bridge.query('entriesByAuthor', {'author': 'x'}), isEmpty);
101
+ // …but the RAW PLANE holds the decoy too, so an op that forgot its session refuses HERE —
102
+ // exactly the refusal a real multi-workspace app would hit (never a silent sole-session guess).
103
+ final plane = (harness.bridge as NomosSession).parent;
104
+ await expectLater(
105
+ plane.query('entriesByAuthor', {'author': 'x'}),
106
+ throwsA(predicate((e) => '$e'.contains('multiple sessions'))),
107
+ );
108
+ }, timeout: const Timeout(Duration(minutes: 2)));
109
+
85
110
  test('OFFLINE: dispose is idempotent and removes the temp dir', () async {
86
111
  final skip = _missingPrereq();
87
112
  if (skip != null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.59.1",
3
+ "version": "0.60.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",