@githolon/dsl 0.55.1 → 0.57.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.
@@ -1,6 +1,11 @@
1
1
  /// The HEADLESS transport library — `dart:io` only, so it lives outside the main
2
2
  /// `package:nomos_client/nomos_client.dart` barrel (which stays web-safe).
3
3
  ///
4
+ /// MULTI-WORKSPACE BY DESIGN. A [NomosBridge] is a device's LOCAL HOLARCHY PLANE: one bridge / one
5
+ /// transport, and MANY live workspaces on it (the home, each estate, each catalogue, locally-born children).
6
+ /// Each workspace is a LOCAL githolon — cloud sync is an optional per-workspace posture, not a prerequisite.
7
+ /// Open workspaces and hold their [NomosSession] handles:
8
+ ///
4
9
  /// ```dart
5
10
  /// import 'package:nomos_client/nomos_client.dart';
6
11
  /// import 'package:nomos_client/nomos_headless.dart';
@@ -8,9 +13,19 @@
8
13
  /// final transport = await NodeRunnerTransport.start(resolveFrom: 'path/to/app');
9
14
  /// final bridge = NomosBridge(transport);
10
15
  /// await bridge.ready;
11
- /// await bridge.connect(cloud: ..., workspace: ..., clientId: 'test-runner');
16
+ /// // open each workspace on the plane (local-only here — no `cloud` needed):
17
+ /// await bridge.connect(workspace: 'home', session: 'home', restoreFrom: homeBytes);
18
+ /// await bridge.connect(workspace: 'estate-42', session: 'estate-42', restoreFrom: estateBytes);
19
+ /// // bind a generated client to a workspace via its session handle:
20
+ /// final estate = EstateClient(bridge.session('estate-42'));
21
+ /// final plane = await bridge.listWorkspaces(); // enumerate + sync posture
12
22
  /// ```
13
23
  ///
24
+ /// The single-workspace `bridge.connect(...)` / `bridge.query(...)` methods are sugar over an inferred
25
+ /// default session (unchanged). For TESTS, [NomosTestHarness] is a thin wrapper: `.ephemeral(...)` installs
26
+ /// one law + opens one session; `.cluster(...)` installs N laws + opens N sessions on ONE plane — it adds
27
+ /// no session logic of its own, it just drives the same production [NomosBridge.session] surface.
28
+ ///
14
29
  /// The runner child process is the SAME `@githolon/client` every peer runs; the
15
30
  /// generated Dart clients drive it through [NomosBridge] exactly as in the app.
16
31
  library;
@@ -34,7 +34,7 @@ import 'dart:convert';
34
34
  import 'dart:io';
35
35
 
36
36
  import 'node_runner_transport.dart';
37
- import 'webview_bridge.dart' show NomosBridge;
37
+ import 'webview_bridge.dart' show NomosBridge, NomosSession;
38
38
 
39
39
  class NomosTestHarnessException implements Exception {
40
40
  final String message;
@@ -55,8 +55,50 @@ class NomosTestHarness {
55
55
  final void Function(String line)? _onDiagnostic;
56
56
  bool _disposed = false;
57
57
 
58
+ /// The workspace names this harness connected. For [ephemeral] this is the one [workspace]; for [cluster]
59
+ /// it is every workspace named in the call. Use [session] to get its handle.
60
+ final List<String> _sessionNames;
61
+
62
+ /// workspace → the base64 device PUBLIC key it was opened with (only for [cluster]'s `deviceKeyed` set).
63
+ final Map<String, String> _devicePublics;
64
+
65
+ /// The nomos framework controller's content hash from the offline install — the `frameworkHash` a
66
+ /// `.births()` genesis installs (not derivable client-side). Same for every workspace on the plane.
67
+ final String? frameworkHash;
68
+
69
+ /// workspace → the tenant law hashes installed into it (in deploy order); `lawHashes[ws].first` is that
70
+ /// package's domainHash — the `lawHash` a `.births()` recipe names for the child's law install.
71
+ final Map<String, List<String>> _lawHashes;
72
+
73
+ /// The tenant law hashes installed into [workspace] (deploy order); empty if unknown.
74
+ List<String> lawHashes(String workspace) =>
75
+ List.unmodifiable(_lawHashes[workspace] ?? const []);
76
+
58
77
  NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
59
- this._onDiagnostic);
78
+ this._onDiagnostic, this._sessionNames,
79
+ {Map<String, String> devicePublics = const {},
80
+ this.frameworkHash,
81
+ Map<String, List<String>> lawHashes = const {}})
82
+ : _devicePublics = devicePublics,
83
+ _lawHashes = lawHashes;
84
+
85
+ /// The base64 device PUBLIC key a `deviceKeyed` [cluster] session was opened with (null otherwise). Its
86
+ /// `sha256(base64decode(...))` is the attestor keyHash a reader enrols to trust this source's signed reads.
87
+ String? devicePublicKey(String workspace) => _devicePublics[workspace];
88
+
89
+ /// The workspace names on this device's local plane (one for [ephemeral], N for [cluster]).
90
+ List<String> get sessions => List.unmodifiable(_sessionNames);
91
+
92
+ /// A [NomosSession] handle pinned to one connected [workspace] — hand it to a generated typed client
93
+ /// (`EstateClient(harness.session('estate'))`). A THIN delegate to the production [NomosBridge.session]:
94
+ /// the harness owns no session logic, it just installs the laws + opens the sessions on the one plane.
95
+ NomosSession session(String workspace) {
96
+ if (!_sessionNames.contains(workspace)) {
97
+ throw NomosTestHarnessException(
98
+ 'no session "$workspace" on this harness — connected sessions: $_sessionNames');
99
+ }
100
+ return bridge.session(workspace);
101
+ }
60
102
 
