@githolon/dsl 0.73.0 → 0.75.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 +47 -0
- package/dart/lib/nomos_headless.dart +8 -0
- package/dart/lib/src/test_harness.dart +322 -125
- package/dart/lib/src/webview_bridge.dart +149 -84
- package/dart/pubspec.yaml +1 -1
- package/dart/test/acquire_session_credentials_test.dart +132 -0
- package/dart/test/cluster_births_test.dart +0 -1
- package/dart/test/cluster_device_keyed_acceptance_test.dart +108 -0
- package/dart/test/dlq_session_surface_test.dart +203 -0
- package/dart/test/event_plane_seam_test.dart +188 -0
- package/dart/test/first_app_gauntlet_test.dart +301 -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/sealing_client_test.dart +179 -0
- package/dart/test/test_harness_test.dart +6 -5
- package/dart/test/watch_surfaces_test.dart +240 -0
- package/package.json +1 -1
- package/src/aggregate.ts +75 -1
- package/src/codegen_dart.ts +101 -0
- package/src/codegen_dart_client.ts +129 -18
- package/src/codegen_ts.ts +44 -0
- package/src/compile_package_main.ts +26 -1
- package/src/drivers.ts +9 -1
- package/src/fields.ts +23 -0
- package/src/manifest.ts +11 -0
- package/src/usd.ts +24 -0
- package/src/usd_layers.ts +32 -0
- package/src/wire.ts +5 -1
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
## 0.51.0
|
|
2
|
+
|
|
3
|
+
- **The burn-down wave.** Dead-letter queue surface on the session plane: `deadLetters()`,
|
|
4
|
+
`retryDeadLetter()`, `discardDeadLetter()`, `salvage()`, `absorbSalvage()` — with the local-only
|
|
5
|
+
retry/absorb fixes and the salvage-shape parking fix (refused work is NEVER silently dropped).
|
|
6
|
+
`nomos_headless.dart` exports the full session surface. Requires Nomos Cloud ≥ the 2026-07-11
|
|
7
|
+
burn-down deploy for the fleet DLQ sweep lanes (`/dlq-summary`, `/dlq-sweep`) and the
|
|
8
|
+
content-addressed large-deploy staging lane.
|
|
9
|
+
|
|
10
|
+
## 0.50.1
|
|
11
|
+
|
|
12
|
+
- **Structural: the session-delegation-gap class is closed.** All event-dispatch registries
|
|
13
|
+
(watch subscriptions, compat watches, custody/sync/signal/flame/span/transfer/status
|
|
14
|
+
controllers, span buffers, session registries) now route through one `_eventPlane` seam that
|
|
15
|
+
resolves to the plane owning the message loop — the base class is correct for `NomosSession`
|
|
16
|
+
automatically, and all 17 hand-written per-method session overrides are DELETED (including the
|
|
17
|
+
0.48.1 `watchPendingConflicts` fix, whose regression test still passes — the proof). Five
|
|
18
|
+
latent unreported instances of the same bug fixed en passant: `session.custodyEvents` (dead
|
|
19
|
+
stream), `connectedSessions` (empty), `connect()`-on-session state stamping, `checkCompat()`
|
|
20
|
+
caching, `noteLocalSpan()` (dead buffer). A source-level tripwire test fails the suite if a
|
|
21
|
+
future Stream-returning method bypasses the seam.
|
|
22
|
+
- **The first-five-minutes gauntlet** — one checklist test walking the entire promised surface
|
|
23
|
+
(deviceKeyed cluster + phase diagnostics, the blessed session-bound shape, local autoSync
|
|
24
|
+
posture with zero-fetch assertion, acquireSession single-flight, reactive first-snapshots,
|
|
25
|
+
attested keyed reads, custody reset/re-seed, deadline-bounded disposal) — now runs in CI on
|
|
26
|
+
every client/runner/codegen change. Every one of this week's four tenant-reported lifecycle
|
|
27
|
+
bugs appears as an explicit leg.
|
|
28
|
+
|
|
29
|
+
## 0.50.0
|
|
30
|
+
|
|
31
|
+
- **No silent hangs — the standing harness property.** Every `NomosTestHarness` lifecycle phase now
|
|
32
|
+
has a deadline (overridable via the new `phaseTimeout` param) and throws a typed
|
|
33
|
+
`HarnessPhaseTimeout` naming the phase (e.g. `"3b: keyed reconnect of 'catalogue'"`), workspace,
|
|
34
|
+
elapsed time, the awaited bridge op, and likely causes — a wedge is a 30-second named exception,
|
|
35
|
+
never a 40-minute bisection. Every phase logs terse enter/exit diagnostics. Disposal deadline
|
|
36
|
+
breaches are logged, not thrown — teardown always completes.
|
|
37
|
+
- **Keyed-reconnect lane hardened** (the `deviceKeyed` cluster hang report): harness phase 3b
|
|
38
|
+
(keygen + keyed reconnect) had no timeout and no diagnostics — any wedge there hung silently
|
|
39
|
+
after "4 session(s) connected". Additionally two runner/realm defects fixed: the stale session
|
|
40
|
+
record stayed routable during an async re-open (concurrent ops could hit the disposed holon —
|
|
41
|
+
now an atomic replace with a fast typed refusal in the window), and a re-open with new
|
|
42
|
+
credentials but no `restoreFrom` silently kept the OLD identity (the fresh authorSecret was
|
|
43
|
+
never installed) — an identity-bearing option change now forces an in-place re-mount over
|
|
44
|
+
resident custody.
|
|
45
|
+
- co2's acceptance implemented verbatim: `cluster(deviceKeyed:)` completes, `devicePublicKey`
|
|
46
|
+
populated, colocated attested read signed by the re-opened key, disposal completes.
|
|
47
|
+
|
|
1
48
|
## 0.49.0
|
|
2
49
|
|
|
3
50
|
- **Fix: local-only `autoSync()` deadlocked the multi-workspace plane.** A `local://` session's
|
|
@@ -39,3 +39,11 @@ library;
|
|
|
39
39
|
|
|
40
40
|
export 'src/node_runner_transport.dart';
|
|
41
41
|
export 'src/test_harness.dart';
|
|
42
|
+
// THE FULL SESSION SURFACE (task #86 gap 1): a headless test types everything the harness hands out —
|
|
43
|
+
// [NomosSessionCredentials] (the `acquireSession` ifAbsentCredentials callback's return type),
|
|
44
|
+
// [NomosBridge]/[NomosSession]/[NomosBbox]/[NomosCompat], the E2E sealing client
|
|
45
|
+
// ([NomosKeyStore]/[WrappedKeyStore]/[NomosEncryption]), and the typed session-bound errors —
|
|
46
|
+
// WITHOUT also importing the web barrel. Both files are web-safe (they are exported from
|
|
47
|
+
// `nomos_client.dart` too); re-exporting them here just makes the headless import self-sufficient.
|
|
48
|
+
export 'src/webview_bridge.dart';
|
|
49
|
+
export 'src/session_bound.dart';
|
|
@@ -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) {
|