@githolon/dsl 0.102.1 → 0.102.2
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 +9 -0
- package/dart/lib/src/dev_cloud.dart +61 -18
- package/dart/lib/src/runner/node-entry.mjs +27 -6
- package/dart/lib/src/runner/offline-install.mjs +80 -18
- package/dart/lib/src/test_harness.dart +874 -554
- package/dart/pubspec.yaml +1 -1
- package/package.json +1 -1
- package/runtime/generated-application-host.js +14 -6
- package/src/application_composition.ts +117 -2
- package/src/business_birth.ts +11 -9
- package/src/codegen_dart_application.ts +28 -24
- package/src/codegen_dart_scenario.ts +68 -9
- package/src/compile_package_main.ts +168 -42
- package/src/framework/home.ts +7 -1
- package/src/law_check.ts +8 -0
- package/src/usd_layers.ts +68 -51
- package/src/workspace_sharing.ts +5 -3
|
@@ -48,6 +48,114 @@ import 'node_runner_transport.dart';
|
|
|
48
48
|
import 'session_bound.dart' show DomainNotInstalledForSession;
|
|
49
49
|
import 'webview_bridge.dart' show NomosBridge, NomosSession;
|
|
50
50
|
|
|
51
|
+
/// A source checkout is itself a coordinated release candidate: compiler, framework packages and kernels
|
|
52
|
+
/// move together. Generated scenarios must not combine those source-generated laws with a recently-written
|
|
53
|
+
/// but older global runtime cache. Published packages have no release/ directory and keep the normal
|
|
54
|
+
/// release-pinned runtime acquisition path; this branch is only the zero-ceremony dogfood path.
|
|
55
|
+
Future<String?> _sourceCandidateRuntime(String nodeEntry) async {
|
|
56
|
+
var cursor = File(nodeEntry).absolute.parent;
|
|
57
|
+
Directory? root;
|
|
58
|
+
for (var depth = 0; depth < 12; depth++) {
|
|
59
|
+
if (File('${cursor.path}/release/kernel-candidate.json').existsSync() &&
|
|
60
|
+
File('${cursor.path}/cloud/dev-server/kernel-testmint.wasm')
|
|
61
|
+
.existsSync()) {
|
|
62
|
+
root = cursor;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
final parent = cursor.parent;
|
|
66
|
+
if (parent.path == cursor.path) break;
|
|
67
|
+
cursor = parent;
|
|
68
|
+
}
|
|
69
|
+
if (root == null) return null;
|
|
70
|
+
|
|
71
|
+
final descriptor = (jsonDecode(
|
|
72
|
+
await File(
|
|
73
|
+
'${root.path}/release/kernel-candidate.json',
|
|
74
|
+
).readAsString(),
|
|
75
|
+
) as Map)
|
|
76
|
+
.cast<String, Object?>();
|
|
77
|
+
String expected(String key) =>
|
|
78
|
+
(((descriptor[key] as Map?)?['sha256'])?.toString() ?? '').trim();
|
|
79
|
+
final productionExpected = expected('production');
|
|
80
|
+
final testMintExpected = expected('testMint');
|
|
81
|
+
final production = File(
|
|
82
|
+
'${root.path}/cloud/cloudflare-shell/vendor/holon_bytes.wasmbin',
|
|
83
|
+
);
|
|
84
|
+
final testMint = File(
|
|
85
|
+
'${root.path}/cloud/dev-server/kernel-testmint.wasm',
|
|
86
|
+
);
|
|
87
|
+
final bootstrap = File(
|
|
88
|
+
'${root.path}/cloud/cloudflare-shell/domains/domain_bootstrap.package.usda',
|
|
89
|
+
);
|
|
90
|
+
final nomos = File(
|
|
91
|
+
'${root.path}/cloud/cloudflare-shell/domains/domain_nomos.package.usda',
|
|
92
|
+
);
|
|
93
|
+
for (final file in [production, testMint, bootstrap, nomos]) {
|
|
94
|
+
if (!file.existsSync()) {
|
|
95
|
+
throw NomosTestHarnessException(
|
|
96
|
+
'source release candidate is incomplete: ${file.path} is missing',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
final productionBytes = await production.readAsBytes();
|
|
101
|
+
final testMintBytes = await testMint.readAsBytes();
|
|
102
|
+
final productionActual = sha256.convert(productionBytes).toString();
|
|
103
|
+
final testMintActual = sha256.convert(testMintBytes).toString();
|
|
104
|
+
String short(String value) =>
|
|
105
|
+
value.length <= 12 ? value : value.substring(0, 12);
|
|
106
|
+
if (productionActual != productionExpected ||
|
|
107
|
+
testMintActual != testMintExpected) {
|
|
108
|
+
throw NomosTestHarnessException(
|
|
109
|
+
'source release candidate kernel mismatch: release/kernel-candidate.json '
|
|
110
|
+
'expects ${short(productionExpected)}/${short(testMintExpected)}, but the staged artifacts are '
|
|
111
|
+
'${short(productionActual)}/${short(testMintActual)} — run the kernel candidate preparation before testing',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
final bootstrapText = await bootstrap.readAsString();
|
|
115
|
+
final nomosText = await nomos.readAsString();
|
|
116
|
+
final bundleIdentity = sha256
|
|
117
|
+
.convert(
|
|
118
|
+
utf8.encode(
|
|
119
|
+
'$productionActual:$testMintActual:'
|
|
120
|
+
'${sha256.convert(utf8.encode(bootstrapText))}:'
|
|
121
|
+
'${sha256.convert(utf8.encode(nomosText))}',
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
.toString();
|
|
125
|
+
final runtime = Directory(
|
|
126
|
+
'${Directory.systemTemp.path}/nomos-source-runtime-${bundleIdentity.substring(0, 20)}',
|
|
127
|
+
);
|
|
128
|
+
await runtime.create(recursive: true);
|
|
129
|
+
Future<void> publish(String name, List<int> bytes) async {
|
|
130
|
+
final target = File('${runtime.path}/$name');
|
|
131
|
+
if (target.existsSync() && target.lengthSync() == bytes.length) return;
|
|
132
|
+
final candidate = File(
|
|
133
|
+
'${target.path}.tmp-${pid}-${DateTime.now().microsecondsSinceEpoch}');
|
|
134
|
+
await candidate.writeAsBytes(bytes, flush: true);
|
|
135
|
+
try {
|
|
136
|
+
await candidate.rename(target.path);
|
|
137
|
+
} on FileSystemException {
|
|
138
|
+
if (!target.existsSync()) rethrow;
|
|
139
|
+
try {
|
|
140
|
+
await candidate.delete();
|
|
141
|
+
} on FileSystemException {
|
|
142
|
+
// Another process won the same content-addressed publication race; its complete target is enough.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await publish('holon.wasm', productionBytes);
|
|
148
|
+
await publish('holon-testmint.wasm', testMintBytes);
|
|
149
|
+
await publish(
|
|
150
|
+
'packages.json',
|
|
151
|
+
utf8.encode(jsonEncode(<String, String>{
|
|
152
|
+
'bootstrap': bootstrapText,
|
|
153
|
+
'nomos': nomosText,
|
|
154
|
+
})),
|
|
155
|
+
);
|
|
156
|
+
return runtime.path;
|
|
157
|
+
}
|
|
158
|
+
|
|
51
159
|
/// Framework-internal classifier for a sync result that can never become converged by retrying. Generated
|
|
52
160
|
/// scenarios use it through [NomosTestHarness.converge]; it is public only inside `package:nomos_client/src`
|
|
53
161
|
/// so the false-green contract has a cheap regression test without booting a cloud.
|
|
@@ -58,8 +166,11 @@ String? nmsPermanentConvergenceFailure(Map<String, Object?> result) {
|
|
|
58
166
|
: const [];
|
|
59
167
|
final rejected = sessions
|
|
60
168
|
.whereType<Map>()
|
|
61
|
-
.expand(
|
|
62
|
-
|
|
169
|
+
.expand(
|
|
170
|
+
(session) => session['rejected'] is List
|
|
171
|
+
? session['rejected'] as List
|
|
172
|
+
: const [],
|
|
173
|
+
)
|
|
63
174
|
.toList();
|
|
64
175
|
final deadLetters = admission is Map && admission['deadLetters'] is List
|
|
65
176
|
? admission['deadLetters'] as List
|
|
@@ -137,9 +248,10 @@ class HarnessPhaseTimeout extends NomosTestHarnessException {
|
|
|
137
248
|
required this.awaiting,
|
|
138
249
|
this.likelyCauses = const [],
|
|
139
250
|
}) : super(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
251
|
+
'phase "$phase"${workspace != null ? ' (workspace "$workspace")' : ''} '
|
|
252
|
+
'did not complete within ${elapsed.inSeconds}s while awaiting `$awaiting`'
|
|
253
|
+
'${likelyCauses.isEmpty ? '' : ' — likely causes: ${likelyCauses.map((c) => '\n • $c').join()}'}',
|
|
254
|
+
);
|
|
143
255
|
@override
|
|
144
256
|
String toString() => 'HarnessPhaseTimeout: $message';
|
|
145
257
|
}
|
|
@@ -234,25 +346,32 @@ class NomosTestHarness {
|
|
|
234
346
|
final ws = homeWorkspace;
|
|
235
347
|
if (ws == null) {
|
|
236
348
|
throw NomosTestHarnessException(
|
|
237
|
-
|
|
349
|
+
'this harness has no primary application session',
|
|
350
|
+
);
|
|
238
351
|
}
|
|
239
352
|
return session(ws);
|
|
240
353
|
}
|
|
241
354
|
|
|
242
|
-
NomosTestHarness._(
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
355
|
+
NomosTestHarness._(
|
|
356
|
+
this.bridge,
|
|
357
|
+
this.workspace,
|
|
358
|
+
this._transport,
|
|
359
|
+
this._workdir,
|
|
360
|
+
this._onDiagnostic,
|
|
361
|
+
List<String> sessionNames, {
|
|
362
|
+
Map<String, String> devicePublics = const {},
|
|
363
|
+
Map<String, String> deviceSecrets = const {},
|
|
364
|
+
this.frameworkHash,
|
|
365
|
+
Map<String, List<String>> lawHashes = const {},
|
|
366
|
+
Map<String, String> snapshots = const {},
|
|
367
|
+
_InstallLane? installLane,
|
|
368
|
+
Duration? phaseTimeout,
|
|
369
|
+
_CloudRestartLane? cloudRestartLane,
|
|
370
|
+
this.testCloud,
|
|
371
|
+
this.homeWorkspace,
|
|
372
|
+
}) : _sessionNames = List.of(
|
|
373
|
+
sessionNames,
|
|
374
|
+
), // GROWABLE — restoreWorkspace may add a fresh session
|
|
256
375
|
_devicePublics = devicePublics,
|
|
257
376
|
_deviceSecrets = deviceSecrets,
|
|
258
377
|
_lawHashes = Map.of(lawHashes),
|
|
@@ -286,13 +405,16 @@ class NomosTestHarness {
|
|
|
286
405
|
final where = workspace != null ? ' "$workspace"' : '';
|
|
287
406
|
log('phase $phase$where (≤${timeout.inSeconds}s) …');
|
|
288
407
|
final sw = Stopwatch()..start();
|
|
289
|
-
final r = await run().timeout(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
408
|
+
final r = await run().timeout(
|
|
409
|
+
timeout,
|
|
410
|
+
onTimeout: () => throw HarnessPhaseTimeout(
|
|
411
|
+
phase: phase,
|
|
412
|
+
workspace: workspace,
|
|
413
|
+
elapsed: timeout,
|
|
414
|
+
awaiting: awaiting,
|
|
415
|
+
likelyCauses: likelyCauses,
|
|
416
|
+
),
|
|
417
|
+
);
|
|
296
418
|
log('phase $phase$where done (${sw.elapsedMilliseconds}ms)');
|
|
297
419
|
return r;
|
|
298
420
|
}
|
|
@@ -322,13 +444,16 @@ class NomosTestHarness {
|
|
|
322
444
|
final secret = _deviceSecrets[workspace];
|
|
323
445
|
if (secret == null || secret.isEmpty) {
|
|
324
446
|
throw NomosTestHarnessException(
|
|
325
|
-
|
|
447
|
+
'the generated scenario cannot establish its business permission because the test device secret is unavailable',
|
|
448
|
+
);
|
|
326
449
|
}
|
|
327
|
-
final rows = await session(
|
|
328
|
-
|
|
450
|
+
final rows = await session(
|
|
451
|
+
workspace,
|
|
452
|
+
).queryById('birth-attestation:self', null, subject);
|
|
329
453
|
if (rows.length != 1) {
|
|
330
454
|
throw NomosTestHarnessException(
|
|
331
|
-
|
|
455
|
+
'the generated scenario expected one verified birth attestation before establishing business permission; found ${rows.length}',
|
|
456
|
+
);
|
|
332
457
|
}
|
|
333
458
|
final row = rows.single;
|
|
334
459
|
final data = row['data'] is Map
|
|
@@ -349,7 +474,8 @@ class NomosTestHarness {
|
|
|
349
474
|
final certificate = issued['certString']?.toString();
|
|
350
475
|
if (certificate == null || certificate.isEmpty) {
|
|
351
476
|
throw NomosTestHarnessException(
|
|
352
|
-
|
|
477
|
+
'the generated scenario could not mint the parent-rooted business delegation',
|
|
478
|
+
);
|
|
353
479
|
}
|
|
354
480
|
await session(workspace).offerOutcome(
|
|
355
481
|
domain: domain,
|
|
@@ -373,7 +499,8 @@ class NomosTestHarness {
|
|
|
373
499
|
NomosSession session(String workspace) {
|
|
374
500
|
if (!_sessionNames.contains(workspace)) {
|
|
375
501
|
throw NomosTestHarnessException(
|
|
376
|
-
|
|
502
|
+
'no session "$workspace" on this harness — connected sessions: $_sessionNames',
|
|
503
|
+
);
|
|
377
504
|
}
|
|
378
505
|
return bridge.session(workspace);
|
|
379
506
|
}
|
|
@@ -415,13 +542,16 @@ class NomosTestHarness {
|
|
|
415
542
|
}) async {
|
|
416
543
|
if (deployJsonPaths.isEmpty) {
|
|
417
544
|
throw NomosTestHarnessException(
|
|
418
|
-
|
|
545
|
+
'no deployJsonPaths — pass the build/<name>.deploy.json `npx githolon compile` emits',
|
|
546
|
+
);
|
|
419
547
|
}
|
|
420
548
|
final entry = nodeEntry ??
|
|
421
549
|
await _resolveEntry(node, resolveFrom ?? Directory.current.path);
|
|
422
550
|
final effectiveRuntimeDir = runtimeDir ??
|
|
551
|
+
Platform.environment['NOMOS_CANDIDATE_RUNTIME'] ??
|
|
423
552
|
Platform.environment['NOMOS_DART_RUNTIME'] ??
|
|
424
|
-
Platform.environment['NOMOS_OFFLINE_RUNTIME']
|
|
553
|
+
Platform.environment['NOMOS_OFFLINE_RUNTIME'] ??
|
|
554
|
+
await _sourceCandidateRuntime(entry);
|
|
425
555
|
// THE INSTALL LANE. [installHelper] is the one seam (PACKAGING NOTE above): either the in-repo
|
|
426
556
|
// `offline-install.mjs` node script, OR the published `githolon harness-install` subcommand (the CLI
|
|
427
557
|
// bundles the engine + this exact install sequence, so a published tenant needs nothing but `githolon`).
|
|
@@ -462,28 +592,29 @@ class NomosTestHarness {
|
|
|
462
592
|
try {
|
|
463
593
|
final snapshot = '${workdir.path}/snapshot.bin';
|
|
464
594
|
final info = await _phased(
|
|
465
|
-
|
|
466
|
-
|
|
595
|
+
phase:
|
|
596
|
+
'1/4: offline law install (${deployJsonPaths.length} package(s))',
|
|
597
|
+
workspace: installWorkspace,
|
|
598
|
+
awaiting:
|
|
599
|
+
useInRepoScript ? 'offline-install.ts' : 'githolon harness-install',
|
|
600
|
+
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
601
|
+
onDiagnostic: onDiagnostic,
|
|
602
|
+
likelyCauses: const [
|
|
603
|
+
'a cold npx download of githolon (pre-warm with any `githolon` run)',
|
|
604
|
+
'a very large law package folding through the offline gate — raise phaseTimeout',
|
|
605
|
+
],
|
|
606
|
+
run: () => _runInstall(
|
|
607
|
+
node: node,
|
|
608
|
+
useInRepoScript: useInRepoScript,
|
|
609
|
+
inRepoScript: resolvedHelper,
|
|
610
|
+
githolonCmd: githolonCmd,
|
|
611
|
+
deployJsonPaths: deployJsonPaths,
|
|
467
612
|
workspace: installWorkspace,
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
: 'githolon harness-install',
|
|
471
|
-
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
613
|
+
snapshot: snapshot,
|
|
614
|
+
runtimeDir: effectiveRuntimeDir,
|
|
472
615
|
onDiagnostic: onDiagnostic,
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
'a very large law package folding through the offline gate — raise phaseTimeout',
|
|
476
|
-
],
|
|
477
|
-
run: () => _runInstall(
|
|
478
|
-
node: node,
|
|
479
|
-
useInRepoScript: useInRepoScript,
|
|
480
|
-
inRepoScript: resolvedHelper,
|
|
481
|
-
githolonCmd: githolonCmd,
|
|
482
|
-
deployJsonPaths: deployJsonPaths,
|
|
483
|
-
workspace: installWorkspace,
|
|
484
|
-
snapshot: snapshot,
|
|
485
|
-
runtimeDir: effectiveRuntimeDir,
|
|
486
|
-
onDiagnostic: onDiagnostic));
|
|
616
|
+
),
|
|
617
|
+
);
|
|
487
618
|
|
|
488
619
|
final runnerEnvironment = <String, String>{
|
|
489
620
|
'NOMOS_OFFLINE': '1',
|
|
@@ -497,50 +628,53 @@ class NomosTestHarness {
|
|
|
497
628
|
'NOMOS_OFFLINE_RUNTIME': effectiveRuntimeDir,
|
|
498
629
|
};
|
|
499
630
|
transport = await _phased(
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
631
|
+
phase: '2/4: spawn the node runner',
|
|
632
|
+
awaiting: 'Process.start($node)',
|
|
633
|
+
timeout: phaseTimeout ?? _kSpawnDeadline,
|
|
634
|
+
onDiagnostic: onDiagnostic,
|
|
635
|
+
run: () => NodeRunnerTransport.start(
|
|
636
|
+
entry: entry,
|
|
637
|
+
resolveFrom: resolveFrom,
|
|
638
|
+
node: node,
|
|
639
|
+
environment: runnerEnvironment,
|
|
503
640
|
onDiagnostic: onDiagnostic,
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
resolveFrom: resolveFrom,
|
|
507
|
-
node: node,
|
|
508
|
-
environment: runnerEnvironment,
|
|
509
|
-
onDiagnostic: onDiagnostic,
|
|
510
|
-
));
|
|
641
|
+
),
|
|
642
|
+
);
|
|
511
643
|
final plane = NomosBridge(transport!);
|
|
512
644
|
await _phased(
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
645
|
+
phase: '3/4: runner ready',
|
|
646
|
+
awaiting: "event:'ready'",
|
|
647
|
+
timeout: phaseTimeout ?? readyTimeout,
|
|
648
|
+
onDiagnostic: onDiagnostic,
|
|
649
|
+
likelyCauses: const [
|
|
650
|
+
'node is not on PATH, or ~/.holon/runtime is cold (holon-testmint.wasm + packages.json)',
|
|
651
|
+
'the runner process failed to boot the engine — run with onDiagnostic to see its stderr',
|
|
652
|
+
],
|
|
653
|
+
run: () => plane.ready,
|
|
654
|
+
);
|
|
522
655
|
await _phased(
|
|
523
|
-
|
|
656
|
+
phase: '4/4: connect session',
|
|
657
|
+
workspace: installWorkspace,
|
|
658
|
+
awaiting: 'connect',
|
|
659
|
+
timeout: phaseTimeout ?? connectTimeout,
|
|
660
|
+
onDiagnostic: onDiagnostic,
|
|
661
|
+
likelyCauses: const [
|
|
662
|
+
'a large ledger converge-fold can be slow — raise connectTimeout/phaseTimeout',
|
|
663
|
+
'run with onDiagnostic to see where the runner blocked',
|
|
664
|
+
],
|
|
665
|
+
run: () => plane.connect(
|
|
666
|
+
cloud:
|
|
667
|
+
'https://offline.invalid', // ignored: the offline runner serves everything locally
|
|
524
668
|
workspace: installWorkspace,
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
],
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
'https://offline.invalid', // ignored: the offline runner serves everything locally
|
|
535
|
-
workspace: installWorkspace,
|
|
536
|
-
session: installWorkspace,
|
|
537
|
-
clientId: clientId,
|
|
538
|
-
allowUnborn: true,
|
|
539
|
-
// THE HARNESS DEVICE (era-0): the install lane mints one device key per plane — sessions
|
|
540
|
-
// sign with it, and the client completes the self-serve birth ceremony (enrol + cert)
|
|
541
|
-
// with the SAME key, so offline `.births()` offers Just Work.
|
|
542
|
-
authorSecret: info['deviceSecret']?.toString(),
|
|
543
|
-
));
|
|
669
|
+
session: installWorkspace,
|
|
670
|
+
clientId: clientId,
|
|
671
|
+
allowUnborn: true,
|
|
672
|
+
// THE HARNESS DEVICE (era-0): the install lane mints one device key per plane — sessions
|
|
673
|
+
// sign with it, and the client completes the self-serve birth ceremony (enrol + cert)
|
|
674
|
+
// with the SAME key, so offline `.births()` offers Just Work.
|
|
675
|
+
authorSecret: info['deviceSecret']?.toString(),
|
|
676
|
+
),
|
|
677
|
+
);
|
|
544
678
|
if (birthsApplicationHome) {
|
|
545
679
|
final devicePublic = info['devicePublic']?.toString();
|
|
546
680
|
final deviceSecret = info['deviceSecret']?.toString();
|
|
@@ -557,7 +691,8 @@ class NomosTestHarness {
|
|
|
557
691
|
framework.isEmpty ||
|
|
558
692
|
laws.isEmpty) {
|
|
559
693
|
throw NomosTestHarnessException(
|
|
560
|
-
|
|
694
|
+
'the offline application parent did not return the framework law, application law and test device needed to birth the signed-in application',
|
|
695
|
+
);
|
|
561
696
|
}
|
|
562
697
|
final keyHash = sha256.convert(base64Decode(devicePublic)).toString();
|
|
563
698
|
final outcome = await _phased(
|
|
@@ -575,7 +710,9 @@ class NomosTestHarness {
|
|
|
575
710
|
'owner': owner,
|
|
576
711
|
'ownerKeyHash': keyHash,
|
|
577
712
|
'frameworkHash': framework,
|
|
578
|
-
|
|
713
|
+
// Ordered application deployment is reference packages first, deferred child payload last.
|
|
714
|
+
// The parent serves the references; the receiving group is born with only its payload law.
|
|
715
|
+
'lawHash': laws.last,
|
|
579
716
|
'bornAt': DateTime.now().toUtc().toIso8601String(),
|
|
580
717
|
},
|
|
581
718
|
),
|
|
@@ -591,8 +728,9 @@ class NomosTestHarness {
|
|
|
591
728
|
outcome.born.single != workspace ||
|
|
592
729
|
bornStatus != 'born') {
|
|
593
730
|
throw NomosTestHarnessException(
|
|
594
|
-
|
|
595
|
-
|
|
731
|
+
'the offline application parent did not birth "$workspace" cleanly '
|
|
732
|
+
'(born=${outcome.born}, birthOutcomes=${outcome.birthOutcomes})',
|
|
733
|
+
);
|
|
596
734
|
}
|
|
597
735
|
await _phased(
|
|
598
736
|
phase: 'application: mount the signed-in application',
|
|
@@ -616,7 +754,8 @@ class NomosTestHarness {
|
|
|
616
754
|
deviceSecret == null ||
|
|
617
755
|
deviceSecret.isEmpty) {
|
|
618
756
|
throw NomosTestHarnessException(
|
|
619
|
-
|
|
757
|
+
'the offline install did not return the test device key needed to make "$owner" an application-ready signer',
|
|
758
|
+
);
|
|
620
759
|
}
|
|
621
760
|
try {
|
|
622
761
|
final keyHash = sha256.convert(base64Decode(devicePublic)).toString();
|
|
@@ -648,7 +787,8 @@ class NomosTestHarness {
|
|
|
648
787
|
// signer route. Preserve their old harness behaviour, while keeping
|
|
649
788
|
// every refusal from a current package fatal.
|
|
650
789
|
if (!error.toString().contains(
|
|
651
|
-
|
|
790
|
+
"no active installed law declares domain '__nomos_application_home_v1'",
|
|
791
|
+
)) {
|
|
652
792
|
rethrow;
|
|
653
793
|
}
|
|
654
794
|
}
|
|
@@ -732,8 +872,19 @@ class NomosTestHarness {
|
|
|
732
872
|
// application-ready.
|
|
733
873
|
}
|
|
734
874
|
}
|
|
735
|
-
|
|
736
|
-
|
|
875
|
+
final installedHashes =
|
|
876
|
+
((info['lawHashes'] as List?) ?? const [])
|
|
877
|
+
.map((hash) => hash.toString())
|
|
878
|
+
.where((hash) => hash.isNotEmpty)
|
|
879
|
+
.toList();
|
|
880
|
+
final sessionLaw =
|
|
881
|
+
installedHashes.isEmpty
|
|
882
|
+
? 'installed'
|
|
883
|
+
: (birthsApplicationHome
|
|
884
|
+
? installedHashes.last
|
|
885
|
+
: installedHashes.first)
|
|
886
|
+
.substring(0, 12);
|
|
887
|
+
phase('ready — session "$workspace" connected (law $sessionLaw)');
|
|
737
888
|
// THE DECOY SESSION (multi-workspace by default in tests): sole-session inference is how
|
|
738
889
|
// single-workspace assumptions ship — with one session connected, an op that forgot its session
|
|
739
890
|
// silently works in tests and only refuses in production when the app connects its second
|
|
@@ -743,58 +894,66 @@ class NomosTestHarness {
|
|
|
743
894
|
// single-session trips. Opt out with `decoySession: false` (please don't).
|
|
744
895
|
if (decoySession && !birthsApplicationHome) {
|
|
745
896
|
await _phased(
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
cloud: 'https://offline.invalid',
|
|
753
|
-
workspace: _decoyWorkspace,
|
|
754
|
-
session: _decoyWorkspace,
|
|
755
|
-
clientId: '$clientId-decoy',
|
|
756
|
-
allowUnborn: true,
|
|
757
|
-
restoreFrom: base64Encode(await File(snapshot).readAsBytes()),
|
|
758
|
-
));
|
|
759
|
-
}
|
|
760
|
-
final harness = NomosTestHarness._(plane.session(workspace), workspace,
|
|
761
|
-
transport, workdir, onDiagnostic, [workspace],
|
|
762
|
-
devicePublics: {
|
|
763
|
-
if (info['devicePublic'] != null)
|
|
764
|
-
workspace: info['devicePublic'].toString(),
|
|
765
|
-
},
|
|
766
|
-
deviceSecrets: {
|
|
767
|
-
if (info['deviceSecret'] != null)
|
|
768
|
-
workspace: info['deviceSecret'].toString(),
|
|
769
|
-
},
|
|
770
|
-
frameworkHash: info['frameworkHash']?.toString(),
|
|
771
|
-
lawHashes: {
|
|
772
|
-
workspace: ((info['lawHashes'] as List?) ?? const [])
|
|
773
|
-
.map((e) => e.toString())
|
|
774
|
-
.toList()
|
|
775
|
-
},
|
|
776
|
-
snapshots: {
|
|
777
|
-
workspace: base64Encode(await File(snapshot).readAsBytes())
|
|
778
|
-
},
|
|
779
|
-
installLane: _InstallLane(
|
|
780
|
-
node: node,
|
|
781
|
-
useInRepoScript: useInRepoScript,
|
|
782
|
-
inRepoScript: resolvedHelper,
|
|
783
|
-
githolonCmd: githolonCmd,
|
|
784
|
-
runtimeDir: effectiveRuntimeDir,
|
|
785
|
-
clientId: clientId),
|
|
786
|
-
phaseTimeout: phaseTimeout,
|
|
787
|
-
cloudRestartLane: _CloudRestartLane(
|
|
788
|
-
entry: entry,
|
|
789
|
-
node: node,
|
|
897
|
+
phase: 'decoy: connect the decoy session',
|
|
898
|
+
workspace: _decoyWorkspace,
|
|
899
|
+
awaiting: 'connect',
|
|
900
|
+
timeout: phaseTimeout ?? connectTimeout,
|
|
901
|
+
onDiagnostic: onDiagnostic,
|
|
902
|
+
run: () async => plane.connect(
|
|
790
903
|
cloud: 'https://offline.invalid',
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
clientId: clientId,
|
|
794
|
-
|
|
795
|
-
|
|
904
|
+
workspace: _decoyWorkspace,
|
|
905
|
+
session: _decoyWorkspace,
|
|
906
|
+
clientId: '$clientId-decoy',
|
|
907
|
+
allowUnborn: true,
|
|
908
|
+
restoreFrom: base64Encode(await File(snapshot).readAsBytes()),
|
|
796
909
|
),
|
|
797
|
-
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
final harness = NomosTestHarness._(
|
|
913
|
+
plane.session(workspace),
|
|
914
|
+
workspace,
|
|
915
|
+
transport,
|
|
916
|
+
workdir,
|
|
917
|
+
onDiagnostic,
|
|
918
|
+
[workspace],
|
|
919
|
+
devicePublics: {
|
|
920
|
+
if (info['devicePublic'] != null)
|
|
921
|
+
workspace: info['devicePublic'].toString(),
|
|
922
|
+
},
|
|
923
|
+
deviceSecrets: {
|
|
924
|
+
if (info['deviceSecret'] != null)
|
|
925
|
+
workspace: info['deviceSecret'].toString(),
|
|
926
|
+
},
|
|
927
|
+
frameworkHash: info['frameworkHash']?.toString(),
|
|
928
|
+
lawHashes: {
|
|
929
|
+
workspace: ((info['lawHashes'] as List?) ?? const [])
|
|
930
|
+
.map((e) => e.toString())
|
|
931
|
+
.toList(),
|
|
932
|
+
},
|
|
933
|
+
snapshots: {
|
|
934
|
+
workspace: base64Encode(await File(snapshot).readAsBytes()),
|
|
935
|
+
},
|
|
936
|
+
installLane: _InstallLane(
|
|
937
|
+
node: node,
|
|
938
|
+
useInRepoScript: useInRepoScript,
|
|
939
|
+
inRepoScript: resolvedHelper,
|
|
940
|
+
githolonCmd: githolonCmd,
|
|
941
|
+
runtimeDir: effectiveRuntimeDir,
|
|
942
|
+
clientId: clientId,
|
|
943
|
+
),
|
|
944
|
+
phaseTimeout: phaseTimeout,
|
|
945
|
+
cloudRestartLane: _CloudRestartLane(
|
|
946
|
+
entry: entry,
|
|
947
|
+
node: node,
|
|
948
|
+
cloud: 'https://offline.invalid',
|
|
949
|
+
authToken: '',
|
|
950
|
+
authorSecret: info['deviceSecret']?.toString() ?? '',
|
|
951
|
+
clientId: clientId,
|
|
952
|
+
resolveFrom: resolveFrom,
|
|
953
|
+
environment: runnerEnvironment,
|
|
954
|
+
),
|
|
955
|
+
homeWorkspace: workspace,
|
|
956
|
+
);
|
|
798
957
|
if (autoTearDown && registerTearDown != null) {
|
|
799
958
|
registerTearDown(harness.dispose);
|
|
800
959
|
}
|
|
@@ -844,19 +1003,23 @@ class NomosTestHarness {
|
|
|
844
1003
|
}) async {
|
|
845
1004
|
if (workspaces.isEmpty) {
|
|
846
1005
|
throw NomosTestHarnessException(
|
|
847
|
-
|
|
1006
|
+
'no workspaces — pass { workspaceName: [build/<name>.deploy.json] } for each colocated workspace',
|
|
1007
|
+
);
|
|
848
1008
|
}
|
|
849
1009
|
for (final e in workspaces.entries) {
|
|
850
1010
|
if (e.value.isEmpty) {
|
|
851
1011
|
throw NomosTestHarnessException(
|
|
852
|
-
|
|
1012
|
+
'workspace "${e.key}" has no deployJsonPaths — pass its build/<name>.deploy.json',
|
|
1013
|
+
);
|
|
853
1014
|
}
|
|
854
1015
|
}
|
|
855
1016
|
final entry = nodeEntry ??
|
|
856
1017
|
await _resolveEntry(node, resolveFrom ?? Directory.current.path);
|
|
857
1018
|
final effectiveRuntimeDir = runtimeDir ??
|
|
1019
|
+
Platform.environment['NOMOS_CANDIDATE_RUNTIME'] ??
|
|
858
1020
|
Platform.environment['NOMOS_DART_RUNTIME'] ??
|
|
859
|
-
Platform.environment['NOMOS_OFFLINE_RUNTIME']
|
|
1021
|
+
Platform.environment['NOMOS_OFFLINE_RUNTIME'] ??
|
|
1022
|
+
await _sourceCandidateRuntime(entry);
|
|
860
1023
|
final adjacentInstaller = '${File(entry).parent.path}/offline-install.mjs';
|
|
861
1024
|
final resolvedHelper = installHelper ??
|
|
862
1025
|
(File(adjacentInstaller).existsSync()
|
|
@@ -882,27 +1045,29 @@ class NomosTestHarness {
|
|
|
882
1045
|
for (final e in workspaces.entries) {
|
|
883
1046
|
final snap = '${workdir.path}/${e.key}.bin';
|
|
884
1047
|
final info = await _phased(
|
|
885
|
-
|
|
1048
|
+
phase: '1: offline law install ${++_i}/${workspaces.length}',
|
|
1049
|
+
workspace: e.key,
|
|
1050
|
+
awaiting: useInRepoScript
|
|
1051
|
+
? 'offline-install.ts'
|
|
1052
|
+
: 'githolon harness-install',
|
|
1053
|
+
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
1054
|
+
onDiagnostic: onDiagnostic,
|
|
1055
|
+
likelyCauses: const [
|
|
1056
|
+
'a cold npx download of githolon (pre-warm with any `githolon` run)',
|
|
1057
|
+
'a very large law package folding through the offline gate — raise phaseTimeout',
|
|
1058
|
+
],
|
|
1059
|
+
run: () => _runInstall(
|
|
1060
|
+
node: node,
|
|
1061
|
+
useInRepoScript: useInRepoScript,
|
|
1062
|
+
inRepoScript: resolvedHelper,
|
|
1063
|
+
githolonCmd: githolonCmd,
|
|
1064
|
+
deployJsonPaths: e.value,
|
|
886
1065
|
workspace: e.key,
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
: 'githolon harness-install',
|
|
890
|
-
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
1066
|
+
snapshot: snap,
|
|
1067
|
+
runtimeDir: effectiveRuntimeDir,
|
|
891
1068
|
onDiagnostic: onDiagnostic,
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
'a very large law package folding through the offline gate — raise phaseTimeout',
|
|
895
|
-
],
|
|
896
|
-
run: () => _runInstall(
|
|
897
|
-
node: node,
|
|
898
|
-
useInRepoScript: useInRepoScript,
|
|
899
|
-
inRepoScript: resolvedHelper,
|
|
900
|
-
githolonCmd: githolonCmd,
|
|
901
|
-
deployJsonPaths: e.value,
|
|
902
|
-
workspace: e.key,
|
|
903
|
-
snapshot: snap,
|
|
904
|
-
runtimeDir: effectiveRuntimeDir,
|
|
905
|
-
onDiagnostic: onDiagnostic));
|
|
1069
|
+
),
|
|
1070
|
+
);
|
|
906
1071
|
snapshots[e.key] = snap;
|
|
907
1072
|
lawHashesByWs[e.key] = ((info['lawHashes'] as List?) ?? const [])
|
|
908
1073
|
.map((x) => x.toString())
|
|
@@ -923,64 +1088,68 @@ class NomosTestHarness {
|
|
|
923
1088
|
if (onDiagnostic != null) onDiagnostic('[nomos-test-harness] $m');
|
|
924
1089
|
};
|
|
925
1090
|
transport = await _phased(
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1091
|
+
phase: '2: spawn the node runner (${workspaces.length} workspace(s))',
|
|
1092
|
+
awaiting: 'Process.start($node)',
|
|
1093
|
+
timeout: phaseTimeout ?? _kSpawnDeadline,
|
|
1094
|
+
onDiagnostic: onDiagnostic,
|
|
1095
|
+
run: () => NodeRunnerTransport.start(
|
|
1096
|
+
entry: entry,
|
|
1097
|
+
resolveFrom: resolveFrom,
|
|
1098
|
+
node: node,
|
|
1099
|
+
environment: {
|
|
1100
|
+
'NOMOS_OFFLINE': '1',
|
|
1101
|
+
'NOMOS_OFFLINE_WASM': 'holon-testmint.wasm',
|
|
1102
|
+
if (planeTestRootPublic != null)
|
|
1103
|
+
'NOMOS_TEST_K_ROOT_PUBLIC': planeTestRootPublic,
|
|
1104
|
+
if (planeTestRootSecret != null)
|
|
1105
|
+
'NOMOS_TEST_ROOT_SECRET': planeTestRootSecret,
|
|
1106
|
+
if (effectiveRuntimeDir != null)
|
|
1107
|
+
'NOMOS_OFFLINE_RUNTIME': effectiveRuntimeDir,
|
|
1108
|
+
},
|
|
929
1109
|
onDiagnostic: onDiagnostic,
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
resolveFrom: resolveFrom,
|
|
933
|
-
node: node,
|
|
934
|
-
environment: {
|
|
935
|
-
'NOMOS_OFFLINE': '1',
|
|
936
|
-
'NOMOS_OFFLINE_WASM': 'holon-testmint.wasm',
|
|
937
|
-
if (planeTestRootPublic != null)
|
|
938
|
-
'NOMOS_TEST_K_ROOT_PUBLIC': planeTestRootPublic,
|
|
939
|
-
if (planeTestRootSecret != null)
|
|
940
|
-
'NOMOS_TEST_ROOT_SECRET': planeTestRootSecret,
|
|
941
|
-
if (effectiveRuntimeDir != null)
|
|
942
|
-
'NOMOS_OFFLINE_RUNTIME': effectiveRuntimeDir,
|
|
943
|
-
},
|
|
944
|
-
onDiagnostic: onDiagnostic,
|
|
945
|
-
));
|
|
1110
|
+
),
|
|
1111
|
+
);
|
|
946
1112
|
final bridge = NomosBridge(transport!);
|
|
947
1113
|
await _phased(
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1114
|
+
phase: '2: runner ready',
|
|
1115
|
+
awaiting: "event:'ready'",
|
|
1116
|
+
timeout: phaseTimeout ?? readyTimeout,
|
|
1117
|
+
onDiagnostic: onDiagnostic,
|
|
1118
|
+
likelyCauses: const [
|
|
1119
|
+
'node is not on PATH, or ~/.holon/runtime is cold (holon-testmint.wasm + packages.json)',
|
|
1120
|
+
'the runner process failed to boot the engine — run with onDiagnostic to see its stderr',
|
|
1121
|
+
],
|
|
1122
|
+
run: () => bridge.ready,
|
|
1123
|
+
);
|
|
957
1124
|
|
|
958
1125
|
// 3. connect one SESSION per workspace, each restoring its OWN snapshot — LOCAL-ONLY (no cloud;
|
|
959
1126
|
// these are local githolons on the device plane, offline by nature).
|
|
960
1127
|
final snapshotB64 = <String, String>{};
|
|
961
1128
|
var _n = 0;
|
|
962
1129
|
for (final e in workspaces.entries) {
|
|
963
|
-
snapshotB64[e.key] =
|
|
964
|
-
|
|
1130
|
+
snapshotB64[e.key] = base64Encode(
|
|
1131
|
+
await File(snapshots[e.key]!).readAsBytes(),
|
|
1132
|
+
);
|
|
965
1133
|
await _phased(
|
|
966
|
-
|
|
1134
|
+
phase: '3: connect session ${++_n}/${workspaces.length}',
|
|
1135
|
+
workspace: e.key,
|
|
1136
|
+
awaiting: 'connect',
|
|
1137
|
+
timeout: phaseTimeout ?? connectTimeout,
|
|
1138
|
+
onDiagnostic: onDiagnostic,
|
|
1139
|
+
likelyCauses: const [
|
|
1140
|
+
'a large ledger converge-fold can be slow — raise connectTimeout/phaseTimeout',
|
|
1141
|
+
'run with onDiagnostic to see where the runner blocked',
|
|
1142
|
+
],
|
|
1143
|
+
run: () => bridge.connect(
|
|
967
1144
|
workspace: e.key,
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
workspace: e.key,
|
|
977
|
-
session: e.key,
|
|
978
|
-
clientId: '$clientId-${e.key}',
|
|
979
|
-
restoreFrom: snapshotB64[e.key],
|
|
980
|
-
allowUnborn: true,
|
|
981
|
-
// signed with the install-lane harness device (era-0 self-serve birth ceremony key)
|
|
982
|
-
authorSecret: installDeviceSecrets[e.key],
|
|
983
|
-
));
|
|
1145
|
+
session: e.key,
|
|
1146
|
+
clientId: '$clientId-${e.key}',
|
|
1147
|
+
restoreFrom: snapshotB64[e.key],
|
|
1148
|
+
allowUnborn: true,
|
|
1149
|
+
// signed with the install-lane harness device (era-0 self-serve birth ceremony key)
|
|
1150
|
+
authorSecret: installDeviceSecrets[e.key],
|
|
1151
|
+
),
|
|
1152
|
+
);
|
|
984
1153
|
}
|
|
985
1154
|
phase('cluster: ${workspaces.length} session(s) connected');
|
|
986
1155
|
|
|
@@ -993,60 +1162,71 @@ class NomosTestHarness {
|
|
|
993
1162
|
for (final ws in deviceKeyed) {
|
|
994
1163
|
if (!workspaces.containsKey(ws)) {
|
|
995
1164
|
throw NomosTestHarnessException(
|
|
996
|
-
|
|
997
|
-
|
|
1165
|
+
'deviceKeyed names "$ws" but it is not a workspace of this cluster — '
|
|
1166
|
+
'workspaces: ${workspaces.keys.toList()}',
|
|
1167
|
+
);
|
|
998
1168
|
}
|
|
999
1169
|
final kp = await _phased(
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1170
|
+
phase: '3b: device keygen',
|
|
1171
|
+
workspace: ws,
|
|
1172
|
+
awaiting: 'authorKeygen',
|
|
1173
|
+
timeout: phaseTimeout ?? _kOtherDeadline,
|
|
1174
|
+
onDiagnostic: onDiagnostic,
|
|
1175
|
+
run: () => bridge.session(ws).authorKeygen(),
|
|
1176
|
+
);
|
|
1006
1177
|
devicePublics[ws] = (kp['public'] ?? '').toString();
|
|
1007
1178
|
deviceSecretsKeyed[ws] = (kp['secret'] ?? '').toString();
|
|
1008
1179
|
await _phased(
|
|
1009
|
-
|
|
1180
|
+
phase: "3b: keyed reconnect of '$ws'",
|
|
1181
|
+
workspace: ws,
|
|
1182
|
+
awaiting: 'connect',
|
|
1183
|
+
timeout: phaseTimeout ?? connectTimeout,
|
|
1184
|
+
onDiagnostic: onDiagnostic,
|
|
1185
|
+
likelyCauses: const [
|
|
1186
|
+
'the runner never answered the re-connect of an already-open session — '
|
|
1187
|
+
'update @githolon/client (the session re-open is an atomic replace)',
|
|
1188
|
+
'a large ledger re-fold on the re-mount — raise connectTimeout/phaseTimeout',
|
|
1189
|
+
],
|
|
1190
|
+
run: () => bridge.connect(
|
|
1010
1191
|
workspace: ws,
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
],
|
|
1019
|
-
run: () => bridge.connect(
|
|
1020
|
-
workspace: ws,
|
|
1021
|
-
session: ws,
|
|
1022
|
-
clientId: '$clientId-$ws',
|
|
1023
|
-
restoreFrom: snapshotB64[ws],
|
|
1024
|
-
authorSecret: (kp['secret'] ?? '').toString(),
|
|
1025
|
-
allowUnborn: true,
|
|
1026
|
-
));
|
|
1192
|
+
session: ws,
|
|
1193
|
+
clientId: '$clientId-$ws',
|
|
1194
|
+
restoreFrom: snapshotB64[ws],
|
|
1195
|
+
authorSecret: (kp['secret'] ?? '').toString(),
|
|
1196
|
+
allowUnborn: true,
|
|
1197
|
+
),
|
|
1198
|
+
);
|
|
1027
1199
|
}
|
|
1028
1200
|
if (deviceKeyed.isNotEmpty) {
|
|
1029
1201
|
phase(
|
|
1030
|
-
|
|
1202
|
+
'cluster: ${deviceKeyed.length} device-keyed session(s) re-opened',
|
|
1203
|
+
);
|
|
1031
1204
|
}
|
|
1032
1205
|
|
|
1033
1206
|
final names = workspaces.keys.toList();
|
|
1034
1207
|
final harness = NomosTestHarness._(
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1208
|
+
bridge,
|
|
1209
|
+
names.first,
|
|
1210
|
+
transport,
|
|
1211
|
+
workdir,
|
|
1212
|
+
onDiagnostic,
|
|
1213
|
+
names,
|
|
1214
|
+
// install-lane harness devices under; explicit deviceKeyed re-opens override for their ws
|
|
1215
|
+
devicePublics: {...installDevicePublics, ...devicePublics},
|
|
1216
|
+
deviceSecrets: {...installDeviceSecrets, ...deviceSecretsKeyed},
|
|
1217
|
+
frameworkHash: frameworkHash,
|
|
1218
|
+
lawHashes: lawHashesByWs,
|
|
1219
|
+
snapshots: snapshotB64,
|
|
1220
|
+
installLane: _InstallLane(
|
|
1221
|
+
node: node,
|
|
1222
|
+
useInRepoScript: useInRepoScript,
|
|
1223
|
+
inRepoScript: resolvedHelper,
|
|
1224
|
+
githolonCmd: githolonCmd,
|
|
1225
|
+
runtimeDir: effectiveRuntimeDir,
|
|
1226
|
+
clientId: clientId,
|
|
1227
|
+
),
|
|
1228
|
+
phaseTimeout: phaseTimeout,
|
|
1229
|
+
);
|
|
1050
1230
|
if (autoTearDown && registerTearDown != null) {
|
|
1051
1231
|
registerTearDown(harness.dispose);
|
|
1052
1232
|
}
|
|
@@ -1104,15 +1284,19 @@ class NomosTestHarness {
|
|
|
1104
1284
|
}) async {
|
|
1105
1285
|
if (deployJsonPaths.isEmpty) {
|
|
1106
1286
|
throw NomosTestHarnessException(
|
|
1107
|
-
|
|
1287
|
+
'no deployJsonPaths — pass the build/<name>.deploy.json `npx githolon compile` emits',
|
|
1288
|
+
);
|
|
1108
1289
|
}
|
|
1109
1290
|
final entry = nodeEntry ??
|
|
1110
1291
|
await _resolveEntry(node, resolveFrom ?? Directory.current.path);
|
|
1111
1292
|
final bareUid =
|
|
1112
1293
|
subject.startsWith('user:') ? subject.substring(5) : subject;
|
|
1113
1294
|
final http = HttpClient();
|
|
1114
|
-
Future<Map<String, Object?>> post(
|
|
1115
|
-
|
|
1295
|
+
Future<Map<String, Object?>> post(
|
|
1296
|
+
String path,
|
|
1297
|
+
Object? body, {
|
|
1298
|
+
Map<String, String> headers = const {},
|
|
1299
|
+
}) async {
|
|
1116
1300
|
final req = await http.postUrl(Uri.parse('${cloud.origin}$path'));
|
|
1117
1301
|
req.headers.set('content-type', 'application/json');
|
|
1118
1302
|
req.headers.set('x-nomos-principal', bareUid);
|
|
@@ -1125,7 +1309,23 @@ class NomosTestHarness {
|
|
|
1125
1309
|
final decoded = (jsonDecode(text) as Map).cast<String, Object?>();
|
|
1126
1310
|
if (resp.statusCode >= 300 || decoded['ok'] == false) {
|
|
1127
1311
|
throw NomosTestHarnessException(
|
|
1128
|
-
|
|
1312
|
+
'cloudBound setup: POST $path → ${resp.statusCode}: ${text.substring(0, text.length.clamp(0, 400))}',
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
return decoded;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
Future<Map<String, Object?>> get(String path) async {
|
|
1319
|
+
final req = await http.getUrl(Uri.parse('${cloud.origin}$path'));
|
|
1320
|
+
req.headers.set('x-nomos-principal', bareUid);
|
|
1321
|
+
if (authToken != null) req.headers.set('x-nomos-auth', authToken);
|
|
1322
|
+
final resp = await req.close();
|
|
1323
|
+
final text = await resp.transform(utf8.decoder).join();
|
|
1324
|
+
final decoded = (jsonDecode(text) as Map).cast<String, Object?>();
|
|
1325
|
+
if (resp.statusCode >= 300 || decoded['ok'] == false) {
|
|
1326
|
+
throw NomosTestHarnessException(
|
|
1327
|
+
'cloudBound setup: GET $path → ${resp.statusCode}: ${text.substring(0, text.length.clamp(0, 400))}',
|
|
1328
|
+
);
|
|
1129
1329
|
}
|
|
1130
1330
|
return decoded;
|
|
1131
1331
|
}
|
|
@@ -1143,86 +1343,141 @@ class NomosTestHarness {
|
|
|
1143
1343
|
<({String domain, String directive, String path})>[];
|
|
1144
1344
|
final lawHashes = <String>[];
|
|
1145
1345
|
await _phased(
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
rethrow;
|
|
1346
|
+
phase: '1/6: boot the born platform + deploy law on the test cloud',
|
|
1347
|
+
workspace: platformWorkspace,
|
|
1348
|
+
awaiting: 'POST /v2/workspaces + /domains',
|
|
1349
|
+
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
1350
|
+
onDiagnostic: onDiagnostic,
|
|
1351
|
+
run: () async {
|
|
1352
|
+
Map<String, Object?> created;
|
|
1353
|
+
try {
|
|
1354
|
+
created = await post('/v2/workspaces/$platformWorkspace', null);
|
|
1355
|
+
} on NomosTestHarnessException catch (e) {
|
|
1356
|
+
if (e.message.contains('unborn')) {
|
|
1357
|
+
throw NomosTestHarnessException(
|
|
1358
|
+
"cloudBound: platform '$platformWorkspace' is UNBORN on the test cloud — era-0 has no "
|
|
1359
|
+
"host-side create. Start the cloud with it: "
|
|
1360
|
+
"NomosTestCloud.start(serverEntry: …, platform: '$platformWorkspace') "
|
|
1361
|
+
'(the dev server births it at boot via its operator lane).',
|
|
1362
|
+
);
|
|
1164
1363
|
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1364
|
+
rethrow;
|
|
1365
|
+
}
|
|
1366
|
+
frameworkHash =
|
|
1367
|
+
((created['controller'] as Map?)?['domainHash'])?.toString();
|
|
1368
|
+
final existing = await get(
|
|
1369
|
+
'/v2/workspaces/$platformWorkspace/domains',
|
|
1370
|
+
);
|
|
1371
|
+
final heldHashes = <String>{
|
|
1372
|
+
...((existing['domains'] as List?) ?? const [])
|
|
1373
|
+
.map((row) => (row as Map?)?['domainHash']?.toString())
|
|
1374
|
+
.whereType<String>()
|
|
1375
|
+
.where((hash) => hash.isNotEmpty),
|
|
1376
|
+
...(((existing['currentLaw'] as Map?) ?? const {})
|
|
1377
|
+
.values
|
|
1378
|
+
.map((hash) => hash?.toString())
|
|
1379
|
+
.whereType<String>()
|
|
1380
|
+
.where((hash) => hash.isNotEmpty)),
|
|
1381
|
+
};
|
|
1382
|
+
for (final p in deployJsonPaths) {
|
|
1383
|
+
final body = await File(p).readAsString();
|
|
1384
|
+
final deploy = (jsonDecode(body) as Map);
|
|
1385
|
+
final summary = (deploy['summary'] as Map?);
|
|
1386
|
+
final h = summary?['domainHash']?.toString();
|
|
1387
|
+
// A composed test application is normally installed atomically by the bootstrap operator.
|
|
1388
|
+
// The signed-in scenario participant must not need installDomain authority merely because the
|
|
1389
|
+
// harness was also handed those artifacts for routes, hashes and child-law selection.
|
|
1390
|
+
if (h == null || h.isEmpty || !heldHashes.contains(h)) {
|
|
1169
1391
|
await post('/v2/workspaces/$platformWorkspace/domains', body);
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
for (final
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
));
|
|
1193
|
-
}
|
|
1392
|
+
if (h != null && h.isNotEmpty) heldHashes.add(h);
|
|
1393
|
+
}
|
|
1394
|
+
if (h != null && h.isNotEmpty) lawHashes.add(h);
|
|
1395
|
+
final openapi = deploy['openapi'] as Map?;
|
|
1396
|
+
final pathSets = <Map>[
|
|
1397
|
+
(openapi?['paths'] as Map?) ?? const {},
|
|
1398
|
+
(((openapi?['x-nomos-framework-routes'] as Map?)?['paths'])
|
|
1399
|
+
as Map?) ??
|
|
1400
|
+
const {},
|
|
1401
|
+
];
|
|
1402
|
+
for (final paths in pathSets) {
|
|
1403
|
+
for (final entry in paths.entries) {
|
|
1404
|
+
final post = (entry.value as Map?)?['post'] as Map?;
|
|
1405
|
+
final route = post?['x-nomos'] as Map?;
|
|
1406
|
+
final domain = route?['domain']?.toString() ?? '';
|
|
1407
|
+
final directive = route?['directiveId']?.toString() ?? '';
|
|
1408
|
+
if (domain.isNotEmpty && directive.isNotEmpty) {
|
|
1409
|
+
frameworkPostRoutes.add((
|
|
1410
|
+
domain: domain,
|
|
1411
|
+
directive: directive,
|
|
1412
|
+
path: entry.key.toString(),
|
|
1413
|
+
));
|
|
1194
1414
|
}
|
|
1195
1415
|
}
|
|
1196
1416
|
}
|
|
1197
|
-
}
|
|
1417
|
+
}
|
|
1418
|
+
},
|
|
1419
|
+
);
|
|
1198
1420
|
if (frameworkHash == null || frameworkHash!.isEmpty) {
|
|
1199
1421
|
throw NomosTestHarnessException(
|
|
1200
|
-
|
|
1422
|
+
'cloudBound: the test cloud returned no controller.domainHash — cannot run the birth ceremony (frameworkHash unknown)',
|
|
1423
|
+
);
|
|
1201
1424
|
}
|
|
1202
1425
|
if (lawHashes.isEmpty) {
|
|
1203
1426
|
throw NomosTestHarnessException(
|
|
1204
|
-
|
|
1427
|
+
'cloudBound: no deploy.json carried a summary.domainHash — cannot run the birth ceremony (lawHash unknown)',
|
|
1428
|
+
);
|
|
1205
1429
|
}
|
|
1206
1430
|
final birthRoutes = frameworkPostRoutes
|
|
1207
|
-
.where(
|
|
1208
|
-
|
|
1431
|
+
.where(
|
|
1432
|
+
(route) =>
|
|
1433
|
+
route.directive == 'nomosInternalBirthReceivingGroup7c1f5e',
|
|
1434
|
+
)
|
|
1209
1435
|
.toSet()
|
|
1210
1436
|
.toList();
|
|
1211
1437
|
if (birthRoutes.length != 1) {
|
|
1212
1438
|
throw NomosTestHarnessException(
|
|
1213
|
-
|
|
1439
|
+
'cloudBound: the deployed package must expose exactly one framework receiving-group route; found ${birthRoutes.length}',
|
|
1440
|
+
);
|
|
1214
1441
|
}
|
|
1215
1442
|
final applicationHomeDomain = birthRoutes.single.domain;
|
|
1216
1443
|
final birthReceivingGroupPath = birthRoutes.single.path;
|
|
1444
|
+
// A composed application has two distinct laws at this seam:
|
|
1445
|
+
// * the parent's CURRENT control law authors enrollFirstDevice + the birth offer;
|
|
1446
|
+
// * the application's final PAYLOAD package is the law installed into the born receiving group.
|
|
1447
|
+
// Treating those as one law clones platform/control state into the user's home. The ordered package
|
|
1448
|
+
// set is the executable form of the application's reference-before-payload arcs: the final artifact is
|
|
1449
|
+
// therefore the explicit child law, even though it is deliberately only staged on the parent.
|
|
1450
|
+
final parentDomains = await get(
|
|
1451
|
+
'/v2/workspaces/$platformWorkspace/domains',
|
|
1452
|
+
);
|
|
1453
|
+
final currentLaw =
|
|
1454
|
+
(parentDomains['currentLaw'] as Map?)?.cast<String, Object?>() ??
|
|
1455
|
+
const <String, Object?>{};
|
|
1456
|
+
final controlHomeLawHash =
|
|
1457
|
+
currentLaw[applicationHomeDomain]?.toString() ?? '';
|
|
1458
|
+
if (controlHomeLawHash.isEmpty) {
|
|
1459
|
+
throw NomosTestHarnessException(
|
|
1460
|
+
"cloudBound: application/home control domain '$applicationHomeDomain' has no current law on '$platformWorkspace'",
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
if (!lawHashes.contains(controlHomeLawHash)) {
|
|
1464
|
+
throw NomosTestHarnessException(
|
|
1465
|
+
"cloudBound: current application/home control law '$controlHomeLawHash' was not among the packages this scenario deployed",
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
final applicationHomeLawHash = lawHashes.last;
|
|
1217
1469
|
final enrollmentRoutes = frameworkPostRoutes
|
|
1218
|
-
.where(
|
|
1219
|
-
|
|
1220
|
-
|
|
1470
|
+
.where(
|
|
1471
|
+
(route) =>
|
|
1472
|
+
route.directive == 'enrollFirstDevice' &&
|
|
1473
|
+
route.domain == applicationHomeDomain,
|
|
1474
|
+
)
|
|
1221
1475
|
.toSet()
|
|
1222
1476
|
.toList();
|
|
1223
1477
|
if (enrollmentRoutes.length != 1) {
|
|
1224
1478
|
throw NomosTestHarnessException(
|
|
1225
|
-
|
|
1479
|
+
"cloudBound: application/home domain '$applicationHomeDomain' must expose exactly one semantic enrollFirstDevice route; found ${enrollmentRoutes.length}",
|
|
1480
|
+
);
|
|
1226
1481
|
}
|
|
1227
1482
|
final enrollFirstDevicePath = enrollmentRoutes.single.path;
|
|
1228
1483
|
// 2/6 — the LOCAL engine plane, CLOUD-BOUND, spawned BEFORE the birth: the runner's local kernel is
|
|
@@ -1244,15 +1499,19 @@ class NomosTestHarness {
|
|
|
1244
1499
|
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
1245
1500
|
onDiagnostic: onDiagnostic,
|
|
1246
1501
|
run: () async {
|
|
1247
|
-
final request = await http
|
|
1248
|
-
|
|
1502
|
+
final request = await http.getUrl(
|
|
1503
|
+
Uri.parse('${cloud.origin}/v1/runtime/holon.wasm'),
|
|
1504
|
+
);
|
|
1249
1505
|
final response = await request.close();
|
|
1250
|
-
final bytes = await response
|
|
1251
|
-
|
|
1506
|
+
final bytes = await response.fold<List<int>>(
|
|
1507
|
+
<int>[],
|
|
1508
|
+
(all, chunk) => all..addAll(chunk),
|
|
1509
|
+
);
|
|
1252
1510
|
if (response.statusCode != HttpStatus.ok || bytes.length < 1024) {
|
|
1253
1511
|
throw NomosTestHarnessException(
|
|
1254
|
-
|
|
1255
|
-
|
|
1512
|
+
'cloudBound: could not pin the client kernel before going offline '
|
|
1513
|
+
'(status ${response.statusCode}, ${bytes.length} bytes)',
|
|
1514
|
+
);
|
|
1256
1515
|
}
|
|
1257
1516
|
await File(localWasm).writeAsBytes(bytes, flush: true);
|
|
1258
1517
|
},
|
|
@@ -1265,82 +1524,90 @@ class NomosTestHarness {
|
|
|
1265
1524
|
'NOMOS_LOCAL_WASM': localWasm,
|
|
1266
1525
|
};
|
|
1267
1526
|
transport = await _phased(
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1527
|
+
phase: '2/6: spawn the node runner (cloud-bound)',
|
|
1528
|
+
awaiting: 'Process.start($node)',
|
|
1529
|
+
timeout: phaseTimeout ?? _kSpawnDeadline,
|
|
1530
|
+
onDiagnostic: onDiagnostic,
|
|
1531
|
+
run: () => NodeRunnerTransport.start(
|
|
1532
|
+
entry: entry,
|
|
1533
|
+
resolveFrom: resolveFrom,
|
|
1534
|
+
node: node,
|
|
1535
|
+
environment: runnerEnvironment,
|
|
1271
1536
|
onDiagnostic: onDiagnostic,
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
resolveFrom: resolveFrom,
|
|
1275
|
-
node: node,
|
|
1276
|
-
environment: runnerEnvironment,
|
|
1277
|
-
onDiagnostic: onDiagnostic));
|
|
1537
|
+
),
|
|
1538
|
+
);
|
|
1278
1539
|
final plane = NomosBridge(transport!);
|
|
1279
1540
|
await _phased(
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1541
|
+
phase: '3/6: runner ready',
|
|
1542
|
+
awaiting: "event:'ready'",
|
|
1543
|
+
timeout: phaseTimeout ?? readyTimeout,
|
|
1544
|
+
onDiagnostic: onDiagnostic,
|
|
1545
|
+
run: () => plane.ready,
|
|
1546
|
+
);
|
|
1285
1547
|
// 4/6 — THE ERA-0 DEVICE CEREMONY (the WARRANTED onboarding lane, exactly production's shape): mint the
|
|
1286
1548
|
// owner's device key in the LOCAL kernel, get an IdP token from the dev cloud's test IdP
|
|
1287
1549
|
// (device-bound: nonce == the device keyHash), and enrol the FIRST DEVICE against the platform's
|
|
1288
1550
|
// on-chain AuthProvider (enrollFirstDevice — no admin in the loop). The platform is WARRANTED, so
|
|
1289
1551
|
// plain unsigned enrolment refuses by design; this is the one first-device door.
|
|
1290
1552
|
final device = await _phased(
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1553
|
+
phase:
|
|
1554
|
+
'4/6: device ceremony (keygen → dev-IdP token → enrollFirstDevice)',
|
|
1555
|
+
workspace: platformWorkspace,
|
|
1556
|
+
awaiting: 'authorKeygen + /dev/mint-token + enrollFirstDevice',
|
|
1557
|
+
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
1558
|
+
onDiagnostic: onDiagnostic,
|
|
1559
|
+
run: () async {
|
|
1560
|
+
// The runner's crypto ops (authorKeygen/signBirthCert) run on a CONNECTED holon — open a
|
|
1561
|
+
// SCRATCH session on an unborn name (allowUnborn ⇒ an empty local holon; the pull-miss is
|
|
1562
|
+
// tolerated): a PRIVATE platform (declared read caps — co2's shape) refuses an
|
|
1563
|
+
// unauthenticated ledger clone, and the ceremony never needs the platform's ledger anyway
|
|
1564
|
+
// (its writes go over the HTTP gateway below).
|
|
1565
|
+
await plane.connect(
|
|
1566
|
+
cloud: cloud.origin,
|
|
1567
|
+
workspace: '$clientId-onboard-scratch',
|
|
1568
|
+
session: '$clientId-onboard-scratch',
|
|
1569
|
+
clientId: '$clientId-onboard',
|
|
1570
|
+
allowUnborn: true,
|
|
1571
|
+
);
|
|
1572
|
+
final kp = await plane.authorKeygen();
|
|
1573
|
+
final publicB64 = (kp['public'] ?? '').toString();
|
|
1574
|
+
final secret = (kp['secret'] ?? '').toString();
|
|
1575
|
+
if (publicB64.isEmpty || secret.isEmpty) {
|
|
1576
|
+
throw NomosTestHarnessException(
|
|
1577
|
+
'cloudBound: authorKeygen returned no device key',
|
|
1309
1578
|
);
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
await post(
|
|
1343
|
-
'/v2/workspaces/$platformWorkspace$enrollFirstDevicePath', {
|
|
1579
|
+
}
|
|
1580
|
+
final keyHash = sha256.convert(base64Decode(publicB64)).toString();
|
|
1581
|
+
final minted = await post('/dev/mint-token', {
|
|
1582
|
+
'sub': bareUid,
|
|
1583
|
+
'nonce': keyHash,
|
|
1584
|
+
});
|
|
1585
|
+
final idpToken = (minted['token'] ?? '').toString();
|
|
1586
|
+
final issuer = (minted['issuer'] ?? '').toString();
|
|
1587
|
+
if (idpToken.isEmpty || issuer.isEmpty) {
|
|
1588
|
+
throw NomosTestHarnessException(
|
|
1589
|
+
'cloudBound: /dev/mint-token returned no token — is the test cloud started with platform:/deployJsonPath: and a law composing setAuthProvider?',
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
// THE KEYLESS ENROLL-ONCE DELEGATION (links[1] of the home cert chain) — device-self-signed,
|
|
1593
|
+
// its authority the dev-IdP token. enrollFirstDevice SEEDS it as the user's SignerDelegation, so
|
|
1594
|
+
// birthHome's offer-effect assembles a WELL-FORMED chain [home_cert, u_delegation, …platform…].
|
|
1595
|
+
// WITHOUT it the platform holds no delegation for the owner, the assembly falls to the
|
|
1596
|
+
// no-delegation branch, the platform_cert lands in the delegation slot, and the home refuses at
|
|
1597
|
+
// its OWN verify door ("home cert founder != delegation cert founder").
|
|
1598
|
+
final delegationCert = await plane.mintKeylessDelegation({
|
|
1599
|
+
'user': subject,
|
|
1600
|
+
'deviceKey': publicB64,
|
|
1601
|
+
'childName': _receivingGroupNameOf(subject),
|
|
1602
|
+
'parentId': platformWorkspace,
|
|
1603
|
+
'verifiedVia': issuer,
|
|
1604
|
+
'idpToken': idpToken,
|
|
1605
|
+
'capturedClock': DateTime.now().toUtc().millisecondsSinceEpoch,
|
|
1606
|
+
'epoch': 0,
|
|
1607
|
+
}, secret);
|
|
1608
|
+
await post(
|
|
1609
|
+
'/v2/workspaces/$platformWorkspace$enrollFirstDevicePath',
|
|
1610
|
+
{
|
|
1344
1611
|
'keyHash': keyHash,
|
|
1345
1612
|
'verifiedVia': issuer,
|
|
1346
1613
|
'validFromEpoch': 0,
|
|
@@ -1350,86 +1617,89 @@ class NomosTestHarness {
|
|
|
1350
1617
|
'authorSecret': secret,
|
|
1351
1618
|
'delegationCert': jsonEncode(delegationCert),
|
|
1352
1619
|
},
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1620
|
+
headers: {'x-nomos-auth': idpToken},
|
|
1621
|
+
);
|
|
1622
|
+
return (
|
|
1623
|
+
publicB64: publicB64,
|
|
1624
|
+
secret: secret,
|
|
1625
|
+
keyHash: keyHash,
|
|
1626
|
+
idpToken: idpToken,
|
|
1627
|
+
);
|
|
1628
|
+
},
|
|
1629
|
+
);
|
|
1363
1630
|
// 5/6 — THE HOME CEREMONY, remotely: the platform's own gateway offer births the home (the ONE
|
|
1364
1631
|
// control-plane contact; the app lane below never touches the platform again). The offer carries
|
|
1365
1632
|
// the device-signed homeCert + the owner's device keyHash (the home's genesis ENROLS it, so the
|
|
1366
1633
|
// born home is SIGNER-READY — it can birth its own estates/catalogues from intent 0).
|
|
1367
1634
|
String? home;
|
|
1368
1635
|
await _phased(
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1636
|
+
phase: '5/6: remote home-birth ceremony (attested)',
|
|
1637
|
+
workspace: platformWorkspace,
|
|
1638
|
+
awaiting:
|
|
1639
|
+
'POST /v2/workspaces/$platformWorkspace$birthReceivingGroupPath',
|
|
1640
|
+
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
1641
|
+
onDiagnostic: onDiagnostic,
|
|
1642
|
+
run: () async {
|
|
1643
|
+
final homeName = _receivingGroupNameOf(subject);
|
|
1644
|
+
final homeCert = await plane.signBirthCert({
|
|
1645
|
+
'child': homeName,
|
|
1646
|
+
'founder': subject,
|
|
1647
|
+
// The cert's `verified` must MATCH the gate verdict (CAS-as-law): the offer carries the
|
|
1648
|
+
// dev-IdP token, so the gate resolves a VERIFIED founder.
|
|
1649
|
+
'verified': true,
|
|
1650
|
+
'parentKey': device.publicB64,
|
|
1651
|
+
'parentId': platformWorkspace,
|
|
1652
|
+
'epoch': 0,
|
|
1653
|
+
}, device.secret);
|
|
1654
|
+
final out = await post(
|
|
1655
|
+
'/v2/workspaces/$platformWorkspace$birthReceivingGroupPath',
|
|
1656
|
+
{
|
|
1389
1657
|
'owner': subject,
|
|
1390
1658
|
'ownerKeyHash': device.keyHash,
|
|
1391
1659
|
'frameworkHash': frameworkHash,
|
|
1392
|
-
'lawHash':
|
|
1660
|
+
'lawHash': applicationHomeLawHash,
|
|
1393
1661
|
'bornAt': DateTime.now().toUtc().toIso8601String(),
|
|
1394
1662
|
'homeCert': jsonEncode(homeCert),
|
|
1395
1663
|
'actor': subject,
|
|
1396
1664
|
'authorSecret': device.secret,
|
|
1397
1665
|
},
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1666
|
+
headers: {'x-nomos-auth': device.idpToken},
|
|
1667
|
+
);
|
|
1668
|
+
final born = (out['born'] as List?) ?? const [];
|
|
1669
|
+
final bornNames = born
|
|
1670
|
+
.map((b) => (b as Map)['workspace']?.toString())
|
|
1671
|
+
.whereType<String>()
|
|
1672
|
+
.where((w) => w.isNotEmpty)
|
|
1673
|
+
.toList();
|
|
1674
|
+
home = bornNames.isEmpty ? null : bornNames.first;
|
|
1675
|
+
if (home == null) {
|
|
1676
|
+
final outcomes = jsonEncode(out['birthOutcomes'] ?? const []);
|
|
1677
|
+
throw NomosTestHarnessException(
|
|
1678
|
+
'cloudBound: birthHome admitted but spawned no home (birthOutcomes=$outcomes)',
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
},
|
|
1682
|
+
);
|
|
1414
1683
|
// 6/6 — mount the born home as a normal cloud-bound session (clone from the test cloud).
|
|
1415
1684
|
await _phased(
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1685
|
+
phase: '6/6: mount the born home',
|
|
1686
|
+
workspace: home,
|
|
1687
|
+
awaiting: 'connect(cloud:, workspace: $home)',
|
|
1688
|
+
timeout: phaseTimeout ?? connectTimeout,
|
|
1689
|
+
onDiagnostic: onDiagnostic,
|
|
1690
|
+
run: () => plane.connect(
|
|
1691
|
+
cloud: cloud.origin,
|
|
1692
|
+
workspace: home!,
|
|
1693
|
+
clientId: clientId,
|
|
1694
|
+
// A PRIVATE home (declared read caps — co2's shape) serves its ledger only to a VERIFIED
|
|
1695
|
+
// principal: ride the caller's token when given, else the dev-IdP token the ceremony minted.
|
|
1696
|
+
authToken: authToken ?? device.idpToken,
|
|
1697
|
+
// The home was born WARRANTED with THIS device enrolled in its genesis (ownerKeyHash) —
|
|
1698
|
+
// connect with the same key so every write is signed and the home's own `.births()` offers
|
|
1699
|
+
// (estates/catalogues) are device-attested (the client signs the birth cert automatically).
|
|
1700
|
+
authorSecret: device.secret,
|
|
1701
|
+
),
|
|
1702
|
+
);
|
|
1433
1703
|
// NO DECOY on a cloudBound harness: the app is genuinely multi-workspace (the home plus every
|
|
1434
1704
|
// locally-born child), so the plane is never sole-session — the decoy the offline harness
|
|
1435
1705
|
// needs (to break single-session inference) would just be a spurious local-only workspace here.
|
|
@@ -1448,7 +1718,11 @@ class NomosTestHarness {
|
|
|
1448
1718
|
devicePublics: {home!: device.publicB64},
|
|
1449
1719
|
deviceSecrets: {home!: device.secret},
|
|
1450
1720
|
frameworkHash: frameworkHash,
|
|
1451
|
-
lawHashes: {
|
|
1721
|
+
lawHashes: {
|
|
1722
|
+
// The born receiving group contains the payload package, not every reference-only package which
|
|
1723
|
+
// happened to be available on its parent during the ceremony.
|
|
1724
|
+
home!: [applicationHomeLawHash],
|
|
1725
|
+
},
|
|
1452
1726
|
phaseTimeout: phaseTimeout,
|
|
1453
1727
|
cloudRestartLane: _CloudRestartLane(
|
|
1454
1728
|
entry: entry,
|
|
@@ -1489,9 +1763,10 @@ class NomosTestHarness {
|
|
|
1489
1763
|
/// each locally-born child's remote custody BEFORE that child's own sync converges against it. Repeats
|
|
1490
1764
|
/// until every session reports neither `pushFailed` nor `stillAhead`, or [timeout] (then throws with the
|
|
1491
1765
|
/// per-workspace last verdicts). Returns the final sync result per workspace.
|
|
1492
|
-
Future<Map<String, Map<String, Object?>>> converge(
|
|
1493
|
-
|
|
1494
|
-
|
|
1766
|
+
Future<Map<String, Map<String, Object?>>> converge({
|
|
1767
|
+
Duration timeout = const Duration(seconds: 120),
|
|
1768
|
+
List<String>? workspaces,
|
|
1769
|
+
}) async {
|
|
1495
1770
|
// Every LIVE session on the plane (not the harness's construction-time list — a locally-born child
|
|
1496
1771
|
// acquired after construction must converge too), PARENT-FIRST: the home syncs before its children, so
|
|
1497
1772
|
// its birth offer-effect creates each child's remote custody BEFORE that child pushes its own writes.
|
|
@@ -1528,8 +1803,9 @@ class NomosTestHarness {
|
|
|
1528
1803
|
if (pending.isEmpty) break;
|
|
1529
1804
|
if (DateTime.now().isAfter(deadline)) {
|
|
1530
1805
|
throw NomosTestHarnessException(
|
|
1531
|
-
|
|
1532
|
-
|
|
1806
|
+
'converge: ${pending.length} workspace(s) did not converge within ${timeout.inSeconds}s — '
|
|
1807
|
+
'${pending.map((w) => '$w: ${jsonEncode(last[w] ?? {})}').join('; ')}',
|
|
1808
|
+
);
|
|
1533
1809
|
}
|
|
1534
1810
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
|
1535
1811
|
}
|
|
@@ -1552,22 +1828,24 @@ class NomosTestHarness {
|
|
|
1552
1828
|
final snap = _snapshots[workspace];
|
|
1553
1829
|
if (snap == null) {
|
|
1554
1830
|
throw NomosTestHarnessException(
|
|
1555
|
-
|
|
1556
|
-
|
|
1831
|
+
'resetWorkspaceCustody("$workspace"): no seed snapshot held for this workspace — '
|
|
1832
|
+
'seeded workspaces: ${_snapshots.keys.toList()}',
|
|
1833
|
+
);
|
|
1557
1834
|
}
|
|
1558
1835
|
await _phased(
|
|
1559
|
-
|
|
1836
|
+
phase: 'resetWorkspaceCustody: reconnect from seed snapshot',
|
|
1837
|
+
workspace: workspace,
|
|
1838
|
+
awaiting: 'connect',
|
|
1839
|
+
timeout: _phaseTimeout ?? const Duration(seconds: 60),
|
|
1840
|
+
onDiagnostic: _onDiagnostic,
|
|
1841
|
+
run: () => _plane.connect(
|
|
1560
1842
|
workspace: workspace,
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
clientId: '${_installLane?.clientId ?? 'test-runner'}-$workspace',
|
|
1568
|
-
restoreFrom: snap,
|
|
1569
|
-
allowUnborn: true,
|
|
1570
|
-
));
|
|
1843
|
+
session: workspace,
|
|
1844
|
+
clientId: '${_installLane?.clientId ?? 'test-runner'}-$workspace',
|
|
1845
|
+
restoreFrom: snap,
|
|
1846
|
+
allowUnborn: true,
|
|
1847
|
+
),
|
|
1848
|
+
);
|
|
1571
1849
|
}
|
|
1572
1850
|
|
|
1573
1851
|
/// SEED a NEW workspace on this plane from `build/<name>.deploy.json` law packages — the
|
|
@@ -1580,49 +1858,54 @@ class NomosTestHarness {
|
|
|
1580
1858
|
final lane = _installLane;
|
|
1581
1859
|
if (lane == null) {
|
|
1582
1860
|
throw NomosTestHarnessException(
|
|
1583
|
-
|
|
1861
|
+
'seedWorkspaceFromDeployJson: this harness holds no install-lane config',
|
|
1862
|
+
);
|
|
1584
1863
|
}
|
|
1585
1864
|
if (deployJsonPaths.isEmpty) {
|
|
1586
1865
|
throw NomosTestHarnessException(
|
|
1587
|
-
|
|
1866
|
+
'seedWorkspaceFromDeployJson("$workspace"): pass the build/<name>.deploy.json paths',
|
|
1867
|
+
);
|
|
1588
1868
|
}
|
|
1589
1869
|
final snap = '${_workdir.path}/$workspace.bin';
|
|
1590
1870
|
final info = await _phased(
|
|
1591
|
-
|
|
1871
|
+
phase: 'seed: offline law install',
|
|
1872
|
+
workspace: workspace,
|
|
1873
|
+
awaiting: lane.useInRepoScript
|
|
1874
|
+
? 'offline-install.ts'
|
|
1875
|
+
: 'githolon harness-install',
|
|
1876
|
+
timeout: _phaseTimeout ?? _kInstallDeadline,
|
|
1877
|
+
onDiagnostic: _onDiagnostic,
|
|
1878
|
+
run: () => _runInstall(
|
|
1879
|
+
node: lane.node,
|
|
1880
|
+
useInRepoScript: lane.useInRepoScript,
|
|
1881
|
+
inRepoScript: lane.inRepoScript,
|
|
1882
|
+
githolonCmd: lane.githolonCmd,
|
|
1883
|
+
deployJsonPaths: deployJsonPaths,
|
|
1592
1884
|
workspace: workspace,
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
: 'githolon harness-install',
|
|
1596
|
-
timeout: _phaseTimeout ?? _kInstallDeadline,
|
|
1885
|
+
snapshot: snap,
|
|
1886
|
+
runtimeDir: lane.runtimeDir,
|
|
1597
1887
|
onDiagnostic: _onDiagnostic,
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
useInRepoScript: lane.useInRepoScript,
|
|
1601
|
-
inRepoScript: lane.inRepoScript,
|
|
1602
|
-
githolonCmd: lane.githolonCmd,
|
|
1603
|
-
deployJsonPaths: deployJsonPaths,
|
|
1604
|
-
workspace: workspace,
|
|
1605
|
-
snapshot: snap,
|
|
1606
|
-
runtimeDir: lane.runtimeDir,
|
|
1607
|
-
onDiagnostic: _onDiagnostic));
|
|
1888
|
+
),
|
|
1889
|
+
);
|
|
1608
1890
|
final b64 = base64Encode(await File(snap).readAsBytes());
|
|
1609
1891
|
_snapshots[workspace] = b64;
|
|
1610
1892
|
_lawHashes[workspace] = ((info['lawHashes'] as List?) ?? const [])
|
|
1611
1893
|
.map((e) => e.toString())
|
|
1612
1894
|
.toList();
|
|
1613
1895
|
await _phased(
|
|
1614
|
-
|
|
1896
|
+
phase: 'seed: connect session',
|
|
1897
|
+
workspace: workspace,
|
|
1898
|
+
awaiting: 'connect',
|
|
1899
|
+
timeout: _phaseTimeout ?? const Duration(seconds: 60),
|
|
1900
|
+
onDiagnostic: _onDiagnostic,
|
|
1901
|
+
run: () => _plane.connect(
|
|
1615
1902
|
workspace: workspace,
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
clientId: '${lane.clientId}-$workspace',
|
|
1623
|
-
restoreFrom: b64,
|
|
1624
|
-
allowUnborn: true,
|
|
1625
|
-
));
|
|
1903
|
+
session: workspace,
|
|
1904
|
+
clientId: '${lane.clientId}-$workspace',
|
|
1905
|
+
restoreFrom: b64,
|
|
1906
|
+
allowUnborn: true,
|
|
1907
|
+
),
|
|
1908
|
+
);
|
|
1626
1909
|
if (!_sessionNames.contains(workspace)) _sessionNames.add(workspace);
|
|
1627
1910
|
return _plane.session(workspace);
|
|
1628
1911
|
}
|
|
@@ -1631,20 +1914,24 @@ class NomosTestHarness {
|
|
|
1631
1914
|
/// for "why does my dispatch refuse". Throws [NomosTestHarnessException] naming the ACTIVE
|
|
1632
1915
|
/// domain keys when absent. (For the full shape check use
|
|
1633
1916
|
/// `harness.session(ws).assertCompatible(XClient.interface)`.)
|
|
1634
|
-
Future<void> assertDomainInstalled(
|
|
1635
|
-
|
|
1917
|
+
Future<void> assertDomainInstalled({
|
|
1918
|
+
required String workspace,
|
|
1919
|
+
required String domain,
|
|
1920
|
+
}) async {
|
|
1636
1921
|
final offer = await _phased(
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1922
|
+
phase: 'assertDomainInstalled: read the interface offer',
|
|
1923
|
+
workspace: workspace,
|
|
1924
|
+
awaiting: 'interfaceOffer',
|
|
1925
|
+
timeout: _phaseTimeout ?? _kOtherDeadline,
|
|
1926
|
+
onDiagnostic: _onDiagnostic,
|
|
1927
|
+
run: () => _plane.session(workspace).interfaceOffer(),
|
|
1928
|
+
);
|
|
1643
1929
|
final keys = (offer?.keys.toList() ?? const <String>[])..sort();
|
|
1644
1930
|
if (!keys.contains(domain)) {
|
|
1645
1931
|
throw NomosTestHarnessException(
|
|
1646
|
-
|
|
1647
|
-
|
|
1932
|
+
'domain "$domain" is NOT installed on workspace "$workspace" — active domains: '
|
|
1933
|
+
'${keys.isEmpty ? '(none observable — no on-ledger interface yet, or law truly not deployed)' : keys.join(', ')}',
|
|
1934
|
+
);
|
|
1648
1935
|
}
|
|
1649
1936
|
}
|
|
1650
1937
|
|
|
@@ -1658,7 +1945,8 @@ class NomosTestHarness {
|
|
|
1658
1945
|
final connected = _plane.connectedSessions;
|
|
1659
1946
|
if (!connected.contains(workspace)) {
|
|
1660
1947
|
throw NomosTestHarnessException(
|
|
1661
|
-
|
|
1948
|
+
'snapshotWorkspace("$workspace"): no connected session; connected workspaces: $connected',
|
|
1949
|
+
);
|
|
1662
1950
|
}
|
|
1663
1951
|
final s = _plane.session(workspace);
|
|
1664
1952
|
return _phased(
|
|
@@ -1686,19 +1974,24 @@ class NomosTestHarness {
|
|
|
1686
1974
|
Future<PlaneSnapshot> snapshotPlane() async {
|
|
1687
1975
|
final out = <String, WorkspaceSnapshot>{};
|
|
1688
1976
|
final connected = _plane.connectedSessions.where(
|
|
1689
|
-
|
|
1977
|
+
(ws) => ws != _decoyWorkspace && !ws.endsWith('-onboard-scratch'),
|
|
1978
|
+
);
|
|
1690
1979
|
for (final ws in connected) {
|
|
1691
1980
|
out[ws] = await snapshotWorkspace(ws);
|
|
1692
1981
|
}
|
|
1693
1982
|
return PlaneSnapshot(
|
|
1694
|
-
|
|
1983
|
+
workspaces: Map.unmodifiable(out),
|
|
1984
|
+
capturedAt: DateTime.now().toUtc(),
|
|
1985
|
+
);
|
|
1695
1986
|
}
|
|
1696
1987
|
|
|
1697
1988
|
/// Restore a workspace FROM a [WorkspaceSnapshot] — re-open its session on this plane from the snapshot's
|
|
1698
1989
|
/// LOCAL CUSTODY (no network clone), replacing the resident holon. The workspace need not currently be a
|
|
1699
1990
|
/// session (a fresh restore is allowed); its name is taken from [snapshot] unless [into] overrides it.
|
|
1700
|
-
Future<NomosSession> restoreWorkspace(
|
|
1701
|
-
|
|
1991
|
+
Future<NomosSession> restoreWorkspace(
|
|
1992
|
+
WorkspaceSnapshot snapshot, {
|
|
1993
|
+
String? into,
|
|
1994
|
+
}) async {
|
|
1702
1995
|
final ws = into ?? snapshot.workspace;
|
|
1703
1996
|
await _phased(
|
|
1704
1997
|
phase: 'restoreWorkspace',
|
|
@@ -1732,19 +2025,22 @@ class NomosTestHarness {
|
|
|
1732
2025
|
Future<void> restart() async {
|
|
1733
2026
|
if (_disposed) {
|
|
1734
2027
|
throw NomosTestHarnessException(
|
|
1735
|
-
|
|
2028
|
+
'restart(): this test device has already been disposed',
|
|
2029
|
+
);
|
|
1736
2030
|
}
|
|
1737
2031
|
final lane = _cloudRestartLane;
|
|
1738
2032
|
final home = homeWorkspace;
|
|
1739
2033
|
if (lane == null || home == null) {
|
|
1740
2034
|
throw NomosTestHarnessException(
|
|
1741
|
-
|
|
2035
|
+
'restart() requires a harness with one primary application session',
|
|
2036
|
+
);
|
|
1742
2037
|
}
|
|
1743
2038
|
|
|
1744
2039
|
final oldPlane = _plane;
|
|
1745
2040
|
final targets = oldPlane.connectedSessions
|
|
1746
2041
|
.where(
|
|
1747
|
-
|
|
2042
|
+
(ws) => ws != _decoyWorkspace && !ws.endsWith('-onboard-scratch'),
|
|
2043
|
+
)
|
|
1748
2044
|
.toList();
|
|
1749
2045
|
if (targets.remove(home)) targets.insert(0, home);
|
|
1750
2046
|
final snapshots = <String, String>{};
|
|
@@ -1831,19 +2127,22 @@ class NomosTestHarness {
|
|
|
1831
2127
|
Future<void> reinstall() async {
|
|
1832
2128
|
if (_disposed) {
|
|
1833
2129
|
throw NomosTestHarnessException(
|
|
1834
|
-
|
|
2130
|
+
'reinstall(): this test device has already been disposed',
|
|
2131
|
+
);
|
|
1835
2132
|
}
|
|
1836
2133
|
final lane = _cloudRestartLane;
|
|
1837
2134
|
final home = homeWorkspace;
|
|
1838
2135
|
if (lane == null || home == null || testCloud == null) {
|
|
1839
2136
|
throw NomosTestHarnessException(
|
|
1840
|
-
|
|
2137
|
+
'reinstall() requires a connected scenario with remote custody',
|
|
2138
|
+
);
|
|
1841
2139
|
}
|
|
1842
2140
|
|
|
1843
2141
|
final oldPlane = _plane;
|
|
1844
2142
|
final targets = oldPlane.connectedSessions
|
|
1845
2143
|
.where(
|
|
1846
|
-
|
|
2144
|
+
(ws) => ws != _decoyWorkspace && !ws.endsWith('-onboard-scratch'),
|
|
2145
|
+
)
|
|
1847
2146
|
.toList();
|
|
1848
2147
|
if (targets.remove(home)) targets.insert(0, home);
|
|
1849
2148
|
for (final ws in targets) {
|
|
@@ -1890,31 +2189,41 @@ class NomosTestHarness {
|
|
|
1890
2189
|
_disposed = true;
|
|
1891
2190
|
final f = _onDiagnostic;
|
|
1892
2191
|
if (f != null)
|
|
1893
|
-
f(
|
|
2192
|
+
f(
|
|
2193
|
+
'[nomos-test-harness] phase dispose (≤${(_phaseTimeout ?? _kOtherDeadline).inSeconds}s) …',
|
|
2194
|
+
);
|
|
1894
2195
|
try {
|
|
1895
2196
|
// The bridge owns the transport's one message subscription and every watch controller. Closing the
|
|
1896
2197
|
// transport first makes its incoming stream wait on consumers that can no longer receive their
|
|
1897
2198
|
// unwatch/done messages—a deterministic teardown deadlock in reactive Flutter tests.
|
|
1898
|
-
await _plane.dispose().timeout(
|
|
1899
|
-
|
|
2199
|
+
await _plane.dispose().timeout(
|
|
2200
|
+
_phaseTimeout ?? _kOtherDeadline,
|
|
2201
|
+
onTimeout: () => throw HarnessPhaseTimeout(
|
|
1900
2202
|
phase: 'dispose: close bridge watches',
|
|
1901
2203
|
workspace: workspace,
|
|
1902
2204
|
elapsed: _phaseTimeout ?? _kOtherDeadline,
|
|
1903
|
-
awaiting: 'NomosBridge.dispose'
|
|
2205
|
+
awaiting: 'NomosBridge.dispose',
|
|
2206
|
+
),
|
|
2207
|
+
);
|
|
1904
2208
|
} catch (e) {
|
|
1905
2209
|
_diag(
|
|
1906
|
-
|
|
2210
|
+
'[nomos-test-harness] bridge dispose error (continuing to runner cleanup): $e',
|
|
2211
|
+
);
|
|
1907
2212
|
}
|
|
1908
2213
|
try {
|
|
1909
|
-
await _transport.dispose().timeout(
|
|
1910
|
-
|
|
2214
|
+
await _transport.dispose().timeout(
|
|
2215
|
+
_phaseTimeout ?? _kOtherDeadline,
|
|
2216
|
+
onTimeout: () => throw HarnessPhaseTimeout(
|
|
1911
2217
|
phase: 'dispose: stop the node runner',
|
|
1912
2218
|
workspace: workspace,
|
|
1913
2219
|
elapsed: _phaseTimeout ?? _kOtherDeadline,
|
|
1914
|
-
awaiting: 'NodeRunnerTransport.dispose'
|
|
2220
|
+
awaiting: 'NodeRunnerTransport.dispose',
|
|
2221
|
+
),
|
|
2222
|
+
);
|
|
1915
2223
|
} catch (e) {
|
|
1916
2224
|
_diag(
|
|
1917
|
-
|
|
2225
|
+
'[nomos-test-harness] transport dispose error (continuing to temp-dir cleanup): $e',
|
|
2226
|
+
);
|
|
1918
2227
|
}
|
|
1919
2228
|
try {
|
|
1920
2229
|
if (await _workdir.exists()) await _workdir.delete(recursive: true);
|
|
@@ -1971,7 +2280,8 @@ class NomosTestHarness {
|
|
|
1971
2280
|
.toList();
|
|
1972
2281
|
if (tokens.isEmpty) {
|
|
1973
2282
|
throw NomosTestHarnessException(
|
|
1974
|
-
|
|
2283
|
+
'no install helper resolved — pass installHelper: (an offline-install.ts path, or a githolon command)',
|
|
2284
|
+
);
|
|
1975
2285
|
}
|
|
1976
2286
|
executable = tokens.first;
|
|
1977
2287
|
args = [...tokens.skip(1), 'harness-install', ...common];
|
|
@@ -1986,7 +2296,8 @@ class NomosTestHarness {
|
|
|
1986
2296
|
}
|
|
1987
2297
|
if (r.exitCode != 0) {
|
|
1988
2298
|
throw NomosTestHarnessException(
|
|
1989
|
-
|
|
2299
|
+
'offline law install for "$workspace" failed (exit ${r.exitCode}): ${err.isEmpty ? "(no stderr)" : err}',
|
|
2300
|
+
);
|
|
1990
2301
|
}
|
|
1991
2302
|
// The helper prints one JSON line on success; verify it before spawning the runner.
|
|
1992
2303
|
final out = (r.stdout as String).trim();
|
|
@@ -1998,7 +2309,8 @@ class NomosTestHarness {
|
|
|
1998
2309
|
} catch (_) {}
|
|
1999
2310
|
if (decoded['ok'] != true || !File(snapshot).existsSync()) {
|
|
2000
2311
|
throw NomosTestHarnessException(
|
|
2001
|
-
|
|
2312
|
+
'offline law install produced no snapshot (stdout: ${out.isEmpty ? "(empty)" : out})',
|
|
2313
|
+
);
|
|
2002
2314
|
}
|
|
2003
2315
|
// { ok, snapshot, workspace, lawHashes, frameworkHash } — the caller keeps lawHashes/frameworkHash so a
|
|
2004
2316
|
// `.births()` directive can be driven (its genesis installs the framework + law by hash).
|
|
@@ -2042,17 +2354,20 @@ class NomosTestHarness {
|
|
|
2042
2354
|
'@githolon/client/runner/node-entry.ts',
|
|
2043
2355
|
]) {
|
|
2044
2356
|
final r = await Process.run(
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2357
|
+
node,
|
|
2358
|
+
[
|
|
2359
|
+
'-p',
|
|
2360
|
+
"require.resolve('$entry')",
|
|
2361
|
+
],
|
|
2362
|
+
workingDirectory: from);
|
|
2049
2363
|
final out = (r.stdout as String).trim();
|
|
2050
2364
|
if (r.exitCode == 0 && out.isNotEmpty) return out;
|
|
2051
2365
|
}
|
|
2052
2366
|
throw NomosTestHarnessException(
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2367
|
+
'the Nomos release contains no bundled node runner and '
|
|
2368
|
+
'@githolon/client/runner/node-entry.{mjs,ts} cannot be resolved from $from. '
|
|
2369
|
+
'Reinstall nomos_client, or pass nodeEntry: only when developing Nomos itself.',
|
|
2370
|
+
);
|
|
2056
2371
|
}
|
|
2057
2372
|
}
|
|
2058
2373
|
|
|
@@ -2091,8 +2406,10 @@ class WorkspaceSnapshot {
|
|
|
2091
2406
|
head: (j['head'] ?? '') as String,
|
|
2092
2407
|
lawHashes:
|
|
2093
2408
|
((j['lawHashes'] as List?) ?? const []).map((e) => '$e').toList(),
|
|
2094
|
-
capturedAt: DateTime.parse(
|
|
2095
|
-
|
|
2409
|
+
capturedAt: DateTime.parse(
|
|
2410
|
+
(j['capturedAt'] ?? DateTime.now().toUtc().toIso8601String())
|
|
2411
|
+
as String,
|
|
2412
|
+
),
|
|
2096
2413
|
);
|
|
2097
2414
|
|
|
2098
2415
|
@override
|
|
@@ -2115,12 +2432,15 @@ class PlaneSnapshot {
|
|
|
2115
2432
|
};
|
|
2116
2433
|
|
|
2117
2434
|
factory PlaneSnapshot.fromJson(Map<String, Object?> j) => PlaneSnapshot(
|
|
2118
|
-
workspaces: ((j['workspaces'] as Map?) ?? const {}).map(
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2435
|
+
workspaces: ((j['workspaces'] as Map?) ?? const {}).map(
|
|
2436
|
+
(k, v) => MapEntry(
|
|
2437
|
+
'$k',
|
|
2438
|
+
WorkspaceSnapshot.fromJson((v as Map).cast<String, Object?>()),
|
|
2439
|
+
),
|
|
2440
|
+
),
|
|
2441
|
+
capturedAt: DateTime.parse(
|
|
2442
|
+
(j['capturedAt'] ?? DateTime.now().toUtc().toIso8601String())
|
|
2443
|
+
as String,
|
|
2444
|
+
),
|
|
2125
2445
|
);
|
|
2126
2446
|
}
|