61
103
  /// Install [deployJsonPaths] into a fresh LOCAL workspace and boot a bridge over it — NO cloud contact.
62
104
  ///
@@ -114,7 +156,7 @@ class NomosTestHarness {
114
156
  NodeRunnerTransport? transport;
115
157
  try {
116
158
  final snapshot = '${workdir.path}/snapshot.bin';
117
- await _runInstall(
159
+ final info = await _runInstall(
118
160
  node: node,
119
161
  useInRepoScript: useInRepoScript,
120
162
  inRepoScript: resolvedHelper,
@@ -147,8 +189,8 @@ class NomosTestHarness {
147
189
  clientId: clientId,
148
190
  allowUnborn: true,
149
191
  );
150
- final harness =
151
- NomosTestHarness._(bridge, workspace, transport, workdir, onDiagnostic);
192
+ final harness = NomosTestHarness._(
193
+ bridge, workspace, transport, workdir, onDiagnostic, [workspace]);
152
194
  if (autoTearDown && registerTearDown != null) {
153
195
  registerTearDown(harness.dispose);
154
196
  }
@@ -167,6 +209,139 @@ class NomosTestHarness {
167
209
  }
168
210
  }
169
211
 
212
+ /// A COLOCATED TEST CLUSTER — install MANY workspaces' law offline and connect one SESSION per workspace on
213
+ /// ONE engine plane (one runner, one transport, one [bridge]), so a cross-workspace scenario (attested
214
+ /// reads, foreign-source imports) runs in-process with ZERO network. Each workspace's law is installed via
215
+ /// the SAME offline lane [ephemeral] uses (one snapshot per workspace), then `connect`ed as its own session;
216
+ /// the per-connect restore snapshot loads it into the shared plane.
217
+ ///
218
+ /// [workspaces] — `{ workspaceName: [deployJsonPaths] }` (installed in order per workspace). Get a
219
+ /// session-scoped view with [session] and bind it to a generated client: `EstateClient(harness.session(
220
+ /// 'estate'))`. All other params mirror [ephemeral]. [clientId] is suffixed per session.
221
+ /// [deviceKeyed] names workspaces to open WITH a fresh in-kernel device key as their signing `authorSecret`
222
+ /// (the warrant default — every write is signed, replay-provably). Required for a source of ATTESTED READS:
223
+ /// the colocation resolver serves+signs a foreign read with the source session's device key, so a reader
224
+ /// enrols `sha256(that device public key)`. Get the public via [devicePublicKey].
225
+ static Future<NomosTestHarness> cluster({
226
+ required Map<String, List<String>> workspaces,
227
+ Set<String> deviceKeyed = const {},
228
+ String clientId = 'test-runner',
229
+ String? runtimeDir,
230
+ String? nodeEntry,
231
+ String? installHelper,
232
+ String? resolveFrom,
233
+ String node = 'node',
234
+ bool autoTearDown = true,
235
+ void Function(Future<void> Function())? registerTearDown,
236
+ void Function(String line)? onDiagnostic,
237
+ Duration readyTimeout = const Duration(seconds: 30),
238
+ }) async {
239
+ if (workspaces.isEmpty) {
240
+ throw NomosTestHarnessException(
241
+ 'no workspaces — pass { workspaceName: [build/<name>.deploy.json] } for each colocated workspace');
242
+ }
243
+ for (final e in workspaces.entries) {
244
+ if (e.value.isEmpty) {
245
+ throw NomosTestHarnessException(
246
+ 'workspace "${e.key}" has no deployJsonPaths — pass its build/<name>.deploy.json');
247
+ }
248
+ }
249
+ final entry = nodeEntry ??
250
+ await _resolveEntry(node, resolveFrom ?? Directory.current.path);
251
+ final resolvedHelper =
252
+ installHelper ?? '${File(entry).parent.path}/offline-install.mjs';
253
+ final useInRepoScript =
254
+ resolvedHelper.endsWith('.mjs') && File(resolvedHelper).existsSync();
255
+ final githolonCmd = installHelper == null ? 'npx githolon' : resolvedHelper;
256
+
257
+ final workdir = await Directory.systemTemp.createTemp('nomos-cluster-');
258
+ NodeRunnerTransport? transport;
259
+ try {
260
+ // 1. install each workspace's law offline → one snapshot per workspace (colocated, still separate law).
261
+ final snapshots = <String, String>{};
262
+ for (final e in workspaces.entries) {
263
+ final snap = '${workdir.path}/${e.key}.bin';
264
+ await _runInstall(
265
+ node: node,
266
+ useInRepoScript: useInRepoScript,
267
+ inRepoScript: resolvedHelper,
268
+ githolonCmd: githolonCmd,
269
+ deployJsonPaths: e.value,
270
+ workspace: e.key,
271
+ snapshot: snap,
272
+ runtimeDir: runtimeDir,
273
+ onDiagnostic: onDiagnostic);
274
+ snapshots[e.key] = snap;
275
+ }
276
+
277
+ // 2. ONE runner / ONE bridge for the whole plane — no env restore default; each connect brings its own.
278
+ transport = await NodeRunnerTransport.start(
279
+ entry: entry,
280
+ resolveFrom: resolveFrom,
281
+ node: node,
282
+ environment: {
283
+ 'NOMOS_OFFLINE': '1',
284
+ if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
285
+ },
286
+ onDiagnostic: onDiagnostic,
287
+ );
288
+ final bridge = NomosBridge(transport);
289
+ await bridge.ready.timeout(readyTimeout,
290
+ onTimeout: () => throw NomosTestHarnessException(
291
+ 'the offline runner never signalled ready within $readyTimeout '
292
+ '(is `node` on PATH and ~/.holon/runtime warm?)'));
293
+
294
+ // 3. connect one SESSION per workspace, each restoring its OWN snapshot — LOCAL-ONLY (no cloud;
295
+ // these are local githolons on the device plane, offline by nature).
296
+ final snapshotB64 = <String, String>{};
297
+ for (final e in workspaces.entries) {
298
+ snapshotB64[e.key] = base64Encode(await File(snapshots[e.key]!).readAsBytes());
299
+ await bridge.connect(
300
+ workspace: e.key,
301
+ session: e.key,
302
+ clientId: '$clientId-${e.key}',
303
+ restoreFrom: snapshotB64[e.key],
304
+ allowUnborn: true,
305
+ );
306
+ }
307
+
308
+ // 3b. DEVICE-KEY the named sessions: mint a fresh in-kernel key on each (now that it is mounted) and
309
+ // RE-OPEN it with that key as its signing authorSecret — the resolver's attested-read signing key.
310
+ final devicePublics = <String, String>{};
311
+ for (final ws in deviceKeyed) {
312
+ final kp = await bridge.session(ws).authorKeygen();
313
+ devicePublics[ws] = (kp['public'] ?? '').toString();
314
+ await bridge.connect(
315
+ workspace: ws,
316
+ session: ws,
317
+ clientId: '$clientId-$ws',
318
+ restoreFrom: snapshotB64[ws],
319
+ authorSecret: (kp['secret'] ?? '').toString(),
320
+ allowUnborn: true,
321
+ );
322
+ }
323
+
324
+ final names = workspaces.keys.toList();
325
+ final harness = NomosTestHarness._(
326
+ bridge, names.first, transport, workdir, onDiagnostic, names,
327
+ devicePublics);
328
+ if (autoTearDown && registerTearDown != null) {
329
+ registerTearDown(harness.dispose);
330
+ }
331
+ return harness;
332
+ } catch (_) {
333
+ if (transport != null) {
334
+ try {
335
+ await transport.dispose();
336
+ } catch (_) {}
337
+ }
338
+ try {
339
+ await workdir.delete(recursive: true);
340
+ } catch (_) {}
341
+ rethrow;
342
+ }
343
+ }
344
+
170
345
  /// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
171
346
  /// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
172
347
  /// failed setup. Nothing left running, nothing left on disk.
@@ -196,7 +371,7 @@ class NomosTestHarness {
196
371
 
197
372
  // ── offline install (a short-lived node child; produces the restore snapshot) ──
198
373
 
199
- static Future<void> _runInstall({
374
+ static Future<Map<String, Object?>> _runInstall({
200
375
  required String node,
201
376
  required bool useInRepoScript,
202
377
  required String inRepoScript,
@@ -255,6 +430,9 @@ class NomosTestHarness {
255
430
  throw NomosTestHarnessException(
256
431
  'offline law install produced no snapshot (stdout: ${out.isEmpty ? "(empty)" : out})');
257
432
  }
433
+ // { ok, snapshot, workspace, lawHashes, frameworkHash } — the caller keeps lawHashes/frameworkHash so a
434
+ // `.births()` directive can be driven (its genesis installs the framework + law by hash).
435
+ return decoded;
258
436
  }
259
437
 
260
438
  static Future<String> _resolveEntry(String node, String from) async {