@githolon/dsl 0.59.1 → 0.60.1
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 +11 -0
- package/dart/lib/src/test_harness.dart +58 -10
- package/dart/lib/src/webview_bridge.dart +51 -6
- package/dart/pubspec.yaml +1 -1
- package/dart/test/test_harness_test.dart +49 -0
- package/package.json +1 -1
- package/src/codegen_dart_client.ts +5 -0
- package/src/codegen_proof.ts +46 -4
- package/src/compile_package_main.ts +27 -2
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 0.41.0
|
|
2
|
+
- `NomosTestHarness` emits named phase-progress diagnostics (install → spawn runner → await ready → connect, plus per-session progress in `cluster`) and each `connect()` carries a `connectTimeout` (default 120s) that throws a NAMED remedy instead of hanging — so a multi-test AppSim run can tell install vs runner-wait vs session-block vs deadlock (co2 feedback).
|
|
3
|
+
|
|
4
|
+
## 0.40.0
|
|
5
|
+
- `connect(maxMounted: …)` — the realm's mounted-workspace budget (LRU park/remount above it); useful on memory-constrained devices holding many local workspaces.
|
|
6
|
+
- 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`.
|
|
7
|
+
- `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.
|
|
8
|
+
|
|
9
|
+
## 0.39.0
|
|
10
|
+
- 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).
|
|
11
|
+
|
|
1
12
|
## 0.38.0
|
|
2
13
|
- 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
14
|
|
|
@@ -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,8 @@ 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
|
+
Duration connectTimeout = const Duration(seconds: 120),
|
|
140
|
+
bool decoySession = true,
|
|
132
141
|
}) async {
|
|
133
142
|
if (deployJsonPaths.isEmpty) {
|
|
134
143
|
throw NomosTestHarnessException(
|
|
@@ -154,8 +163,10 @@ class NomosTestHarness {
|
|
|
154
163
|
final workdir = await Directory.systemTemp.createTemp('nomos-harness-');
|
|
155
164
|
// Total-ordered teardown even on a mid-setup failure: build the transport lazily and clean up on throw.
|
|
156
165
|
NodeRunnerTransport? transport;
|
|
166
|
+
final phase = (String m) { if (onDiagnostic != null) onDiagnostic('[nomos-test-harness] $m'); };
|
|
157
167
|
try {
|
|
158
168
|
final snapshot = '${workdir.path}/snapshot.bin';
|
|
169
|
+
phase('phase 1/4 installing ${deployJsonPaths.length} law package(s) offline into "$workspace" …');
|
|
159
170
|
final info = await _runInstall(
|
|
160
171
|
node: node,
|
|
161
172
|
useInRepoScript: useInRepoScript,
|
|
@@ -167,6 +178,7 @@ class NomosTestHarness {
|
|
|
167
178
|
runtimeDir: runtimeDir,
|
|
168
179
|
onDiagnostic: onDiagnostic);
|
|
169
180
|
|
|
181
|
+
phase('phase 2/4 spawning the node runner (node=$node) …');
|
|
170
182
|
transport = await NodeRunnerTransport.start(
|
|
171
183
|
entry: entry,
|
|
172
184
|
resolveFrom: resolveFrom,
|
|
@@ -178,19 +190,44 @@ class NomosTestHarness {
|
|
|
178
190
|
},
|
|
179
191
|
onDiagnostic: onDiagnostic,
|
|
180
192
|
);
|
|
181
|
-
final
|
|
182
|
-
|
|
193
|
+
final plane = NomosBridge(transport);
|
|
194
|
+
phase('phase 3/4 awaiting runner ready (≤${readyTimeout.inSeconds}s) …');
|
|
195
|
+
await plane.ready.timeout(readyTimeout,
|
|
183
196
|
onTimeout: () => throw NomosTestHarnessException(
|
|
184
|
-
'the offline runner never signalled ready within $readyTimeout '
|
|
185
|
-
'(is `node` on PATH and ~/.holon/runtime warm?
|
|
186
|
-
|
|
197
|
+
'the offline runner never signalled ready within ${readyTimeout.inSeconds}s '
|
|
198
|
+
'(phase 3/4: is `node` on PATH and ~/.holon/runtime warm? the runner process may have '
|
|
199
|
+
'failed to boot the engine — run with onDiagnostic to see its stderr)'));
|
|
200
|
+
phase('phase 4/4 connecting session "$workspace" (≤${connectTimeout.inSeconds}s) …');
|
|
201
|
+
await plane.connect(
|
|
187
202
|
cloud: 'https://offline.invalid', // ignored: the offline runner serves everything locally
|
|
188
203
|
workspace: workspace,
|
|
189
204
|
clientId: clientId,
|
|
190
205
|
allowUnborn: true,
|
|
191
|
-
)
|
|
206
|
+
).timeout(connectTimeout,
|
|
207
|
+
onTimeout: () => throw NomosTestHarnessException(
|
|
208
|
+
'connect("$workspace") did not complete within ${connectTimeout.inSeconds}s (phase 4/4) — '
|
|
209
|
+
'the runner is up but the session never mounted; a large ledger converge-fold can be slow, '
|
|
210
|
+
'raise connectTimeout, or run with onDiagnostic to see where it blocked'));
|
|
211
|
+
phase('ready — session "$workspace" connected (law ${(info['lawHashes'] as List?)?.isNotEmpty == true ? (info['lawHashes'] as List).first.toString().substring(0, 12) : "installed"})');
|
|
212
|
+
// THE DECOY SESSION (multi-workspace by default in tests): sole-session inference is how
|
|
213
|
+
// single-workspace assumptions ship — with one session connected, an op that forgot its session
|
|
214
|
+
// silently works in tests and only refuses in production when the app connects its second
|
|
215
|
+
// workspace. The decoy keeps the plane at ≥2 sessions so any unpinned op refuses HERE, in the
|
|
216
|
+
// dev's first test. [bridge] is a SESSION HANDLE pinned to [workspace], so tenant code written
|
|
217
|
+
// against the harness keeps working unchanged; only code that grabs the raw plane and assumes
|
|
218
|
+
// single-session trips. Opt out with `decoySession: false` (please don't).
|
|
219
|
+
if (decoySession) {
|
|
220
|
+
await plane.connect(
|
|
221
|
+
cloud: 'https://offline.invalid',
|
|
222
|
+
workspace: _decoyWorkspace,
|
|
223
|
+
session: _decoyWorkspace,
|
|
224
|
+
clientId: '$clientId-decoy',
|
|
225
|
+
allowUnborn: true,
|
|
226
|
+
restoreFrom: base64Encode(await File(snapshot).readAsBytes()),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
192
229
|
final harness = NomosTestHarness._(
|
|
193
|
-
|
|
230
|
+
plane.session(workspace), workspace, transport, workdir, onDiagnostic, [workspace],
|
|
194
231
|
frameworkHash: info['frameworkHash']?.toString(),
|
|
195
232
|
lawHashes: {
|
|
196
233
|
workspace: ((info['lawHashes'] as List?) ?? const [])
|
|
@@ -241,6 +278,7 @@ class NomosTestHarness {
|
|
|
241
278
|
void Function(Future<void> Function())? registerTearDown,
|
|
242
279
|
void Function(String line)? onDiagnostic,
|
|
243
280
|
Duration readyTimeout = const Duration(seconds: 30),
|
|
281
|
+
Duration connectTimeout = const Duration(seconds: 120),
|
|
244
282
|
}) async {
|
|
245
283
|
if (workspaces.isEmpty) {
|
|
246
284
|
throw NomosTestHarnessException(
|
|
@@ -287,6 +325,8 @@ class NomosTestHarness {
|
|
|
287
325
|
}
|
|
288
326
|
|
|
289
327
|
// 2. ONE runner / ONE bridge for the whole plane — no env restore default; each connect brings its own.
|
|
328
|
+
final phase = (String m) { if (onDiagnostic != null) onDiagnostic('[nomos-test-harness] $m'); };
|
|
329
|
+
phase('cluster: spawning the node runner for ${workspaces.length} workspace(s) …');
|
|
290
330
|
transport = await NodeRunnerTransport.start(
|
|
291
331
|
entry: entry,
|
|
292
332
|
resolveFrom: resolveFrom,
|
|
@@ -298,24 +338,32 @@ class NomosTestHarness {
|
|
|
298
338
|
onDiagnostic: onDiagnostic,
|
|
299
339
|
);
|
|
300
340
|
final bridge = NomosBridge(transport);
|
|
341
|
+
phase('cluster: awaiting runner ready (≤${readyTimeout.inSeconds}s) …');
|
|
301
342
|
await bridge.ready.timeout(readyTimeout,
|
|
302
343
|
onTimeout: () => throw NomosTestHarnessException(
|
|
303
|
-
'the offline runner never signalled ready within $readyTimeout '
|
|
304
|
-
'(is `node` on PATH and ~/.holon/runtime warm?)'));
|
|
344
|
+
'the offline runner never signalled ready within ${readyTimeout.inSeconds}s '
|
|
345
|
+
'(is `node` on PATH and ~/.holon/runtime warm? run with onDiagnostic to see the runner stderr)'));
|
|
305
346
|
|
|
306
347
|
// 3. connect one SESSION per workspace, each restoring its OWN snapshot — LOCAL-ONLY (no cloud;
|
|
307
348
|
// these are local githolons on the device plane, offline by nature).
|
|
308
349
|
final snapshotB64 = <String, String>{};
|
|
350
|
+
var _n = 0;
|
|
309
351
|
for (final e in workspaces.entries) {
|
|
310
352
|
snapshotB64[e.key] = base64Encode(await File(snapshots[e.key]!).readAsBytes());
|
|
353
|
+
phase('cluster: connecting session ${++_n}/${workspaces.length} "${e.key}" (≤${connectTimeout.inSeconds}s) …');
|
|
311
354
|
await bridge.connect(
|
|
312
355
|
workspace: e.key,
|
|
313
356
|
session: e.key,
|
|
314
357
|
clientId: '$clientId-${e.key}',
|
|
315
358
|
restoreFrom: snapshotB64[e.key],
|
|
316
359
|
allowUnborn: true,
|
|
317
|
-
)
|
|
360
|
+
).timeout(connectTimeout,
|
|
361
|
+
onTimeout: () => throw NomosTestHarnessException(
|
|
362
|
+
'connect("${e.key}") did not complete within ${connectTimeout.inSeconds}s — the runner is up '
|
|
363
|
+
'but this session never mounted (a large ledger converge-fold can be slow; raise connectTimeout '
|
|
364
|
+
'or run with onDiagnostic)'));
|
|
318
365
|
}
|
|
366
|
+
phase('cluster: ${workspaces.length} session(s) connected');
|
|
319
367
|
|
|
320
368
|
// 3b. DEVICE-KEY the named sessions: mint a fresh in-kernel key on each (now that it is mounted) and
|
|
321
369
|
// RE-OPEN it with that key as its signing authorSecret — the resolver's attested-read signing key.
|
|
@@ -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
|
|
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
|
-
|
|
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 =>
|
|
1706
|
+
NomosCompat? get compat =>
|
|
1707
|
+
parent._lastCompatBySession[workspace] ?? parent.compat;
|
|
1665
1708
|
@override
|
|
1666
|
-
Stream<NomosCompat> get onLawChanged => parent.
|
|
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.
|
|
14
|
+
version: 0.41.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,54 @@ 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
|
+
|
|
110
|
+
test('DX: ephemeral emits phase-progress diagnostics (install → runner → ready → connect)', () async {
|
|
111
|
+
final skip = _missingPrereq();
|
|
112
|
+
if (skip != null) {
|
|
113
|
+
markTestSkipped(skip);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
final lines = <String>[];
|
|
117
|
+
final harness = await NomosTestHarness.ephemeral(
|
|
118
|
+
deployJsonPaths: [_guestbookDeployJson],
|
|
119
|
+
nodeEntry: _repoEntry,
|
|
120
|
+
runtimeDir: _runtimeDir,
|
|
121
|
+
registerTearDown: addTearDown,
|
|
122
|
+
onDiagnostic: lines.add,
|
|
123
|
+
);
|
|
124
|
+
final joined = lines.join('\n');
|
|
125
|
+
// the four named phases are surfaced so an AppSim can tell install vs runner-wait vs connect-block.
|
|
126
|
+
expect(joined, contains('phase 1/4 installing'));
|
|
127
|
+
expect(joined, contains('phase 2/4 spawning the node runner'));
|
|
128
|
+
expect(joined, contains('phase 3/4 awaiting runner ready'));
|
|
129
|
+
expect(joined, contains('phase 4/4 connecting session'));
|
|
130
|
+
expect(joined, contains('ready — session "test-ws" connected'));
|
|
131
|
+
expect(harness.bridge, isNotNull);
|
|
132
|
+
}, timeout: const Timeout(Duration(minutes: 2)));
|
|
133
|
+
|
|
85
134
|
test('OFFLINE: dispose is idempotent and removes the temp dir', () async {
|
|
86
135
|
final skip = _missingPrereq();
|
|
87
136
|
if (skip != null) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.1",
|
|
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",
|
|
@@ -350,6 +350,11 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
350
350
|
`// Drives the REAL githolon over a NomosBridge (the WebView runner). Regenerated every compile; do`,
|
|
351
351
|
`// not edit. Construct with a connected bridge:`,
|
|
352
352
|
`//`,
|
|
353
|
+
// GENERATED-CODE lints: the bridge read/watch surface returns dynamic (JSON rows), so typed accessors
|
|
354
|
+
// do dynamic member access + casts by construction. Silence the lints that only make sense for
|
|
355
|
+
// hand-written code, so this file never adds noise to a tenant's analyzer output (co2 feedback).
|
|
356
|
+
`// ignore_for_file: avoid_dynamic_calls, unnecessary_this, prefer_const_constructors, unused_import, type_annotate_public_apis, always_specify_types`,
|
|
357
|
+
`//`,
|
|
353
358
|
`// final bridge = NomosBridge(transport); // transport = your WebViewTransport`,
|
|
354
359
|
`// await bridge.ready;`,
|
|
355
360
|
`// await bridge.connect(cloud: cloud, workspace: ws, clientId: deviceId);`,
|
package/src/codegen_proof.ts
CHANGED
|
@@ -656,6 +656,10 @@ export interface RelationBootstrapLeg {
|
|
|
656
656
|
* would trip the seed's owner-binding prerequisite (HomeOwner unset) and FALSE-fail. When present, the
|
|
657
657
|
* runner IGNORES the standalone grant fields and proves via {@link BornContextAuthority}. */
|
|
658
658
|
readonly bornContext?: BornContextAuthority;
|
|
659
|
+
/** THE GATED-GRANTER CHAIN (co2: `grantCo2EstateCreator` itself `.requires("co2Admin")`): the granter's
|
|
660
|
+
* OWN gate bootstrapped recursively — the runner seats THIS authority first (its prerequisite seeds +
|
|
661
|
+
* its grant on the same actor), then the outer grant admits. Depth/cycle-guarded at discovery. */
|
|
662
|
+
readonly granterAuthority?: RelationBootstrapLeg;
|
|
659
663
|
/** THE PREREQUISITE-SEED CHAIN (the composed co2_platform / `seedPlatformAdmin` case). Present iff the
|
|
660
664
|
* granter (`grantDirectiveId`) carries a PREREQUISITE invariant whose state is established NOT by a birth
|
|
661
665
|
* genesis but by a SIBLING SEED in the SAME domain (e.g. `seedPlatformAdmin`'s `PlatformAdminBindsFounder`
|
|
@@ -1186,7 +1190,7 @@ export function discoverPrerequisiteChain(mod: DomainModule, granter: Directive<
|
|
|
1186
1190
|
* granter, so granting B makes the actor satisfy R. `undefined` ⇒ no gated directive, or no granter for R
|
|
1187
1191
|
* or any of its bases (the caller may then fall through to the seed-path lane; the negative conformance
|
|
1188
1192
|
* leg always still runs). */
|
|
1189
|
-
export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstrapLeg | undefined {
|
|
1193
|
+
export function discoverRelationBootstrapLeg(mod: DomainModule, gatedOverride?: Directive<unknown>, _seen: ReadonlySet<string> = new Set()): RelationBootstrapLeg | undefined {
|
|
1190
1194
|
const domain = mod.domain ?? mod.name;
|
|
1191
1195
|
const directives = mod.directives as Directive<unknown>[];
|
|
1192
1196
|
const isGatedProvable = (d: Directive<unknown> | undefined): boolean =>
|
|
@@ -1198,8 +1202,15 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
|
|
|
1198
1202
|
// naive standalone-create leg (isStandaloneCreate), so the bootstrap leg is its ONLY positive proof — seed
|
|
1199
1203
|
// authority, then prove the CREATE is admitted. When no gated create exists, fall back to any gated write
|
|
1200
1204
|
// (a gated `.mutates`/`.ensures`), so an un-created gated law is still proven.
|
|
1201
|
-
|
|
1202
|
-
|
|
1205
|
+
// `gatedOverride` targets a SPECIFIC gated directive instead (the gated-`.births()` case — excluded from
|
|
1206
|
+
// the default scan because a birth is proven by its OWN leg; the override reuses the same granter/seed
|
|
1207
|
+
// discovery to SEAT the birth's required relation).
|
|
1208
|
+
const gated = gatedOverride !== undefined && gatedOverride.requiresRelationGate !== undefined && typeof gatedOverride.payloadSchema === "object"
|
|
1209
|
+
? gatedOverride
|
|
1210
|
+
: gatedOverride !== undefined
|
|
1211
|
+
? undefined
|
|
1212
|
+
: (directives.find((d) => isGatedProvable(d) && d.marker === "creates")
|
|
1213
|
+
?? directives.find((d) => isGatedProvable(d)));
|
|
1203
1214
|
if (gated === undefined) return undefined;
|
|
1204
1215
|
const relation = gated.requiresRelationGate!.relation;
|
|
1205
1216
|
|
|
@@ -1313,6 +1324,18 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
|
|
|
1313
1324
|
if (found === undefined) return undefined; // no granter at all → the caller emits the diagnostic
|
|
1314
1325
|
const granter = found.granter;
|
|
1315
1326
|
|
|
1327
|
+
// GATED GRANTER (co2: grantCo2EstateCreator itself .requires("co2Admin")): recursively bootstrap the
|
|
1328
|
+
// granter's OWN gate so the runner seats that authority first. Cycle/depth-guarded via the seen-set; an
|
|
1329
|
+
// unresolvable granter gate still emits the leg WITHOUT the chain (the runner's refusal names the gap —
|
|
1330
|
+
// never silently weaker than before this lane existed).
|
|
1331
|
+
let granterAuthority: RelationBootstrapLeg | undefined;
|
|
1332
|
+
if (granter.requiresRelationGate !== undefined && !_seen.has(granter.id) && _seen.size < 4) {
|
|
1333
|
+
granterAuthority = discoverRelationBootstrapLeg(mod, granter, new Set([..._seen, gated.id, granter.id]));
|
|
1334
|
+
// a born-context result proves INSIDE a born workspace — not a seatable pre-grant; drop it (the
|
|
1335
|
+
// runner's refusal then names the granter's unseatable gate).
|
|
1336
|
+
if (granterAuthority?.bornContext !== undefined) granterAuthority = undefined;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1316
1339
|
// Synthesize the grant payload + resolve which fields route into subject / grantedBy (so runtime seats the
|
|
1317
1340
|
// grant on the proof actor). Best-effort: an un-probable grant plan still emits the leg (runtime overwrites
|
|
1318
1341
|
// any field whose name looks like a subject/grantedBy anyway).
|
|
@@ -1343,6 +1366,7 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
|
|
|
1343
1366
|
...(seedSpeculative ? { seedSpeculative: true } : {}),
|
|
1344
1367
|
...(seedWritesRelations !== undefined ? { seedWritesRelations } : {}),
|
|
1345
1368
|
...(prerequisiteSeeds !== undefined ? { prerequisiteSeeds } : {}),
|
|
1369
|
+
...(granterAuthority !== undefined ? { granterAuthority } : {}),
|
|
1346
1370
|
};
|
|
1347
1371
|
}
|
|
1348
1372
|
|
|
@@ -1464,6 +1488,12 @@ export interface ChildBirthLeg {
|
|
|
1464
1488
|
* parent workspace (home → estate is the canonical case), the proof runner drives this setup birth first
|
|
1465
1489
|
* and then authors the child birth inside the born parent. */
|
|
1466
1490
|
readonly parentSetup?: ChildBirthParentSetup;
|
|
1491
|
+
/** RELATION SETUP for a GATED birth (`.births().requires(R)`) with NO parent-birth authority path: the
|
|
1492
|
+
* relation-bootstrap discovery (granter marker / inferred writeTuple / computed base / prerequisite-seed
|
|
1493
|
+
* chain) reused to SEAT R on the birth actor before the runner drives the birth offer — the law's own
|
|
1494
|
+
* granters, never a proof-only backdoor. Absent when `parentSetup` covers authority or no granter is
|
|
1495
|
+
* discoverable (the runner then reports the gate refusal with the discovery gap named). */
|
|
1496
|
+
readonly relationSetup?: RelationBootstrapLeg;
|
|
1467
1497
|
/** COMPILE-TIME RECIPE DIAGNOSTICS (births DX): problems found by statically validating each genesis
|
|
1468
1498
|
* step against the composed package (missing child module, unknown seed directive, payload-schema
|
|
1469
1499
|
* mismatch, a plan that returns a non-array — the exact things that otherwise surface at proof time as
|
|
@@ -2041,7 +2071,19 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
|
|
|
2041
2071
|
// The child-birth legs (one per `.births()` directive) — discovered by running each plan. A parent
|
|
2042
2072
|
// with SEVERAL births (co2: birthCatalogueWorkspace + birthEstateWorkspace) keeps EVERY leg; keeping
|
|
2043
2073
|
// only the first left the other children compile-valid-only (unprovable through their birth).
|
|
2044
|
-
const childBirths = allBirths.filter((b) => b.parentDomain === domain).map((b) =>
|
|
2074
|
+
const childBirths = allBirths.filter((b) => b.parentDomain === domain).map((b) => {
|
|
2075
|
+
const withParent = attachParentSetup(b, allBirths);
|
|
2076
|
+
// A GATED birth with no parent-birth authority path: seat its relation via the SAME granter/seed
|
|
2077
|
+
// discovery the relation-bootstrap leg uses (the law's own authority path, e.g. co2's
|
|
2078
|
+
// seedPlatformFounder → seedPlatformAdmin → grantCo2EstateCreator → birthEstateWorkspace).
|
|
2079
|
+
if (withParent.birthRequiresRelation !== undefined && withParent.parentSetup === undefined) {
|
|
2080
|
+
const birthDir = (mod.directives as Directive<unknown>[]).find((d) => d?.id === b.birthDirectiveId);
|
|
2081
|
+
const rb = birthDir !== undefined ? discoverRelationBootstrapLeg(mod, birthDir) : undefined;
|
|
2082
|
+
// a born-context path proves INSIDE a born workspace — not a seatable pre-grant; leave it to the gap diagnostic.
|
|
2083
|
+
if (rb !== undefined && rb.bornContext === undefined) return { ...withParent, relationSetup: rb };
|
|
2084
|
+
}
|
|
2085
|
+
return withParent;
|
|
2086
|
+
});
|
|
2045
2087
|
const childBirthWithSetup = childBirths[0];
|
|
2046
2088
|
|
|
2047
2089
|
// The relation-bootstrap leg (when the domain has a `.requires(R)`-gated directive AND a discoverable
|
|
@@ -888,14 +888,26 @@ function emitDartPackages(
|
|
|
888
888
|
}
|
|
889
889
|
|
|
890
890
|
const declared = new Set<string>();
|
|
891
|
-
|
|
891
|
+
const declaredByFile = new Map<string, Set<string>>();
|
|
892
|
+
for (const [f, src] of typesSources) {
|
|
893
|
+
const ns = declaredDartTypeNames(src);
|
|
894
|
+
declaredByFile.set(f, ns);
|
|
895
|
+
for (const n of ns) declared.add(n);
|
|
896
|
+
}
|
|
897
|
+
// Only import files that actually CONTRIBUTE an alias target — an alias `typedef A = B` needs B in scope,
|
|
898
|
+
// but a file no alias references needs no import (the `export` re-exports it). Importing every file
|
|
899
|
+
// produced unused_import warnings in the tenant's barrel (co2 feedback).
|
|
900
|
+
const aliasTargets = new Set(Object.keys(names ?? {}));
|
|
901
|
+
const aliasImportUris = [...typesSources.keys()]
|
|
902
|
+
.filter((f) => [...(declaredByFile.get(f) ?? [])].some((n) => aliasTargets.has(n)))
|
|
903
|
+
.map((f) => `${genRel}/${f}.dart`);
|
|
892
904
|
const typesBarrel = [
|
|
893
905
|
"// GENERATED by nomos-compile — the TYPES package's public entrypoint. DO NOT EDIT.",
|
|
894
906
|
"// Pure data: payload classes, read models, VOs/enums, typed read accessors over the",
|
|
895
907
|
"// injected NomosReads seams. Frontend-safe — no bridge, no sessions, no lifecycle.",
|
|
896
908
|
"library;",
|
|
897
909
|
"",
|
|
898
|
-
...aliasImportLines(names,
|
|
910
|
+
...aliasImportLines(names, aliasImportUris),
|
|
899
911
|
`export 'package:nomos_types/nomos_types.dart';`,
|
|
900
912
|
...[...typesSources.keys()].map((f) => `export '${genRel}/${f}.dart';`),
|
|
901
913
|
...dartAliasLines(names, declared),
|
|
@@ -2291,6 +2303,19 @@ async function main(): Promise<void> {
|
|
|
2291
2303
|
}
|
|
2292
2304
|
}
|
|
2293
2305
|
console.log(` deploy ${rel(deployPath)}`);
|
|
2306
|
+
if (compact) {
|
|
2307
|
+
// COMPACT PACKAGES ride a base64 blob (nomos:usdJsonB64) — a peer needs the b64 decode arm
|
|
2308
|
+
// (kernel ≥ the 2026-07-04 release; every current cloud/browser/node runtime has it). A NATIVE
|
|
2309
|
+
// (iOS/macOS AOT) client pins its OWN kernel binary: if that xcframework predates the arm, install
|
|
2310
|
+
// fails with `parse embedded USDA IR: domain package missing USDA field nomos:usdJsonHex`. Flag it
|
|
2311
|
+
// so the tenant isn't surprised (the requested "flag compact when targeting native").
|
|
2312
|
+
console.log(``);
|
|
2313
|
+
console.log(` ⚠ compact package (base64 blob): requires a kernel with the b64 decode arm (≥ 2026-07-04).`);
|
|
2314
|
+
console.log(` Current cloud/browser/node runtimes have it. A NATIVE client (nomos_flutter iOS/macOS)`);
|
|
2315
|
+
console.log(` pins its OWN kernel — refetch the current NomosKernel.xcframework (rm ios/Frameworks/`);
|
|
2316
|
+
console.log(` NomosKernel.xcframework && pod install), or set { compact: false } for a hex package that`);
|
|
2317
|
+
console.log(` parses on every kernel (larger deploy body; may need the signed-upload deploy lane).`);
|
|
2318
|
+
}
|
|
2294
2319
|
console.log(``);
|
|
2295
2320
|
console.log(`deploy (Nomos Cloud):`);
|
|
2296
2321
|
console.log(` githolon login --agent && githolon ws create <ws> && githolon deploy <ws> # no secret — deploy is OPEN, kernel-judged at the offer gate`);
|