@githolon/dsl 0.72.1 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dart/CHANGELOG.md +34 -0
- package/dart/lib/src/test_harness.dart +322 -125
- package/dart/lib/src/webview_bridge.dart +92 -0
- package/dart/pubspec.yaml +1 -1
- package/dart/test/acquire_session_test.dart +152 -0
- package/dart/test/cluster_device_keyed_acceptance_test.dart +108 -0
- package/dart/test/cluster_local_autosync_test.dart +119 -0
- package/dart/test/fixtures/wedge/fake-install.mjs +11 -0
- package/dart/test/fixtures/wedge/wedge-entry.mjs +22 -0
- package/dart/test/harness_phase_timeout_test.dart +72 -0
- package/dart/test/test_harness_test.dart +6 -4
- package/package.json +1 -1
- package/src/compile_package_main.ts +1 -1
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
## 0.50.0
|
|
2
|
+
|
|
3
|
+
- **No silent hangs — the standing harness property.** Every `NomosTestHarness` lifecycle phase now
|
|
4
|
+
has a deadline (overridable via the new `phaseTimeout` param) and throws a typed
|
|
5
|
+
`HarnessPhaseTimeout` naming the phase (e.g. `"3b: keyed reconnect of 'catalogue'"`), workspace,
|
|
6
|
+
elapsed time, the awaited bridge op, and likely causes — a wedge is a 30-second named exception,
|
|
7
|
+
never a 40-minute bisection. Every phase logs terse enter/exit diagnostics. Disposal deadline
|
|
8
|
+
breaches are logged, not thrown — teardown always completes.
|
|
9
|
+
- **Keyed-reconnect lane hardened** (the `deviceKeyed` cluster hang report): harness phase 3b
|
|
10
|
+
(keygen + keyed reconnect) had no timeout and no diagnostics — any wedge there hung silently
|
|
11
|
+
after "4 session(s) connected". Additionally two runner/realm defects fixed: the stale session
|
|
12
|
+
record stayed routable during an async re-open (concurrent ops could hit the disposed holon —
|
|
13
|
+
now an atomic replace with a fast typed refusal in the window), and a re-open with new
|
|
14
|
+
credentials but no `restoreFrom` silently kept the OLD identity (the fresh authorSecret was
|
|
15
|
+
never installed) — an identity-bearing option change now forces an in-place re-mount over
|
|
16
|
+
resident custody.
|
|
17
|
+
- co2's acceptance implemented verbatim: `cluster(deviceKeyed:)` completes, `devicePublicKey`
|
|
18
|
+
populated, colocated attested read signed by the re-opened key, disposal completes.
|
|
19
|
+
|
|
20
|
+
## 0.49.0
|
|
21
|
+
|
|
22
|
+
- **Fix: local-only `autoSync()` deadlocked the multi-workspace plane.** A `local://` session's
|
|
23
|
+
autoSync registered the full cloud machinery (doorbell WebSocket retry loop, fallback timers,
|
|
24
|
+
intent-offer POSTs that could never land) — the future never completed, the runner dispatcher
|
|
25
|
+
starved, sibling queries and `listWorkspaces()` hung, and teardown wedged. `autoSync()` on a
|
|
26
|
+
local-only session now registers nothing and returns immediately; `sync()` is a true no-op
|
|
27
|
+
(`localOnly: true` summary); a local:// holon performs ZERO network attempts. (co2's acceptance
|
|
28
|
+
test implemented verbatim; failed before, passes after.)
|
|
29
|
+
- **New: `acquireSession(workspace, {ifAbsentCredentials})`** — atomic single-flight session
|
|
30
|
+
acquisition: reuses a connected session, joins an in-flight open (concurrent calls yield the
|
|
31
|
+
same handle, one connect), opens local-only for workspaces the plane already knows
|
|
32
|
+
(mounted/parked/resident custody — credentials never invoked), and only calls
|
|
33
|
+
`ifAbsentCredentials` (exactly once) when a genuine cloud open is required.
|
|
34
|
+
|
|
1
35
|
## 0.48.1
|
|
2
36
|
|
|
3
37
|
- **Fix: `NomosSession.watchPendingConflicts()` never emitted.** The session overrode every other
|
|
@@ -63,6 +63,43 @@ class NomosTestHarnessException implements Exception {
|
|
|
63
63
|
String toString() => 'NomosTestHarnessException: $message';
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/// A TYPED per-phase deadline breach — the "NO SILENT HANGS" guarantee: every lifecycle await in
|
|
67
|
+
/// [NomosTestHarness] (install, runner spawn, ready, connect, keygen, keyed reconnect, seed, dispose)
|
|
68
|
+
/// runs under a generous deadline and, instead of wedging forever with no output, throws THIS —
|
|
69
|
+
/// naming the phase, the workspace, how long it waited, the bridge op it was awaiting, and the
|
|
70
|
+
/// likely causes where they are known. Subclasses [NomosTestHarnessException] so existing
|
|
71
|
+
/// `on NomosTestHarnessException` handling keeps working. Tune deadlines with the harness's
|
|
72
|
+
/// `phaseTimeout` parameter (it overrides EVERY phase's default when set).
|
|
73
|
+
class HarnessPhaseTimeout extends NomosTestHarnessException {
|
|
74
|
+
/// The human phase name (e.g. `3b: keyed reconnect of 'catalogue'`).
|
|
75
|
+
final String phase;
|
|
76
|
+
|
|
77
|
+
/// The workspace the phase was operating on (null for plane-wide phases like runner spawn).
|
|
78
|
+
final String? workspace;
|
|
79
|
+
|
|
80
|
+
/// How long the harness waited before giving up.
|
|
81
|
+
final Duration elapsed;
|
|
82
|
+
|
|
83
|
+
/// What the harness was awaiting (the bridge op / subprocess), e.g. `connect`, `authorKeygen`.
|
|
84
|
+
final String awaiting;
|
|
85
|
+
|
|
86
|
+
/// Likely causes, where the harness knows them (mirrors DomainNotInstalledForSession.possibleCauses).
|
|
87
|
+
final List<String> likelyCauses;
|
|
88
|
+
|
|
89
|
+
HarnessPhaseTimeout({
|
|
90
|
+
required this.phase,
|
|
91
|
+
this.workspace,
|
|
92
|
+
required this.elapsed,
|
|
93
|
+
required this.awaiting,
|
|
94
|
+
this.likelyCauses = const [],
|
|
95
|
+
}) : super(
|
|
96
|
+
'phase "$phase"${workspace != null ? ' (workspace "$workspace")' : ''} '
|
|
97
|
+
'did not complete within ${elapsed.inSeconds}s while awaiting `$awaiting`'
|
|
98
|
+
'${likelyCauses.isEmpty ? '' : ' — likely causes: ${likelyCauses.map((c) => '\n • $c').join()}'}');
|
|
99
|
+
@override
|
|
100
|
+
String toString() => 'HarnessPhaseTimeout: $message';
|
|
101
|
+
}
|
|
102
|
+
|
|
66
103
|
/// The decoy session [NomosTestHarness.ephemeral] connects alongside the workspace under test — it exists
|
|
67
104
|
/// so the plane is ALWAYS multi-session in tests (sole-session inference never silently passes).
|
|
68
105
|
const String _decoyWorkspace = '__nomos-decoy';
|
|
@@ -107,17 +144,58 @@ class NomosTestHarness {
|
|
|
107
144
|
/// The offline-install lane config (for [seedWorkspaceFromDeployJson]); null when unknown.
|
|
108
145
|
final _InstallLane? _installLane;
|
|
109
146
|
|
|
147
|
+
/// When set, overrides EVERY phase's default deadline (see [HarnessPhaseTimeout]).
|
|
148
|
+
final Duration? _phaseTimeout;
|
|
149
|
+
|
|
110
150
|
NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
|
|
111
151
|
this._onDiagnostic, this._sessionNames,
|
|
112
152
|
{Map<String, String> devicePublics = const {},
|
|
113
153
|
this.frameworkHash,
|
|
114
154
|
Map<String, List<String>> lawHashes = const {},
|
|
115
155
|
Map<String, String> snapshots = const {},
|
|
116
|
-
_InstallLane? installLane
|
|
156
|
+
_InstallLane? installLane,
|
|
157
|
+
Duration? phaseTimeout})
|
|
117
158
|
: _devicePublics = devicePublics,
|
|
118
159
|
_lawHashes = Map.of(lawHashes),
|
|
119
160
|
_snapshots = Map.of(snapshots),
|
|
120
|
-
_installLane = installLane
|
|
161
|
+
_installLane = installLane,
|
|
162
|
+
_phaseTimeout = phaseTimeout;
|
|
163
|
+
|
|
164
|
+
// ── THE "NO SILENT HANGS" MACHINERY — every lifecycle await runs as a named, deadlined PHASE ──
|
|
165
|
+
|
|
166
|
+
/// Per-phase default deadlines (a [phaseTimeout] argument overrides them all).
|
|
167
|
+
static const Duration _kInstallDeadline = Duration(seconds: 60);
|
|
168
|
+
static const Duration _kSpawnDeadline = Duration(seconds: 30);
|
|
169
|
+
static const Duration _kOtherDeadline = Duration(seconds: 30);
|
|
170
|
+
|
|
171
|
+
/// Run one lifecycle phase under a deadline, logging terse enter/exit lines (their ask 5) and
|
|
172
|
+
/// converting a breach into the typed [HarnessPhaseTimeout] — a wedge is NEVER a silent hang.
|
|
173
|
+
static Future<T> _phased<T>({
|
|
174
|
+
required String phase,
|
|
175
|
+
String? workspace,
|
|
176
|
+
required String awaiting,
|
|
177
|
+
required Duration timeout,
|
|
178
|
+
required Future<T> Function() run,
|
|
179
|
+
List<String> likelyCauses = const [],
|
|
180
|
+
void Function(String line)? onDiagnostic,
|
|
181
|
+
}) async {
|
|
182
|
+
void log(String m) {
|
|
183
|
+
if (onDiagnostic != null) onDiagnostic('[nomos-test-harness] $m');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
final where = workspace != null ? ' "$workspace"' : '';
|
|
187
|
+
log('phase $phase$where (≤${timeout.inSeconds}s) …');
|
|
188
|
+
final sw = Stopwatch()..start();
|
|
189
|
+
final r = await run().timeout(timeout,
|
|
190
|
+
onTimeout: () => throw HarnessPhaseTimeout(
|
|
191
|
+
phase: phase,
|
|
192
|
+
workspace: workspace,
|
|
193
|
+
elapsed: timeout,
|
|
194
|
+
awaiting: awaiting,
|
|
195
|
+
likelyCauses: likelyCauses));
|
|
196
|
+
log('phase $phase$where done (${sw.elapsedMilliseconds}ms)');
|
|
197
|
+
return r;
|
|
198
|
+
}
|
|
121
199
|
|
|
122
200
|
/// The base64 device PUBLIC key a `deviceKeyed` [cluster] session was opened with (null otherwise). Its
|
|
123
201
|
/// `sha256(base64decode(...))` is the attestor keyHash a reader enrols to trust this source's signed reads.
|
|
@@ -167,6 +245,7 @@ class NomosTestHarness {
|
|
|
167
245
|
void Function(String line)? onDiagnostic,
|
|
168
246
|
Duration readyTimeout = const Duration(seconds: 30),
|
|
169
247
|
Duration connectTimeout = const Duration(seconds: 120),
|
|
248
|
+
Duration? phaseTimeout,
|
|
170
249
|
bool decoySession = true,
|
|
171
250
|
}) async {
|
|
172
251
|
if (deployJsonPaths.isEmpty) {
|
|
@@ -196,48 +275,70 @@ class NomosTestHarness {
|
|
|
196
275
|
final phase = (String m) { if (onDiagnostic != null) onDiagnostic('[nomos-test-harness] $m'); };
|
|
197
276
|
try {
|
|
198
277
|
final snapshot = '${workdir.path}/snapshot.bin';
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
node: node,
|
|
202
|
-
useInRepoScript: useInRepoScript,
|
|
203
|
-
inRepoScript: resolvedHelper,
|
|
204
|
-
githolonCmd: githolonCmd,
|
|
205
|
-
deployJsonPaths: deployJsonPaths,
|
|
278
|
+
final info = await _phased(
|
|
279
|
+
phase: '1/4: offline law install (${deployJsonPaths.length} package(s))',
|
|
206
280
|
workspace: workspace,
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
onDiagnostic: onDiagnostic
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
await
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
281
|
+
awaiting: useInRepoScript ? 'offline-install.mjs' : 'githolon harness-install',
|
|
282
|
+
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
283
|
+
onDiagnostic: onDiagnostic,
|
|
284
|
+
likelyCauses: const [
|
|
285
|
+
'a cold npx download of githolon (pre-warm with any `githolon` run)',
|
|
286
|
+
'a very large law package folding through the offline gate — raise phaseTimeout',
|
|
287
|
+
],
|
|
288
|
+
run: () => _runInstall(
|
|
289
|
+
node: node,
|
|
290
|
+
useInRepoScript: useInRepoScript,
|
|
291
|
+
inRepoScript: resolvedHelper,
|
|
292
|
+
githolonCmd: githolonCmd,
|
|
293
|
+
deployJsonPaths: deployJsonPaths,
|
|
294
|
+
workspace: workspace,
|
|
295
|
+
snapshot: snapshot,
|
|
296
|
+
runtimeDir: runtimeDir,
|
|
297
|
+
onDiagnostic: onDiagnostic));
|
|
298
|
+
|
|
299
|
+
transport = await _phased(
|
|
300
|
+
phase: '2/4: spawn the node runner',
|
|
301
|
+
awaiting: 'Process.start($node)',
|
|
302
|
+
timeout: phaseTimeout ?? _kSpawnDeadline,
|
|
303
|
+
onDiagnostic: onDiagnostic,
|
|
304
|
+
run: () => NodeRunnerTransport.start(
|
|
305
|
+
entry: entry,
|
|
306
|
+
resolveFrom: resolveFrom,
|
|
307
|
+
node: node,
|
|
308
|
+
environment: {
|
|
309
|
+
'NOMOS_OFFLINE': '1',
|
|
310
|
+
'NOMOS_OFFLINE_RESTORE': snapshot,
|
|
311
|
+
if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
|
|
312
|
+
},
|
|
313
|
+
onDiagnostic: onDiagnostic,
|
|
314
|
+
));
|
|
315
|
+
final plane = NomosBridge(transport!);
|
|
316
|
+
await _phased(
|
|
317
|
+
phase: '3/4: runner ready',
|
|
318
|
+
awaiting: "event:'ready'",
|
|
319
|
+
timeout: phaseTimeout ?? readyTimeout,
|
|
320
|
+
onDiagnostic: onDiagnostic,
|
|
321
|
+
likelyCauses: const [
|
|
322
|
+
'node is not on PATH, or ~/.holon/runtime is cold (holon.wasm + packages.json)',
|
|
323
|
+
'the runner process failed to boot the engine — run with onDiagnostic to see its stderr',
|
|
324
|
+
],
|
|
325
|
+
run: () => plane.ready);
|
|
326
|
+
await _phased(
|
|
327
|
+
phase: '4/4: connect session',
|
|
328
|
+
workspace: workspace,
|
|
329
|
+
awaiting: 'connect',
|
|
330
|
+
timeout: phaseTimeout ?? connectTimeout,
|
|
331
|
+
onDiagnostic: onDiagnostic,
|
|
332
|
+
likelyCauses: const [
|
|
333
|
+
'a large ledger converge-fold can be slow — raise connectTimeout/phaseTimeout',
|
|
334
|
+
'run with onDiagnostic to see where the runner blocked',
|
|
335
|
+
],
|
|
336
|
+
run: () => plane.connect(
|
|
337
|
+
cloud: 'https://offline.invalid', // ignored: the offline runner serves everything locally
|
|
338
|
+
workspace: workspace,
|
|
339
|
+
clientId: clientId,
|
|
340
|
+
allowUnborn: true,
|
|
341
|
+
));
|
|
241
342
|
phase('ready — session "$workspace" connected (law ${(info['lawHashes'] as List?)?.isNotEmpty == true ? (info['lawHashes'] as List).first.toString().substring(0, 12) : "installed"})');
|
|
242
343
|
// THE DECOY SESSION (multi-workspace by default in tests): sole-session inference is how
|
|
243
344
|
// single-workspace assumptions ship — with one session connected, an op that forgot its session
|
|
@@ -247,14 +348,20 @@ class NomosTestHarness {
|
|
|
247
348
|
// against the harness keeps working unchanged; only code that grabs the raw plane and assumes
|
|
248
349
|
// single-session trips. Opt out with `decoySession: false` (please don't).
|
|
249
350
|
if (decoySession) {
|
|
250
|
-
await
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
351
|
+
await _phased(
|
|
352
|
+
phase: 'decoy: connect the decoy session',
|
|
353
|
+
workspace: _decoyWorkspace,
|
|
354
|
+
awaiting: 'connect',
|
|
355
|
+
timeout: phaseTimeout ?? connectTimeout,
|
|
356
|
+
onDiagnostic: onDiagnostic,
|
|
357
|
+
run: () async => plane.connect(
|
|
358
|
+
cloud: 'https://offline.invalid',
|
|
359
|
+
workspace: _decoyWorkspace,
|
|
360
|
+
session: _decoyWorkspace,
|
|
361
|
+
clientId: '$clientId-decoy',
|
|
362
|
+
allowUnborn: true,
|
|
363
|
+
restoreFrom: base64Encode(await File(snapshot).readAsBytes()),
|
|
364
|
+
));
|
|
258
365
|
}
|
|
259
366
|
final harness = NomosTestHarness._(
|
|
260
367
|
plane.session(workspace), workspace, transport, workdir, onDiagnostic, [workspace],
|
|
@@ -271,7 +378,8 @@ class NomosTestHarness {
|
|
|
271
378
|
inRepoScript: resolvedHelper,
|
|
272
379
|
githolonCmd: githolonCmd,
|
|
273
380
|
runtimeDir: runtimeDir,
|
|
274
|
-
clientId: clientId)
|
|
381
|
+
clientId: clientId),
|
|
382
|
+
phaseTimeout: phaseTimeout);
|
|
275
383
|
if (autoTearDown && registerTearDown != null) {
|
|
276
384
|
registerTearDown(harness.dispose);
|
|
277
385
|
}
|
|
@@ -317,6 +425,7 @@ class NomosTestHarness {
|
|
|
317
425
|
void Function(String line)? onDiagnostic,
|
|
318
426
|
Duration readyTimeout = const Duration(seconds: 30),
|
|
319
427
|
Duration connectTimeout = const Duration(seconds: 120),
|
|
428
|
+
Duration? phaseTimeout,
|
|
320
429
|
}) async {
|
|
321
430
|
if (workspaces.isEmpty) {
|
|
322
431
|
throw NomosTestHarnessException(
|
|
@@ -343,18 +452,29 @@ class NomosTestHarness {
|
|
|
343
452
|
final snapshots = <String, String>{};
|
|
344
453
|
final lawHashesByWs = <String, List<String>>{};
|
|
345
454
|
String? frameworkHash;
|
|
455
|
+
var _i = 0;
|
|
346
456
|
for (final e in workspaces.entries) {
|
|
347
457
|
final snap = '${workdir.path}/${e.key}.bin';
|
|
348
|
-
final info = await
|
|
349
|
-
|
|
350
|
-
useInRepoScript: useInRepoScript,
|
|
351
|
-
inRepoScript: resolvedHelper,
|
|
352
|
-
githolonCmd: githolonCmd,
|
|
353
|
-
deployJsonPaths: e.value,
|
|
458
|
+
final info = await _phased(
|
|
459
|
+
phase: '1: offline law install ${++_i}/${workspaces.length}',
|
|
354
460
|
workspace: e.key,
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
onDiagnostic: onDiagnostic
|
|
461
|
+
awaiting: useInRepoScript ? 'offline-install.mjs' : 'githolon harness-install',
|
|
462
|
+
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
463
|
+
onDiagnostic: onDiagnostic,
|
|
464
|
+
likelyCauses: const [
|
|
465
|
+
'a cold npx download of githolon (pre-warm with any `githolon` run)',
|
|
466
|
+
'a very large law package folding through the offline gate — raise phaseTimeout',
|
|
467
|
+
],
|
|
468
|
+
run: () => _runInstall(
|
|
469
|
+
node: node,
|
|
470
|
+
useInRepoScript: useInRepoScript,
|
|
471
|
+
inRepoScript: resolvedHelper,
|
|
472
|
+
githolonCmd: githolonCmd,
|
|
473
|
+
deployJsonPaths: e.value,
|
|
474
|
+
workspace: e.key,
|
|
475
|
+
snapshot: snap,
|
|
476
|
+
runtimeDir: runtimeDir,
|
|
477
|
+
onDiagnostic: onDiagnostic));
|
|
358
478
|
snapshots[e.key] = snap;
|
|
359
479
|
lawHashesByWs[e.key] = ((info['lawHashes'] as List?) ?? const [])
|
|
360
480
|
.map((x) => x.toString())
|
|
@@ -364,23 +484,32 @@ class NomosTestHarness {
|
|
|
364
484
|
|
|
365
485
|
// 2. ONE runner / ONE bridge for the whole plane — no env restore default; each connect brings its own.
|
|
366
486
|
final phase = (String m) { if (onDiagnostic != null) onDiagnostic('[nomos-test-harness] $m'); };
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
487
|
+
transport = await _phased(
|
|
488
|
+
phase: '2: spawn the node runner (${workspaces.length} workspace(s))',
|
|
489
|
+
awaiting: 'Process.start($node)',
|
|
490
|
+
timeout: phaseTimeout ?? _kSpawnDeadline,
|
|
491
|
+
onDiagnostic: onDiagnostic,
|
|
492
|
+
run: () => NodeRunnerTransport.start(
|
|
493
|
+
entry: entry,
|
|
494
|
+
resolveFrom: resolveFrom,
|
|
495
|
+
node: node,
|
|
496
|
+
environment: {
|
|
497
|
+
'NOMOS_OFFLINE': '1',
|
|
498
|
+
if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
|
|
499
|
+
},
|
|
500
|
+
onDiagnostic: onDiagnostic,
|
|
501
|
+
));
|
|
502
|
+
final bridge = NomosBridge(transport!);
|
|
503
|
+
await _phased(
|
|
504
|
+
phase: '2: runner ready',
|
|
505
|
+
awaiting: "event:'ready'",
|
|
506
|
+
timeout: phaseTimeout ?? readyTimeout,
|
|
507
|
+
onDiagnostic: onDiagnostic,
|
|
508
|
+
likelyCauses: const [
|
|
509
|
+
'node is not on PATH, or ~/.holon/runtime is cold (holon.wasm + packages.json)',
|
|
510
|
+
'the runner process failed to boot the engine — run with onDiagnostic to see its stderr',
|
|
511
|
+
],
|
|
512
|
+
run: () => bridge.ready);
|
|
384
513
|
|
|
385
514
|
// 3. connect one SESSION per workspace, each restoring its OWN snapshot — LOCAL-ONLY (no cloud;
|
|
386
515
|
// these are local githolons on the device plane, offline by nature).
|
|
@@ -388,35 +517,67 @@ class NomosTestHarness {
|
|
|
388
517
|
var _n = 0;
|
|
389
518
|
for (final e in workspaces.entries) {
|
|
390
519
|
snapshotB64[e.key] = base64Encode(await File(snapshots[e.key]!).readAsBytes());
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
520
|
+
await _phased(
|
|
521
|
+
phase: '3: connect session ${++_n}/${workspaces.length}',
|
|
522
|
+
workspace: e.key,
|
|
523
|
+
awaiting: 'connect',
|
|
524
|
+
timeout: phaseTimeout ?? connectTimeout,
|
|
525
|
+
onDiagnostic: onDiagnostic,
|
|
526
|
+
likelyCauses: const [
|
|
527
|
+
'a large ledger converge-fold can be slow — raise connectTimeout/phaseTimeout',
|
|
528
|
+
'run with onDiagnostic to see where the runner blocked',
|
|
529
|
+
],
|
|
530
|
+
run: () => bridge.connect(
|
|
531
|
+
workspace: e.key,
|
|
532
|
+
session: e.key,
|
|
533
|
+
clientId: '$clientId-${e.key}',
|
|
534
|
+
restoreFrom: snapshotB64[e.key],
|
|
535
|
+
allowUnborn: true,
|
|
536
|
+
));
|
|
403
537
|
}
|
|
404
538
|
phase('cluster: ${workspaces.length} session(s) connected');
|
|
405
539
|
|
|
406
540
|
// 3b. DEVICE-KEY the named sessions: mint a fresh in-kernel key on each (now that it is mounted) and
|
|
407
541
|
// RE-OPEN it with that key as its signing authorSecret — the resolver's attested-read signing key.
|
|
542
|
+
// The re-open is the runner's ATOMIC session replace (dispose-then-connect under the hood); each
|
|
543
|
+
// leg runs as a named, deadlined phase so a wedge is a typed HarnessPhaseTimeout, never a hang.
|
|
408
544
|
final devicePublics = <String, String>{};
|
|
409
545
|
for (final ws in deviceKeyed) {
|
|
410
|
-
|
|
546
|
+
if (!workspaces.containsKey(ws)) {
|
|
547
|
+
throw NomosTestHarnessException(
|
|
548
|
+
'deviceKeyed names "$ws" but it is not a workspace of this cluster — '
|
|
549
|
+
'workspaces: ${workspaces.keys.toList()}');
|
|
550
|
+
}
|
|
551
|
+
final kp = await _phased(
|
|
552
|
+
phase: '3b: device keygen',
|
|
553
|
+
workspace: ws,
|
|
554
|
+
awaiting: 'authorKeygen',
|
|
555
|
+
timeout: phaseTimeout ?? _kOtherDeadline,
|
|
556
|
+
onDiagnostic: onDiagnostic,
|
|
557
|
+
run: () => bridge.session(ws).authorKeygen());
|
|
411
558
|
devicePublics[ws] = (kp['public'] ?? '').toString();
|
|
412
|
-
await
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
559
|
+
await _phased(
|
|
560
|
+
phase: "3b: keyed reconnect of '$ws'",
|
|
561
|
+
workspace: ws,
|
|
562
|
+
awaiting: 'connect',
|
|
563
|
+
timeout: phaseTimeout ?? connectTimeout,
|
|
564
|
+
onDiagnostic: onDiagnostic,
|
|
565
|
+
likelyCauses: const [
|
|
566
|
+
'the runner never answered the re-connect of an already-open session — '
|
|
567
|
+
'update @githolon/client (the session re-open is an atomic replace)',
|
|
568
|
+
'a large ledger re-fold on the re-mount — raise connectTimeout/phaseTimeout',
|
|
569
|
+
],
|
|
570
|
+
run: () => bridge.connect(
|
|
571
|
+
workspace: ws,
|
|
572
|
+
session: ws,
|
|
573
|
+
clientId: '$clientId-$ws',
|
|
574
|
+
restoreFrom: snapshotB64[ws],
|
|
575
|
+
authorSecret: (kp['secret'] ?? '').toString(),
|
|
576
|
+
allowUnborn: true,
|
|
577
|
+
));
|
|
578
|
+
}
|
|
579
|
+
if (deviceKeyed.isNotEmpty) {
|
|
580
|
+
phase('cluster: ${deviceKeyed.length} device-keyed session(s) re-opened');
|
|
420
581
|
}
|
|
421
582
|
|
|
422
583
|
final names = workspaces.keys.toList();
|
|
@@ -432,7 +593,8 @@ class NomosTestHarness {
|
|
|
432
593
|
inRepoScript: resolvedHelper,
|
|
433
594
|
githolonCmd: githolonCmd,
|
|
434
595
|
runtimeDir: runtimeDir,
|
|
435
|
-
clientId: clientId)
|
|
596
|
+
clientId: clientId),
|
|
597
|
+
phaseTimeout: phaseTimeout);
|
|
436
598
|
if (autoTearDown && registerTearDown != null) {
|
|
437
599
|
registerTearDown(harness.dispose);
|
|
438
600
|
}
|
|
@@ -469,13 +631,19 @@ class NomosTestHarness {
|
|
|
469
631
|
'resetWorkspaceCustody("$workspace"): no seed snapshot held for this workspace — '
|
|
470
632
|
'seeded workspaces: ${_snapshots.keys.toList()}');
|
|
471
633
|
}
|
|
472
|
-
await
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
634
|
+
await _phased(
|
|
635
|
+
phase: 'resetWorkspaceCustody: reconnect from seed snapshot',
|
|
636
|
+
workspace: workspace,
|
|
637
|
+
awaiting: 'connect',
|
|
638
|
+
timeout: _phaseTimeout ?? const Duration(seconds: 60),
|
|
639
|
+
onDiagnostic: _onDiagnostic,
|
|
640
|
+
run: () => _plane.connect(
|
|
641
|
+
workspace: workspace,
|
|
642
|
+
session: workspace,
|
|
643
|
+
clientId: '${_installLane?.clientId ?? 'test-runner'}-$workspace',
|
|
644
|
+
restoreFrom: snap,
|
|
645
|
+
allowUnborn: true,
|
|
646
|
+
));
|
|
479
647
|
}
|
|
480
648
|
|
|
481
649
|
/// SEED a NEW workspace on this plane from `build/<name>.deploy.json` law packages — the
|
|
@@ -495,28 +663,40 @@ class NomosTestHarness {
|
|
|
495
663
|
'seedWorkspaceFromDeployJson("$workspace"): pass the build/<name>.deploy.json paths');
|
|
496
664
|
}
|
|
497
665
|
final snap = '${_workdir.path}/$workspace.bin';
|
|
498
|
-
final info = await
|
|
499
|
-
|
|
500
|
-
useInRepoScript: lane.useInRepoScript,
|
|
501
|
-
inRepoScript: lane.inRepoScript,
|
|
502
|
-
githolonCmd: lane.githolonCmd,
|
|
503
|
-
deployJsonPaths: deployJsonPaths,
|
|
666
|
+
final info = await _phased(
|
|
667
|
+
phase: 'seed: offline law install',
|
|
504
668
|
workspace: workspace,
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
onDiagnostic: _onDiagnostic
|
|
669
|
+
awaiting: lane.useInRepoScript ? 'offline-install.mjs' : 'githolon harness-install',
|
|
670
|
+
timeout: _phaseTimeout ?? _kInstallDeadline,
|
|
671
|
+
onDiagnostic: _onDiagnostic,
|
|
672
|
+
run: () => _runInstall(
|
|
673
|
+
node: lane.node,
|
|
674
|
+
useInRepoScript: lane.useInRepoScript,
|
|
675
|
+
inRepoScript: lane.inRepoScript,
|
|
676
|
+
githolonCmd: lane.githolonCmd,
|
|
677
|
+
deployJsonPaths: deployJsonPaths,
|
|
678
|
+
workspace: workspace,
|
|
679
|
+
snapshot: snap,
|
|
680
|
+
runtimeDir: lane.runtimeDir,
|
|
681
|
+
onDiagnostic: _onDiagnostic));
|
|
508
682
|
final b64 = base64Encode(await File(snap).readAsBytes());
|
|
509
683
|
_snapshots[workspace] = b64;
|
|
510
684
|
_lawHashes[workspace] = ((info['lawHashes'] as List?) ?? const [])
|
|
511
685
|
.map((e) => e.toString())
|
|
512
686
|
.toList();
|
|
513
|
-
await
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
687
|
+
await _phased(
|
|
688
|
+
phase: 'seed: connect session',
|
|
689
|
+
workspace: workspace,
|
|
690
|
+
awaiting: 'connect',
|
|
691
|
+
timeout: _phaseTimeout ?? const Duration(seconds: 60),
|
|
692
|
+
onDiagnostic: _onDiagnostic,
|
|
693
|
+
run: () => _plane.connect(
|
|
694
|
+
workspace: workspace,
|
|
695
|
+
session: workspace,
|
|
696
|
+
clientId: '${lane.clientId}-$workspace',
|
|
697
|
+
restoreFrom: b64,
|
|
698
|
+
allowUnborn: true,
|
|
699
|
+
));
|
|
520
700
|
if (!_sessionNames.contains(workspace)) _sessionNames.add(workspace);
|
|
521
701
|
return _plane.session(workspace);
|
|
522
702
|
}
|
|
@@ -527,7 +707,13 @@ class NomosTestHarness {
|
|
|
527
707
|
/// `harness.session(ws).assertCompatible(XClient.interface)`.)
|
|
528
708
|
Future<void> assertDomainInstalled(
|
|
529
709
|
{required String workspace, required String domain}) async {
|
|
530
|
-
final offer = await
|
|
710
|
+
final offer = await _phased(
|
|
711
|
+
phase: 'assertDomainInstalled: read the interface offer',
|
|
712
|
+
workspace: workspace,
|
|
713
|
+
awaiting: 'interfaceOffer',
|
|
714
|
+
timeout: _phaseTimeout ?? _kOtherDeadline,
|
|
715
|
+
onDiagnostic: _onDiagnostic,
|
|
716
|
+
run: () => _plane.session(workspace).interfaceOffer());
|
|
531
717
|
final keys = (offer?.keys.toList() ?? const <String>[])..sort();
|
|
532
718
|
if (!keys.contains(domain)) {
|
|
533
719
|
throw NomosTestHarnessException(
|
|
@@ -539,11 +725,21 @@ class NomosTestHarness {
|
|
|
539
725
|
/// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
|
|
540
726
|
/// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
|
|
541
727
|
/// failed setup. Nothing left running, nothing left on disk.
|
|
728
|
+
/// Disposal must ALWAYS complete (never leave a hung teardown holding the test runner): the transport
|
|
729
|
+
/// dispose is deadlined too, and a breach is LOGGED (not thrown — throwing from teardown would mask the
|
|
730
|
+
/// test's own outcome) before the temp dir is still deleted.
|
|
542
731
|
Future<void> dispose() async {
|
|
543
732
|
if (_disposed) return;
|
|
544
733
|
_disposed = true;
|
|
734
|
+
final f = _onDiagnostic;
|
|
735
|
+
if (f != null) f('[nomos-test-harness] phase dispose (≤${(_phaseTimeout ?? _kOtherDeadline).inSeconds}s) …');
|
|
545
736
|
try {
|
|
546
|
-
await _transport.dispose()
|
|
737
|
+
await _transport.dispose().timeout(_phaseTimeout ?? _kOtherDeadline,
|
|
738
|
+
onTimeout: () => throw HarnessPhaseTimeout(
|
|
739
|
+
phase: 'dispose: stop the node runner',
|
|
740
|
+
workspace: workspace,
|
|
741
|
+
elapsed: _phaseTimeout ?? _kOtherDeadline,
|
|
742
|
+
awaiting: 'NodeRunnerTransport.dispose'));
|
|
547
743
|
} catch (e) {
|
|
548
744
|
_diag('[nomos-test-harness] transport dispose error (continuing to temp-dir cleanup): $e');
|
|
549
745
|
}
|
|
@@ -552,6 +748,7 @@ class NomosTestHarness {
|
|
|
552
748
|
} catch (e) {
|
|
553
749
|
_diag('[nomos-test-harness] temp-dir cleanup error: $e');
|
|
554
750
|
}
|
|
751
|
+
if (f != null) f('[nomos-test-harness] phase dispose done');
|
|
555
752
|
}
|
|
556
753
|
|
|
557
754
|
void _diag(String line) {
|
|
@@ -661,6 +661,34 @@ class NomosOfferOutcome {
|
|
|
661
661
|
.toList();
|
|
662
662
|
}
|
|
663
663
|
|
|
664
|
+
/// What a GENUINE cloud open needs — returned by an [NomosBridge.acquireSession] `ifAbsentCredentials`
|
|
665
|
+
/// callback, invoked ONLY when the workspace is nowhere on the local plane (never for a local-only or
|
|
666
|
+
/// already-mounted workspace). Mirrors the cloud-shaped subset of [NomosBridge.connect]'s parameters.
|
|
667
|
+
class NomosSessionCredentials {
|
|
668
|
+
/// The cloud base URL the workspace syncs from (e.g. `https://nomos.captainapp.co.uk`).
|
|
669
|
+
final String cloud;
|
|
670
|
+
|
|
671
|
+
/// The verified principal's access token (`x-nomos-auth`), when the workspace requires one.
|
|
672
|
+
final String? authToken;
|
|
673
|
+
|
|
674
|
+
/// The client id for this device's replica (defaults per the runner when omitted).
|
|
675
|
+
final String? clientId;
|
|
676
|
+
|
|
677
|
+
/// The device signing secret (base64, from `authorKeygen`) — the warrant default for every offer.
|
|
678
|
+
final String? authorSecret;
|
|
679
|
+
|
|
680
|
+
/// Open even when the workspace has no remote main yet (an unborn workspace reads empty).
|
|
681
|
+
final bool allowUnborn;
|
|
682
|
+
|
|
683
|
+
const NomosSessionCredentials({
|
|
684
|
+
required this.cloud,
|
|
685
|
+
this.authToken,
|
|
686
|
+
this.clientId,
|
|
687
|
+
this.authorSecret,
|
|
688
|
+
this.allowUnborn = false,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
664
692
|
/// THE BRIDGE. Construct over a [BridgeTransport], `await ready`, then `connect(...)` and drive the holon.
|
|
665
693
|
class NomosBridge {
|
|
666
694
|
final BridgeTransport _transport;
|
|
@@ -708,6 +736,9 @@ class NomosBridge {
|
|
|
708
736
|
// caches the per-workspace [NomosSession] handle so `bridge.session(ws)` is stable + cheap.
|
|
709
737
|
final Set<String> _sessionNames = {};
|
|
710
738
|
final Map<String, NomosSession> _sessionViews = {};
|
|
739
|
+
// [acquireSession]'s per-workspace single-flight: workspace → the in-flight open every concurrent
|
|
740
|
+
// acquirer joins (removed when it completes; a live session then hits the fast path instead).
|
|
741
|
+
final Map<String, Future<NomosSession>> _acquiring = {};
|
|
711
742
|
int _seq = 0;
|
|
712
743
|
final String _traceId = _newTraceId();
|
|
713
744
|
late final StreamSubscription<Map<String, Object?>> _sub;
|
|
@@ -1205,6 +1236,60 @@ class NomosBridge {
|
|
|
1205
1236
|
return session(ws);
|
|
1206
1237
|
}
|
|
1207
1238
|
|
|
1239
|
+
/// ACQUIRE A SESSION ATOMICALLY — the plug-and-play multi-workspace open (co2 DX ask). Semantics:
|
|
1240
|
+
/// • an already-connected session is returned as-is (no work, no credentials);
|
|
1241
|
+
/// • a workspace the local plane already holds (mounted by another path, LRU-parked, or with resident
|
|
1242
|
+
/// custody — e.g. a locally-born child) is opened LOCAL-ONLY, in place (no cloud, no credentials);
|
|
1243
|
+
/// • two concurrent [acquireSession] calls for the same workspace SINGLE-FLIGHT: the second joins the
|
|
1244
|
+
/// first's in-flight open and both yield the SAME [NomosSession] — one connect, never two;
|
|
1245
|
+
/// • ONLY when a genuine cloud open is required (the workspace is nowhere on this device) is
|
|
1246
|
+
/// [ifAbsentCredentials] invoked — exactly once — and the connect made with what it returns.
|
|
1247
|
+
/// Omitting [ifAbsentCredentials] makes a genuinely-absent workspace a named [StateError] instead of a
|
|
1248
|
+
/// silent cloudless mount.
|
|
1249
|
+
Future<NomosSession> acquireSession(String workspace,
|
|
1250
|
+
{Future<NomosSessionCredentials> Function()? ifAbsentCredentials}) {
|
|
1251
|
+
// Fast path: a live session needs no runner round-trip (and never asks for credentials).
|
|
1252
|
+
if (_sessionNames.contains(workspace)) return Future.value(session(workspace));
|
|
1253
|
+
// SINGLE-FLIGHT: one in-flight open per workspace per plane; concurrent callers join it.
|
|
1254
|
+
return _acquiring.putIfAbsent(workspace, () async {
|
|
1255
|
+
try {
|
|
1256
|
+
if (_sessionNames.contains(workspace)) return session(workspace);
|
|
1257
|
+
// Consult the plane's TRUTHFUL registry (mounted ∪ parked ∪ resident custody — the runner's
|
|
1258
|
+
// realm.list()): anything the device already holds opens locally, no cloud, no credentials.
|
|
1259
|
+
var resident = false;
|
|
1260
|
+
try {
|
|
1261
|
+
resident = (await listWorkspaces())
|
|
1262
|
+
.any((w) => w['workspace']?.toString() == workspace);
|
|
1263
|
+
} catch (_) {
|
|
1264
|
+
// a pre-connect plane has no registry yet — fall through to the credentials decision below.
|
|
1265
|
+
}
|
|
1266
|
+
if (resident) {
|
|
1267
|
+
await connect(workspace: workspace, session: workspace, allowUnborn: true);
|
|
1268
|
+
return session(workspace);
|
|
1269
|
+
}
|
|
1270
|
+
if (ifAbsentCredentials == null) {
|
|
1271
|
+
throw StateError(
|
|
1272
|
+
'acquireSession("$workspace"): the workspace is not on this device\'s plane and no '
|
|
1273
|
+
'ifAbsentCredentials was given — a cloud open is genuinely required');
|
|
1274
|
+
}
|
|
1275
|
+
final c = await ifAbsentCredentials();
|
|
1276
|
+
await connect(
|
|
1277
|
+
cloud: c.cloud,
|
|
1278
|
+
workspace: workspace,
|
|
1279
|
+
session: workspace,
|
|
1280
|
+
clientId: c.clientId,
|
|
1281
|
+
authToken: c.authToken,
|
|
1282
|
+
authorSecret: c.authorSecret,
|
|
1283
|
+
allowUnborn: c.allowUnborn,
|
|
1284
|
+
);
|
|
1285
|
+
return session(workspace);
|
|
1286
|
+
} finally {
|
|
1287
|
+
// completed (either way) — the next acquire re-evaluates fresh (a live session hits the fast path).
|
|
1288
|
+
_acquiring.remove(workspace);
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1208
1293
|
/// Reserve a KERNEL-MINTED typed id (`<TypeTag>_<uuidv7>`) via the holon's front-door mint — for app-side
|
|
1209
1294
|
/// references (routing keys, optimistic UI). `.creates` payloads still carry NO id: the LAW mints those
|
|
1210
1295
|
/// (see [offerCreates], which returns them).
|
|
@@ -1947,6 +2032,13 @@ class NomosSession extends NomosBridge {
|
|
|
1947
2032
|
@override
|
|
1948
2033
|
NomosSession session(String workspace) => parent.session(workspace);
|
|
1949
2034
|
|
|
2035
|
+
/// An acquired session mounts on the SHARED plane — delegate to the parent (the plane owns the
|
|
2036
|
+
/// single-flight registry; a session never owns mounts).
|
|
2037
|
+
@override
|
|
2038
|
+
Future<NomosSession> acquireSession(String workspace,
|
|
2039
|
+
{Future<NomosSessionCredentials> Function()? ifAbsentCredentials}) =>
|
|
2040
|
+
parent.acquireSession(workspace, ifAbsentCredentials: ifAbsentCredentials);
|
|
2041
|
+
|
|
1950
2042
|
/// A followed ref mounts on the SHARED plane — delegate to the parent (a session never owns mounts).
|
|
1951
2043
|
@override
|
|
1952
2044
|
Future<NomosSession> follow(NomosRef ref,
|
package/dart/pubspec.yaml
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
|
|
12
12
|
code-generated per-domain aggregate + directive-payload types. (De)serializes
|
|
13
13
|
the EXACT serde JSON the Rust kernel + projection produce/consume.
|
|
14
|
-
version: 0.
|
|
14
|
+
version: 0.50.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// acquireSession — the ATOMIC session-open (co2 DX ask): reuses a mounted session, opens a plane-resident
|
|
2
|
+
// (parked / resident-custody) workspace locally, JOINS an in-flight open (single-flight per workspace), and
|
|
3
|
+
// invokes the credentials callback ONLY when a cloud open is genuinely required. Proven hermetically over a
|
|
4
|
+
// fake transport (the same style as webview_bridge_test.dart) — the live-runner leg rides
|
|
5
|
+
// cluster_local_autosync_test.dart.
|
|
6
|
+
import 'dart:async';
|
|
7
|
+
|
|
8
|
+
import 'package:nomos_client/nomos_client.dart';
|
|
9
|
+
import 'package:test/test.dart';
|
|
10
|
+
|
|
11
|
+
/// A fake runner: answers connect (after a real async gap, so concurrency is observable) and
|
|
12
|
+
/// listWorkspaces (from [resident]); records every request.
|
|
13
|
+
class FakeRunner implements BridgeTransport {
|
|
14
|
+
final _incoming = StreamController<Map<String, Object?>>.broadcast();
|
|
15
|
+
final List<Map<String, Object?>> sent = [];
|
|
16
|
+
final List<String> resident; // workspaces the plane already holds (realm.list())
|
|
17
|
+
|
|
18
|
+
FakeRunner({this.resident = const []}) {
|
|
19
|
+
scheduleMicrotask(
|
|
20
|
+
() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
int get connectCount => sent.where((m) => m['op'] == 'connect').length;
|
|
24
|
+
Map<String, Object?> connectArgs(int i) =>
|
|
25
|
+
(sent.where((m) => m['op'] == 'connect').elementAt(i)['args'] as Map)
|
|
26
|
+
.cast<String, Object?>();
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
void send(Map<String, Object?> message) {
|
|
33
|
+
sent.add(message);
|
|
34
|
+
final id = message['id'] as int;
|
|
35
|
+
final op = message['op'] as String;
|
|
36
|
+
final args = (message['args'] as Map).cast<String, Object?>();
|
|
37
|
+
Future<void>.delayed(const Duration(milliseconds: 5), () {
|
|
38
|
+
Object? result;
|
|
39
|
+
switch (op) {
|
|
40
|
+
case 'connect':
|
|
41
|
+
result = {'workspace': args['workspace']};
|
|
42
|
+
break;
|
|
43
|
+
case 'listWorkspaces':
|
|
44
|
+
result = {
|
|
45
|
+
'workspaces': [
|
|
46
|
+
for (final ws in resident)
|
|
47
|
+
{
|
|
48
|
+
'session': ws,
|
|
49
|
+
'workspace': ws,
|
|
50
|
+
'mounted': false,
|
|
51
|
+
'parked': true,
|
|
52
|
+
'syncPosture': 'local-only'
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
};
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
result = {};
|
|
59
|
+
}
|
|
60
|
+
_incoming
|
|
61
|
+
.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': true, 'result': result});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
void main() {
|
|
67
|
+
test('an already-connected session is reused — credentials NEVER invoked, no second connect', () async {
|
|
68
|
+
final runner = FakeRunner();
|
|
69
|
+
final bridge = NomosBridge(runner);
|
|
70
|
+
await bridge.ready;
|
|
71
|
+
await bridge.connect(workspace: 'home', session: 'home');
|
|
72
|
+
expect(runner.connectCount, 1);
|
|
73
|
+
|
|
74
|
+
final s = await bridge.acquireSession('home',
|
|
75
|
+
ifAbsentCredentials: () async =>
|
|
76
|
+
fail('credentials must not be requested for a mounted session'));
|
|
77
|
+
expect(identical(s, bridge.session('home')), isTrue);
|
|
78
|
+
expect(runner.connectCount, 1, reason: 'no re-connect for a live session');
|
|
79
|
+
await bridge.dispose();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a plane-resident (parked) workspace opens LOCALLY — no cloud, credentials NOT invoked', () async {
|
|
83
|
+
final runner = FakeRunner(resident: ['estate']);
|
|
84
|
+
final bridge = NomosBridge(runner);
|
|
85
|
+
await bridge.ready;
|
|
86
|
+
|
|
87
|
+
var asked = false;
|
|
88
|
+
final s = await bridge.acquireSession('estate', ifAbsentCredentials: () async {
|
|
89
|
+
asked = true;
|
|
90
|
+
return const NomosSessionCredentials(cloud: 'https://never.invalid');
|
|
91
|
+
});
|
|
92
|
+
expect(asked, isFalse, reason: 'a resident workspace never asks for credentials');
|
|
93
|
+
expect(s.workspace, 'estate');
|
|
94
|
+
expect(runner.connectCount, 1);
|
|
95
|
+
expect(runner.connectArgs(0).containsKey('cloud'), isFalse,
|
|
96
|
+
reason: 'a resident open is local-only — no cloud in the connect');
|
|
97
|
+
await bridge.dispose();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('concurrent acquisition SINGLE-FLIGHTS: one open, one credentials call, the same session', () async {
|
|
101
|
+
final runner = FakeRunner();
|
|
102
|
+
final bridge = NomosBridge(runner);
|
|
103
|
+
await bridge.ready;
|
|
104
|
+
|
|
105
|
+
var credentialCalls = 0;
|
|
106
|
+
Future<NomosSessionCredentials> creds() async {
|
|
107
|
+
credentialCalls++;
|
|
108
|
+
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
109
|
+
return const NomosSessionCredentials(
|
|
110
|
+
cloud: 'https://cloud.example', authToken: 'tok', allowUnborn: true);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
final results = await Future.wait([
|
|
114
|
+
bridge.acquireSession('cloudy', ifAbsentCredentials: creds),
|
|
115
|
+
bridge.acquireSession('cloudy', ifAbsentCredentials: creds),
|
|
116
|
+
bridge.acquireSession('cloudy', ifAbsentCredentials: creds),
|
|
117
|
+
]);
|
|
118
|
+
expect(credentialCalls, 1, reason: 'the credentials callback fires exactly once');
|
|
119
|
+
expect(runner.connectCount, 1, reason: 'ONE connect for three concurrent acquires');
|
|
120
|
+
expect(identical(results[0], results[1]), isTrue);
|
|
121
|
+
expect(identical(results[1], results[2]), isTrue);
|
|
122
|
+
final args = runner.connectArgs(0);
|
|
123
|
+
expect(args['cloud'], 'https://cloud.example');
|
|
124
|
+
expect(args['authToken'], 'tok');
|
|
125
|
+
expect(args['allowUnborn'], isTrue);
|
|
126
|
+
await bridge.dispose();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('genuinely absent + no credentials callback → a named StateError (never a silent cloudless mount)',
|
|
130
|
+
() async {
|
|
131
|
+
final runner = FakeRunner();
|
|
132
|
+
final bridge = NomosBridge(runner);
|
|
133
|
+
await bridge.ready;
|
|
134
|
+
await expectLater(bridge.acquireSession('nowhere'), throwsStateError);
|
|
135
|
+
expect(runner.connectCount, 0);
|
|
136
|
+
await bridge.dispose();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a NomosSession delegates acquireSession to the parent plane (one registry)', () async {
|
|
140
|
+
final runner = FakeRunner();
|
|
141
|
+
final bridge = NomosBridge(runner);
|
|
142
|
+
await bridge.ready;
|
|
143
|
+
await bridge.connect(workspace: 'home', session: 'home');
|
|
144
|
+
await bridge.connect(workspace: 'estate', session: 'estate');
|
|
145
|
+
|
|
146
|
+
final viaSession = await bridge.session('home').acquireSession('estate',
|
|
147
|
+
ifAbsentCredentials: () async =>
|
|
148
|
+
fail('estate is already mounted — no credentials'));
|
|
149
|
+
expect(identical(viaSession, bridge.session('estate')), isTrue);
|
|
150
|
+
await bridge.dispose();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// CO2'S ACCEPTANCE (verbatim, 2026-07): `NomosTestHarness.cluster(deviceKeyed: …)` re-opens the keyed
|
|
2
|
+
// session (keygen → keyed reconnect) and MUST NEVER hang — the reported wedge was phase 3b, the second
|
|
3
|
+
// `connect` on an already-open session. Acceptance:
|
|
4
|
+
// 1. cluster(..., deviceKeyed: {workspace}) completes (co2's exact shape: 4 sessions, one
|
|
5
|
+
// deviceKeyed, colon-namespaced workspace names)
|
|
6
|
+
// 2. devicePublicKey(workspace) is populated
|
|
7
|
+
// 3. a colocated attested read succeeds (served + SIGNED with the device key the re-open installed)
|
|
8
|
+
// 4. harness disposal completes without being killed
|
|
9
|
+
// Phase diagnostics (their ask 5) are asserted too: 3b logs enter/exit for keygen + keyed reconnect.
|
|
10
|
+
@TestOn('vm')
|
|
11
|
+
@Timeout(Duration(minutes: 3))
|
|
12
|
+
library;
|
|
13
|
+
|
|
14
|
+
import 'dart:io';
|
|
15
|
+
|
|
16
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
17
|
+
import 'package:test/test.dart';
|
|
18
|
+
|
|
19
|
+
String get _root => '${Directory.current.path}/../..';
|
|
20
|
+
String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
|
|
21
|
+
String get _catDeploy => '$_root/bench/scale/fixtures/catalogue-src/build/cat.deploy.json';
|
|
22
|
+
String get _estateDeploy => '$_root/bench/scale/fixtures/catalogue-reader/build/estate.deploy.json';
|
|
23
|
+
String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
|
|
24
|
+
|
|
25
|
+
String? _missingPrereq() {
|
|
26
|
+
if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
|
|
27
|
+
for (final p in [_catDeploy, _estateDeploy]) {
|
|
28
|
+
if (!File(p).existsSync()) return 'missing fixture $p (compile bench/scale/fixtures first)';
|
|
29
|
+
}
|
|
30
|
+
if (!File('$_runtimeDir/holon.wasm').existsSync() ||
|
|
31
|
+
!File('$_runtimeDir/packages.json').existsSync()) {
|
|
32
|
+
return 'warm ~/.holon/runtime first (run any `githolon` command once)';
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
void main() {
|
|
38
|
+
test('cluster(deviceKeyed) completes: keyed re-open, device key populated, colocated attested read, disposal',
|
|
39
|
+
() async {
|
|
40
|
+
final skip = _missingPrereq();
|
|
41
|
+
if (skip != null) {
|
|
42
|
+
markTestSkipped(skip);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const keyed = 'catalogue:test-catalogue';
|
|
47
|
+
final log = <String>[];
|
|
48
|
+
// 1. cluster construction with a deviceKeyed workspace COMPLETES (the reported hang was here).
|
|
49
|
+
final harness = await NomosTestHarness.cluster(
|
|
50
|
+
workspaces: {
|
|
51
|
+
keyed: [_catDeploy],
|
|
52
|
+
'estate:a': [_estateDeploy],
|
|
53
|
+
'estate:b': [_estateDeploy],
|
|
54
|
+
'estate:c': [_estateDeploy],
|
|
55
|
+
},
|
|
56
|
+
deviceKeyed: {keyed},
|
|
57
|
+
nodeEntry: _repoEntry,
|
|
58
|
+
runtimeDir: _runtimeDir,
|
|
59
|
+
onDiagnostic: log.add,
|
|
60
|
+
);
|
|
61
|
+
try {
|
|
62
|
+
expect(harness.sessions.toSet(),
|
|
63
|
+
{keyed, 'estate:a', 'estate:b', 'estate:c'});
|
|
64
|
+
|
|
65
|
+
// 2. devicePublicKey is populated for the keyed session.
|
|
66
|
+
final public = harness.devicePublicKey(keyed);
|
|
67
|
+
expect(public, isNotNull);
|
|
68
|
+
expect(public, isNotEmpty,
|
|
69
|
+
reason: 'the 3b keygen public half must be recorded on the harness');
|
|
70
|
+
|
|
71
|
+
// ask 5 — phase diagnostics: 3b logs enter AND exit for keygen + keyed reconnect.
|
|
72
|
+
expect(log.any((l) => l.contains('3b: device keygen')), isTrue,
|
|
73
|
+
reason: 'phase 3b keygen must log');
|
|
74
|
+
expect(log.any((l) => l.contains("3b: keyed reconnect of '$keyed'") && l.contains('…')),
|
|
75
|
+
isTrue, reason: 'phase 3b reconnect must log its enter line');
|
|
76
|
+
expect(log.any((l) => l.contains("3b: keyed reconnect of '$keyed'") && l.contains('done')),
|
|
77
|
+
isTrue, reason: 'phase 3b reconnect must log its exit line');
|
|
78
|
+
|
|
79
|
+
// 3. a COLOCATED ATTESTED READ succeeds, signed with the device key the re-open installed
|
|
80
|
+
// (no attestorSecret injected — the source session signs with its connect authorSecret).
|
|
81
|
+
final cat = harness.session(keyed);
|
|
82
|
+
final seeded = await cat.offerCreates(
|
|
83
|
+
domain: 'cat',
|
|
84
|
+
directiveId: 'listCatalogue',
|
|
85
|
+
payload: {
|
|
86
|
+
'listingId': 'L-1',
|
|
87
|
+
'title': 'Walnut desk',
|
|
88
|
+
'priceGbp': '900',
|
|
89
|
+
'listedAt': '2026-07-11T00:00:00Z',
|
|
90
|
+
},
|
|
91
|
+
actor: 'user:seller',
|
|
92
|
+
);
|
|
93
|
+
expect(seeded.head, isNotEmpty);
|
|
94
|
+
final served = await harness.session('estate:a').attestedRead(
|
|
95
|
+
from: keyed,
|
|
96
|
+
queryId: 'listingById',
|
|
97
|
+
args: {'listingId': 'L-1'},
|
|
98
|
+
);
|
|
99
|
+
final entry = (served['entry'] as Map).cast<String, Object?>();
|
|
100
|
+
final attestation = (entry['attestation'] as Map).cast<String, Object?>();
|
|
101
|
+
expect((attestation['public_key'] ?? '').toString(), public,
|
|
102
|
+
reason: 'the colocated read is signed by the re-opened session device key');
|
|
103
|
+
} finally {
|
|
104
|
+
// 4. disposal completes (the harness deadline guarantees it can never wedge the test runner).
|
|
105
|
+
await harness.dispose();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// CO2 ACCEPTANCE — local-only autoSync must not block the multi-workspace plane (their report, verbatim):
|
|
2
|
+
// - Open multiple local-only sessions with NomosTestHarness.cluster.
|
|
3
|
+
// - await homeSession.autoSync() completes promptly.
|
|
4
|
+
// - await bridge.listWorkspaces() and a query on a sibling estate session still complete.
|
|
5
|
+
// - stopAutoSync() and harness disposal complete.
|
|
6
|
+
// - No local://.../intent-offers fetch attempts occur.
|
|
7
|
+
// autoSync() REGISTERS background behavior and returns immediately; for a local-only (local://) session it
|
|
8
|
+
// performs NO network work and must not monopolize the runner request dispatcher.
|
|
9
|
+
@TestOn('vm')
|
|
10
|
+
library;
|
|
11
|
+
|
|
12
|
+
import 'dart:io';
|
|
13
|
+
|
|
14
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
15
|
+
import 'package:test/test.dart';
|
|
16
|
+
|
|
17
|
+
String get _root => '${Directory.current.path}/../..';
|
|
18
|
+
String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
|
|
19
|
+
String get _guestbookDeploy => '$_root/examples/guestbook/build/guestbook.deploy.json';
|
|
20
|
+
String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
|
|
21
|
+
|
|
22
|
+
String? _missingPrereq() {
|
|
23
|
+
if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
|
|
24
|
+
if (!File(_guestbookDeploy).existsSync()) {
|
|
25
|
+
return 'compile the guestbook example: cd examples/guestbook && npx nomos-compile — missing $_guestbookDeploy';
|
|
26
|
+
}
|
|
27
|
+
if (!File('$_runtimeDir/holon.wasm').existsSync() ||
|
|
28
|
+
!File('$_runtimeDir/packages.json').existsSync()) {
|
|
29
|
+
return 'warm ~/.holon/runtime first (run any `githolon` command once)';
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
void main() {
|
|
35
|
+
test(
|
|
36
|
+
'local-only autoSync() returns promptly, never fetches, and does not block the plane (co2 acceptance)',
|
|
37
|
+
() async {
|
|
38
|
+
final skip = _missingPrereq();
|
|
39
|
+
if (skip != null) {
|
|
40
|
+
markTestSkipped(skip);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 1. Open multiple local-only sessions with NomosTestHarness.cluster (four, one bridge).
|
|
45
|
+
final names = ['home', 'estate-a', 'estate-b', 'estate-c'];
|
|
46
|
+
final diagnostics = <String>[];
|
|
47
|
+
final harness = await NomosTestHarness.cluster(
|
|
48
|
+
workspaces: {for (final ws in names) ws: [_guestbookDeploy]},
|
|
49
|
+
nodeEntry: _repoEntry,
|
|
50
|
+
runtimeDir: _runtimeDir,
|
|
51
|
+
onDiagnostic: diagnostics.add,
|
|
52
|
+
);
|
|
53
|
+
try {
|
|
54
|
+
final home = harness.session('home');
|
|
55
|
+
|
|
56
|
+
// A realistic home holds local writes (ahead of any base) — this is what drives the up-leg machinery.
|
|
57
|
+
await home.offerDirective(
|
|
58
|
+
domain: 'guestbook',
|
|
59
|
+
directiveId: 'signGuestbook',
|
|
60
|
+
payload: {
|
|
61
|
+
'author': 'ada',
|
|
62
|
+
'message': 'offline write before autoSync',
|
|
63
|
+
'mood': 'delighted',
|
|
64
|
+
'signedAt': '2026-07-11T00:00:00Z',
|
|
65
|
+
},
|
|
66
|
+
actor: 'ada',
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// 2. await homeSession.autoSync() completes promptly.
|
|
70
|
+
await home.autoSync().timeout(const Duration(seconds: 10),
|
|
71
|
+
onTimeout: () => throw StateError(
|
|
72
|
+
'autoSync() on a local-only session did not complete within 10s (the reported deadlock)'));
|
|
73
|
+
|
|
74
|
+
// 3. await bridge.listWorkspaces() and a query on a sibling estate session still complete.
|
|
75
|
+
final listed = await harness.bridge.listWorkspaces().timeout(
|
|
76
|
+
const Duration(seconds: 10),
|
|
77
|
+
onTimeout: () => throw StateError(
|
|
78
|
+
'listWorkspaces() hung after a local-only autoSync (dispatcher monopolized)'));
|
|
79
|
+
expect(listed.map((w) => w['session'].toString()).toSet(),
|
|
80
|
+
containsAll(names.toSet()));
|
|
81
|
+
final sibling = await harness
|
|
82
|
+
.session('estate-a')
|
|
83
|
+
.query('entriesByAuthor', {'author': 'nobody'}).timeout(
|
|
84
|
+
const Duration(seconds: 10),
|
|
85
|
+
onTimeout: () => throw StateError(
|
|
86
|
+
'a sibling estate query hung after a local-only autoSync'));
|
|
87
|
+
expect(sibling, isEmpty);
|
|
88
|
+
|
|
89
|
+
// Give any wrongly-registered background sync machinery time to fire so its fetch attempts would show.
|
|
90
|
+
await Future<void>.delayed(const Duration(seconds: 2));
|
|
91
|
+
|
|
92
|
+
// 4. stopAutoSync() and harness disposal complete (disposal asserted in finally / teardown).
|
|
93
|
+
await home.stopAutoSync().timeout(const Duration(seconds: 10),
|
|
94
|
+
onTimeout: () =>
|
|
95
|
+
throw StateError('stopAutoSync() hung on a local-only session'));
|
|
96
|
+
|
|
97
|
+
// Bonus (live-runner leg of the acquireSession DX ask): a mounted local workspace is acquired
|
|
98
|
+
// WITHOUT credentials, and yields the same cached session handle.
|
|
99
|
+
final acquired = await harness.bridge.acquireSession('estate-b',
|
|
100
|
+
ifAbsentCredentials: () async =>
|
|
101
|
+
fail('estate-b is mounted on the plane — credentials must not be requested'));
|
|
102
|
+
expect(identical(acquired, harness.session('estate-b')), isTrue);
|
|
103
|
+
|
|
104
|
+
// 5. No local://.../intent-offers fetch attempts occur (attempted-and-refused still counts as attempted).
|
|
105
|
+
final offending = diagnostics
|
|
106
|
+
.where((l) => l.contains('intent-offers') || l.contains('refusing network fetch'))
|
|
107
|
+
.toList();
|
|
108
|
+
expect(offending, isEmpty,
|
|
109
|
+
reason:
|
|
110
|
+
'a local-only session must perform NO network work — saw:\n${offending.join('\n')}');
|
|
111
|
+
} finally {
|
|
112
|
+
await harness
|
|
113
|
+
.dispose()
|
|
114
|
+
.timeout(const Duration(seconds: 20), onTimeout: () {
|
|
115
|
+
throw StateError('harness disposal hung (the reported teardown hang)');
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}, timeout: const Timeout(Duration(minutes: 3)));
|
|
119
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A FAKE offline-install helper for the hermetic HARNESS PHASE-TIMEOUT tests: honours the real helper's
|
|
3
|
+
// flag + one-line-JSON contract (--deploy/--workspace/--out) but installs nothing — it just writes a tiny
|
|
4
|
+
// dummy snapshot so the harness proceeds to the runner phases.
|
|
5
|
+
import { writeFileSync } from "node:fs";
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const val = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; };
|
|
8
|
+
const out = val("--out");
|
|
9
|
+
const workspace = val("--workspace");
|
|
10
|
+
writeFileSync(out, Buffer.from("fake-snapshot"));
|
|
11
|
+
process.stdout.write(JSON.stringify({ ok: true, snapshot: out, workspace, lawHashes: ["deadbeef"], frameworkHash: "cafebabe" }) + "\n");
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A WEDGING FAKE RUNNER for the hermetic HARNESS PHASE-TIMEOUT tests: speaks the NDJSON bridge protocol,
|
|
3
|
+
// answers everything — EXCEPT the op named in NOMOS_WEDGE_OP (optionally only when the args carry an
|
|
4
|
+
// authorSecret, i.e. the 3b keyed reconnect), which it swallows FOREVER. This forges the exact "silent
|
|
5
|
+
// hang" co2 reported so the harness's typed HarnessPhaseTimeout can be asserted without a real wait.
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
const post = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
|
|
8
|
+
const wedgeOp = process.env.NOMOS_WEDGE_OP || "connect"; // default: wedge the KEYED reconnect (co2's shape)
|
|
9
|
+
const wedgeOnlyKeyed = process.env.NOMOS_WEDGE_ONLY_KEYED !== "0";
|
|
10
|
+
post({ v: 1, event: "ready" });
|
|
11
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
12
|
+
rl.on("line", (line) => {
|
|
13
|
+
const s = line.trim();
|
|
14
|
+
if (!s) return;
|
|
15
|
+
let msg; try { msg = JSON.parse(s); } catch { return; }
|
|
16
|
+
const a = msg.args || {};
|
|
17
|
+
if (msg.op === wedgeOp && (!wedgeOnlyKeyed || a.authorSecret)) return; // THE WEDGE: never answer
|
|
18
|
+
if (msg.op === "authorKeygen") return post({ v: 1, id: msg.id, ok: true, result: { secret: "c2VjcmV0", public: "cHVibGlj" } });
|
|
19
|
+
if (msg.op === "connect") return post({ v: 1, id: msg.id, ok: true, result: { workspace: a.workspace, session: a.session } });
|
|
20
|
+
post({ v: 1, id: msg.id, ok: true, result: {} });
|
|
21
|
+
});
|
|
22
|
+
rl.on("close", () => process.exit(0));
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// NO SILENT HANGS — the harness's per-phase deadline machinery, proven HERMETICALLY (no real waits, no
|
|
2
|
+
// wasm, no network): a fake runner (test/fixtures/wedge/wedge-entry.mjs) speaks the bridge protocol but
|
|
3
|
+
// SWALLOWS the 3b keyed reconnect forever — the exact wedge co2 reported. With a short `phaseTimeout` the
|
|
4
|
+
// harness must throw the TYPED [HarnessPhaseTimeout] naming the phase/workspace/op, never hang; and the
|
|
5
|
+
// failed construction must tear the runner down by itself (no leaked process holding the test runner).
|
|
6
|
+
@TestOn('vm')
|
|
7
|
+
@Timeout(Duration(seconds: 60))
|
|
8
|
+
library;
|
|
9
|
+
|
|
10
|
+
import 'dart:io';
|
|
11
|
+
|
|
12
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
13
|
+
import 'package:test/test.dart';
|
|
14
|
+
|
|
15
|
+
String get _fixtures => '${Directory.current.path}/test/fixtures/wedge';
|
|
16
|
+
String get _wedgeEntry => '$_fixtures/wedge-entry.mjs';
|
|
17
|
+
String get _fakeInstall => '$_fixtures/fake-install.mjs';
|
|
18
|
+
|
|
19
|
+
void main() {
|
|
20
|
+
test('a wedged keyed reconnect fires HarnessPhaseTimeout naming phase 3b (typed, no hang)', () async {
|
|
21
|
+
final log = <String>[];
|
|
22
|
+
Object? err;
|
|
23
|
+
try {
|
|
24
|
+
await NomosTestHarness.cluster(
|
|
25
|
+
workspaces: {
|
|
26
|
+
'catalogue': ['unused.deploy.json'], // the fake install helper ignores the paths
|
|
27
|
+
},
|
|
28
|
+
deviceKeyed: {'catalogue'},
|
|
29
|
+
nodeEntry: _wedgeEntry,
|
|
30
|
+
installHelper: _fakeInstall,
|
|
31
|
+
phaseTimeout: const Duration(seconds: 2), // hermetic: a short deadline, not a real 120s wait
|
|
32
|
+
onDiagnostic: log.add,
|
|
33
|
+
);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
err = e;
|
|
36
|
+
}
|
|
37
|
+
expect(err, isA<HarnessPhaseTimeout>(),
|
|
38
|
+
reason: 'the wedge must surface as the typed phase timeout, never an eternal await');
|
|
39
|
+
final t = err as HarnessPhaseTimeout;
|
|
40
|
+
expect(t.phase, "3b: keyed reconnect of 'catalogue'");
|
|
41
|
+
expect(t.workspace, 'catalogue');
|
|
42
|
+
expect(t.awaiting, 'connect');
|
|
43
|
+
expect(t.elapsed, const Duration(seconds: 2));
|
|
44
|
+
expect(t.likelyCauses, isNotEmpty, reason: 'known wedges carry their likely causes');
|
|
45
|
+
expect(t, isA<NomosTestHarnessException>(),
|
|
46
|
+
reason: 'existing on-NomosTestHarnessException handling keeps working');
|
|
47
|
+
expect(t.toString(), contains("3b: keyed reconnect of 'catalogue'"));
|
|
48
|
+
// ask 5 — the phases before the wedge logged enter AND exit; the wedged phase logged its enter.
|
|
49
|
+
expect(log.any((l) => l.contains('3: connect session') && l.contains('done')), isTrue);
|
|
50
|
+
expect(log.any((l) => l.contains('3b: device keygen') && l.contains('done')), isTrue);
|
|
51
|
+
expect(log.any((l) => l.contains("3b: keyed reconnect of 'catalogue'")), isTrue);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('deviceKeyed naming an unknown workspace refuses typed (never a wedge)', () async {
|
|
55
|
+
Object? err;
|
|
56
|
+
try {
|
|
57
|
+
await NomosTestHarness.cluster(
|
|
58
|
+
workspaces: {
|
|
59
|
+
'catalogue': ['unused.deploy.json'],
|
|
60
|
+
},
|
|
61
|
+
deviceKeyed: {'not-a-workspace'},
|
|
62
|
+
nodeEntry: _wedgeEntry,
|
|
63
|
+
installHelper: _fakeInstall,
|
|
64
|
+
phaseTimeout: const Duration(seconds: 5),
|
|
65
|
+
);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
err = e;
|
|
68
|
+
}
|
|
69
|
+
expect(err, isA<NomosTestHarnessException>());
|
|
70
|
+
expect(err.toString(), contains('not-a-workspace'));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -123,10 +123,12 @@ void main() {
|
|
|
123
123
|
);
|
|
124
124
|
final joined = lines.join('\n');
|
|
125
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
|
|
127
|
-
expect(joined, contains('phase 2/4
|
|
128
|
-
expect(joined, contains('phase
|
|
129
|
-
expect(joined, contains('phase
|
|
126
|
+
expect(joined, contains('phase 1/4: offline law install'));
|
|
127
|
+
expect(joined, contains('phase 2/4: spawn the node runner'));
|
|
128
|
+
expect(joined, contains('phase 2/4: spawn the node runner done'));
|
|
129
|
+
expect(joined, contains('phase 3/4: runner ready'));
|
|
130
|
+
expect(joined, contains('phase 4/4: connect session'));
|
|
131
|
+
expect(joined, contains('phase 4/4: connect session "test-ws" done'));
|
|
130
132
|
expect(joined, contains('ready — session "test-ws" connected'));
|
|
131
133
|
expect(harness.bridge, isNotNull);
|
|
132
134
|
}, timeout: const Timeout(Duration(minutes: 2)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -904,7 +904,7 @@ const NOMOS_TYPES_DEP = "^0.3.0";
|
|
|
904
904
|
// 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
|
|
905
905
|
// DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
|
|
906
906
|
// REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
|
|
907
|
-
const NOMOS_CLIENT_DEP = "^0.
|
|
907
|
+
const NOMOS_CLIENT_DEP = "^0.50.0";
|
|
908
908
|
// DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
|
|
909
909
|
// and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
|
|
910
910
|
// land back in dependency_overrides (the exact co2 report this closes, third occurrence).
|