@githolon/dsl 0.74.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 +28 -0
- package/dart/lib/nomos_headless.dart +8 -0
- 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/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/sealing_client_test.dart +179 -0
- package/dart/test/test_harness_test.dart +0 -1
- 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,31 @@
|
|
|
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
|
+
|
|
1
29
|
## 0.50.0
|
|
2
30
|
|
|
3
31
|
- **No silent hangs — the standing harness property.** Every `NomosTestHarness` lifecycle phase now
|
|
@@ -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';
|
|
@@ -769,6 +769,26 @@ class NomosBridge {
|
|
|
769
769
|
|
|
770
770
|
static String _spanIdFor(int id) => id.toRadixString(16).padLeft(16, '0');
|
|
771
771
|
|
|
772
|
+
/// THE EVENT PLANE — the ONE bridge whose mutable dispatch registries every event/push resolves against.
|
|
773
|
+
///
|
|
774
|
+
/// WHY THIS EXISTS (the closed bug class): a [NomosSession] extends this class but shares its [parent]'s
|
|
775
|
+
/// transport + message loop — [_onMessage] only ever runs on the PARENT, so events resolve against the
|
|
776
|
+
/// PARENT's controller maps ([_watches], [_compatWatches], the broadcast StreamControllers, [_spanBuffer],
|
|
777
|
+
/// [_sessionNames], …). Any Stream-returning method / stateful getter that touched `this`'s copies on a
|
|
778
|
+
/// session registered on a DEAD registry and never emitted (real shipped instances: `watchPendingConflicts`
|
|
779
|
+
/// pre-0.48.1, and the five hand-written session watch overrides this seam retired).
|
|
780
|
+
///
|
|
781
|
+
/// THE RULE (tripwire-enforced by `test/event_plane_seam_test.dart`): EVERY member that reads or writes
|
|
782
|
+
/// event-dispatch / plane state MUST do so via `_eventPlane.<field>` — never a bare field access. Requests
|
|
783
|
+
/// still go through `this._call` (a session's override stamps `args.session`), so a session's watch
|
|
784
|
+
/// registers its controller on the PARENT's registry while the wire op still carries the SESSION's
|
|
785
|
+
/// workspace. Chases to the root, so nested delegation stays correct.
|
|
786
|
+
NomosBridge get _eventPlane => this;
|
|
787
|
+
|
|
788
|
+
/// The session name this view is pinned to (null on the plane itself). Lets base implementations be
|
|
789
|
+
/// session-aware (per-workspace compat caching / law-change streams) with no per-method overrides.
|
|
790
|
+
String? get _sessionName => null;
|
|
791
|
+
|
|
772
792
|
/// Completes once the runner has posted `{ event: "ready" }` (the wasm + client are booted).
|
|
773
793
|
Future<void> get ready => _ready.future;
|
|
774
794
|
|
|
@@ -926,8 +946,8 @@ class NomosBridge {
|
|
|
926
946
|
/// it to a generated client (`EstateClient(bridge.session('estate'))`), and `connect` the workspace when
|
|
927
947
|
/// ready — reads/writes flow once it is open. A device typically holds many: `bridge.session('home')`,
|
|
928
948
|
/// `bridge.session('estate-42')`, … all on ONE bridge/transport (this device's local plane).
|
|
929
|
-
NomosSession session(String workspace) =>
|
|
930
|
-
|
|
949
|
+
NomosSession session(String workspace) => _eventPlane._sessionViews
|
|
950
|
+
.putIfAbsent(workspace, () => NomosSession(_eventPlane, workspace));
|
|
931
951
|
|
|
932
952
|
/// A [NomosForeignReader] backed by THIS bridge's multi-workspace plane — what a generated `ForeignRef`
|
|
933
953
|
/// (a `t.foreignRef` cross-workspace locator) resolves against: `row.data.listing.resolve(bridge.foreignReader())`.
|
|
@@ -939,7 +959,8 @@ class NomosBridge {
|
|
|
939
959
|
NomosForeignReader foreignReader({String? cloud}) => _BridgeForeignReader(this, cloud);
|
|
940
960
|
|
|
941
961
|
/// The workspace names [connect] has opened on this plane (the live sessions).
|
|
942
|
-
List<String> get connectedSessions =>
|
|
962
|
+
List<String> get connectedSessions =>
|
|
963
|
+
List.unmodifiable(_eventPlane._sessionNames);
|
|
943
964
|
|
|
944
965
|
/// ENUMERATE THE LOCAL PLANE — every workspace open on this device + its sync posture:
|
|
945
966
|
/// `[{ session, workspace, mounted, syncPosture: "local-only" | "cloud-bound" }]`. A local-only workspace
|
|
@@ -965,7 +986,7 @@ class NomosBridge {
|
|
|
965
986
|
String? session,
|
|
966
987
|
int? maxMounted}) async {
|
|
967
988
|
final ws = session ?? workspace;
|
|
968
|
-
_sessionNames.add(ws);
|
|
989
|
+
_eventPlane._sessionNames.add(ws);
|
|
969
990
|
final r = _asMap(await _call('connect', {
|
|
970
991
|
// [cloud] is OPTIONAL — omit it to open a LOCAL-ONLY workspace (a local githolon with no cloud
|
|
971
992
|
// counterpart; the runner uses a `local://` sentinel and sync is a no-op). Pass it to make the
|
|
@@ -992,13 +1013,13 @@ class NomosBridge {
|
|
|
992
1013
|
// fail closed (the break surfaces as an error on the sync/pull that hit it; the holon stays local).
|
|
993
1014
|
if (onCustodyBreak != null) 'onCustodyBreak': onCustodyBreak.wire,
|
|
994
1015
|
}));
|
|
995
|
-
_lastRecovery = (r['recovery'] as Map?)?.cast<String, Object?>();
|
|
1016
|
+
_eventPlane._lastRecovery = (r['recovery'] as Map?)?.cast<String, Object?>();
|
|
996
1017
|
}
|
|
997
1018
|
|
|
998
1019
|
/// The custody lifecycle stream (rebirth_and_custody_lifecycle.md) — a rebirth/divergence surfaces here as a
|
|
999
1020
|
/// typed [NomosCustodyEvent]. Bind a Flutter break dialog / posture banner to it; for a `CustodyBreakPolicy.
|
|
1000
1021
|
/// ask` session, an event of `kind == 'ask'` awaits [resolveCustodyBreak].
|
|
1001
|
-
Stream<NomosCustodyEvent> get custodyEvents => _custodyEvents.stream;
|
|
1022
|
+
Stream<NomosCustodyEvent> get custodyEvents => _eventPlane._custodyEvents.stream;
|
|
1002
1023
|
|
|
1003
1024
|
/// Where a session sits on the custody spectrum (incarnation, posture, pending/salvaged local work,
|
|
1004
1025
|
/// supersession history) — for a status banner or a "you're offline on old truth" indicator.
|
|
@@ -1032,10 +1053,54 @@ class NomosBridge {
|
|
|
1032
1053
|
Future<Map<String, Object?>> discardSalvaged(String id, {String? session}) async =>
|
|
1033
1054
|
_asMap(await _call('discardSalvaged', {'id': id, if (session != null) 'session': session}));
|
|
1034
1055
|
|
|
1056
|
+
// ── THE DEAD-LETTER QUEUE (refused-but-legitimate work is NEVER lost) — the Dart mirror of the
|
|
1057
|
+
// JS holon's DLQ surface (`deadLetters()` / `retryDeadLetter()` / `discardDeadLetter()`). A
|
|
1058
|
+
// DOMAIN rejection (the law couldn't merge it — edge admission or a local replay refusal)
|
|
1059
|
+
// parks the FULL intent durably in the workspace tree (it rides export()/restore); the app
|
|
1060
|
+
// inspects, the domain dev ships a law fix, retry unjams. ──
|
|
1061
|
+
|
|
1062
|
+
/// The parked dead letters — metadata only (`{id, oid, source, error, domain, directiveId, at}`);
|
|
1063
|
+
/// the intent bytes stay in the durable queue.
|
|
1064
|
+
Future<List<Map<String, Object?>>> deadLetters({String? session}) async {
|
|
1065
|
+
final r = _asMap(await _call('deadLetters', {if (session != null) 'session': session}));
|
|
1066
|
+
return ((r['dead'] as List?) ?? const [])
|
|
1067
|
+
.map((e) => (e as Map).cast<String, Object?>())
|
|
1068
|
+
.toList();
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/// Retry one dead letter (after the law fix landed): pulls canon first (it may already be
|
|
1072
|
+
/// admitted), else re-offers the parked bytes locally. `{ok, head?|alreadyOnMain?|error?}` —
|
|
1073
|
+
/// a refusal leaves the entry parked (with the fresh error), never lost.
|
|
1074
|
+
Future<Map<String, Object?>> retryDeadLetter(String id, {String? session}) async =>
|
|
1075
|
+
_asMap(await _call('retryDeadLetter', {'id': id, if (session != null) 'session': session}));
|
|
1076
|
+
|
|
1077
|
+
/// Discard one dead letter — the app's EXPLICIT choice to drop the work. `{ok}`.
|
|
1078
|
+
Future<Map<String, Object?>> discardDeadLetter(String id, {String? session}) async =>
|
|
1079
|
+
_asMap(await _call('discardDeadLetter', {'id': id, if (session != null) 'session': session}));
|
|
1080
|
+
|
|
1081
|
+
/// SALVAGE — best-effort extraction of this holon's local-only authored work (un-synced pending
|
|
1082
|
+
/// writes + the DLQ), each with its full `intentB64` bytes: `{pendingWrites, deadLetters}`. The
|
|
1083
|
+
/// read half of the recovery lane (`connect(recover: true)` runs salvage → [absorbSalvage]).
|
|
1084
|
+
Future<Map<String, Object?>> salvage({String? session}) async =>
|
|
1085
|
+
_asMap(await _call('salvage', {if (session != null) 'session': session}));
|
|
1086
|
+
|
|
1087
|
+
/// ABSORB salvaged work into THIS holon: pending writes are re-offered under the current law
|
|
1088
|
+
/// (a refusal dead-letters — never lost), and dead-letter entries are re-queued into the DLQ.
|
|
1089
|
+
/// Returns the recovery report `{salvagedWrites, requeuedDeadLetters, unsalvageable}`.
|
|
1090
|
+
Future<Map<String, Object?>> absorbSalvage(
|
|
1091
|
+
{List<Map<String, Object?>> pendingWrites = const [],
|
|
1092
|
+
List<Map<String, Object?>> deadLetters = const [],
|
|
1093
|
+
String? session}) async =>
|
|
1094
|
+
_asMap(await _call('absorbSalvage', {
|
|
1095
|
+
'pendingWrites': pendingWrites,
|
|
1096
|
+
'deadLetters': deadLetters,
|
|
1097
|
+
if (session != null) 'session': session,
|
|
1098
|
+
}));
|
|
1099
|
+
|
|
1035
1100
|
/// The salvage report from the most recent `connect(recover: true)` — `{ salvagedWrites, requeuedDeadLetters,
|
|
1036
1101
|
/// unsalvageable }`, or null if the connection didn't recover. Show "recovered N writes" / surface
|
|
1037
1102
|
/// `unsalvageable` (writes the current law refused — kept in the DLQ, never dropped).
|
|
1038
|
-
Map<String, Object?>? get lastRecovery => _lastRecovery;
|
|
1103
|
+
Map<String, Object?>? get lastRecovery => _eventPlane._lastRecovery;
|
|
1039
1104
|
Map<String, Object?>? _lastRecovery;
|
|
1040
1105
|
|
|
1041
1106
|
/// LOCAL-FIRST offer: carry opaque intent bytes into the GitHolon. Returns the new local head; durable on [sync].
|
|
@@ -1249,11 +1314,14 @@ class NomosBridge {
|
|
|
1249
1314
|
Future<NomosSession> acquireSession(String workspace,
|
|
1250
1315
|
{Future<NomosSessionCredentials> Function()? ifAbsentCredentials}) {
|
|
1251
1316
|
// Fast path: a live session needs no runner round-trip (and never asks for credentials).
|
|
1252
|
-
|
|
1317
|
+
final plane = _eventPlane;
|
|
1318
|
+
if (plane._sessionNames.contains(workspace)) {
|
|
1319
|
+
return Future.value(session(workspace));
|
|
1320
|
+
}
|
|
1253
1321
|
// SINGLE-FLIGHT: one in-flight open per workspace per plane; concurrent callers join it.
|
|
1254
|
-
return _acquiring.putIfAbsent(workspace, () async {
|
|
1322
|
+
return plane._acquiring.putIfAbsent(workspace, () async {
|
|
1255
1323
|
try {
|
|
1256
|
-
if (_sessionNames.contains(workspace)) return session(workspace);
|
|
1324
|
+
if (plane._sessionNames.contains(workspace)) return session(workspace);
|
|
1257
1325
|
// Consult the plane's TRUTHFUL registry (mounted ∪ parked ∪ resident custody — the runner's
|
|
1258
1326
|
// realm.list()): anything the device already holds opens locally, no cloud, no credentials.
|
|
1259
1327
|
var resident = false;
|
|
@@ -1285,7 +1353,7 @@ class NomosBridge {
|
|
|
1285
1353
|
return session(workspace);
|
|
1286
1354
|
} finally {
|
|
1287
1355
|
// completed (either way) — the next acquire re-evaluates fresh (a live session hits the fast path).
|
|
1288
|
-
_acquiring.remove(workspace);
|
|
1356
|
+
plane._acquiring.remove(workspace);
|
|
1289
1357
|
}
|
|
1290
1358
|
});
|
|
1291
1359
|
}
|
|
@@ -1712,18 +1780,19 @@ class NomosBridge {
|
|
|
1712
1780
|
}
|
|
1713
1781
|
|
|
1714
1782
|
/// Background-sync telemetry: each autoSync round (`event:'sync'`) and doorbell socket state (`event:'link'`).
|
|
1715
|
-
Stream<Map<String, Object?>> get syncEvents => _syncEvents.stream;
|
|
1783
|
+
Stream<Map<String, Object?>> get syncEvents => _eventPlane._syncEvents.stream;
|
|
1716
1784
|
|
|
1717
1785
|
/// LIVE pack byte-progress — the real loading bar. Each event carries `received`/`total` (the host's
|
|
1718
1786
|
/// actual `Content-Length`, known before the body) + `kind` ('delta'|'full'). Emits only while a `/pack`
|
|
1719
1787
|
/// transfer is moving (small syncs ride the inline offer-result piggyback — instant, no bar).
|
|
1720
|
-
Stream<TransferProgress> get transferProgress =>
|
|
1788
|
+
Stream<TransferProgress> get transferProgress =>
|
|
1789
|
+
_eventPlane._transferEvents.stream;
|
|
1721
1790
|
|
|
1722
1791
|
/// THE APP-READY SYNC STATE — a truthful aggregation of sync rounds, the doorbell, transfer progress, and a
|
|
1723
1792
|
/// periodic metrics poll. Map straight to a sync-state page. Starts a lightweight 4s metrics poll while
|
|
1724
1793
|
/// listened to (stops on cancel). [currentStatus] is the latest snapshot for a non-stream read.
|
|
1725
|
-
Stream<SyncStatus> get syncStatus => _syncStatusCtrl.stream;
|
|
1726
|
-
SyncStatus get currentStatus => _status;
|
|
1794
|
+
Stream<SyncStatus> get syncStatus => _eventPlane._syncStatusCtrl.stream;
|
|
1795
|
+
SyncStatus get currentStatus => _eventPlane._status;
|
|
1727
1796
|
|
|
1728
1797
|
void _pushStatus(SyncStatus s) {
|
|
1729
1798
|
_status = s;
|
|
@@ -1761,20 +1830,24 @@ class NomosBridge {
|
|
|
1761
1830
|
}
|
|
1762
1831
|
|
|
1763
1832
|
/// Ephemeral signal-holon projections (`event:'signal'`), currently peer frontiers / lag for the workspace.
|
|
1764
|
-
Stream<Map<String, Object?>> get signalEvents =>
|
|
1833
|
+
Stream<Map<String, Object?>> get signalEvents =>
|
|
1834
|
+
_eventPlane._signalEvents.stream;
|
|
1765
1835
|
|
|
1766
1836
|
/// THE REALTIME FLAMEPATH: one event per admission the edge broadcasts over the doorbell —
|
|
1767
1837
|
/// `{ at, oid, edge:{totalMs,scanMs,fetchMs,gateFoldMs,sealMs,intents}, clients:[{client,n}] }`.
|
|
1768
1838
|
/// Every subscriber sees EVERY admission (yours and your peers') — the cloud track of the flame chart.
|
|
1769
|
-
Stream<Map<String, Object?>> get flameEvents =>
|
|
1839
|
+
Stream<Map<String, Object?>> get flameEvents =>
|
|
1840
|
+
_eventPlane._flameEvents.stream;
|
|
1770
1841
|
|
|
1771
1842
|
/// LIVE per-layer spans of the local pipeline (begin/end as they happen): mutex wait, githolon author,
|
|
1772
1843
|
/// push, converge … The host renders in-progress bars so a hang is visible WHILE it blocks, not after.
|
|
1773
|
-
Stream<Map<String, Object?>> get spanEvents =>
|
|
1844
|
+
Stream<Map<String, Object?>> get spanEvents =>
|
|
1845
|
+
_eventPlane._spanEvents.stream;
|
|
1774
1846
|
|
|
1775
1847
|
/// The buffered recent span events — replay these on first build to recover the BOOT flamepath
|
|
1776
1848
|
/// (fetch wasm / clone ledger / instantiate) that streamed during connect(), before the UI subscribed.
|
|
1777
|
-
List<Map<String, Object?>> get recentSpans =>
|
|
1849
|
+
List<Map<String, Object?>> get recentSpans =>
|
|
1850
|
+
List.unmodifiable(_eventPlane._spanBuffer);
|
|
1778
1851
|
|
|
1779
1852
|
int _localSpanSeq = 0;
|
|
1780
1853
|
|
|
@@ -1790,14 +1863,17 @@ class NomosBridge {
|
|
|
1790
1863
|
required int t1,
|
|
1791
1864
|
String? id,
|
|
1792
1865
|
}) {
|
|
1793
|
-
final
|
|
1866
|
+
final plane = _eventPlane;
|
|
1867
|
+
final sid = id ?? 'local-${plane._localSpanSeq++}';
|
|
1794
1868
|
for (final m in <Map<String, Object?>>[
|
|
1795
1869
|
{'event': 'span', 'span': {'phase': 'begin', 'id': sid, 'track': track, 'name': name, 'kind': kind, 't0': t0}},
|
|
1796
1870
|
{'event': 'span', 'span': {'phase': 'end', 'id': sid, 't1': t1}},
|
|
1797
1871
|
]) {
|
|
1798
|
-
_spanBuffer.add(m);
|
|
1799
|
-
if (_spanBuffer.length > 400)
|
|
1800
|
-
|
|
1872
|
+
plane._spanBuffer.add(m);
|
|
1873
|
+
if (plane._spanBuffer.length > 400) {
|
|
1874
|
+
plane._spanBuffer.removeRange(0, plane._spanBuffer.length - 400);
|
|
1875
|
+
}
|
|
1876
|
+
if (!plane._spanEvents.isClosed) plane._spanEvents.add(m);
|
|
1801
1877
|
}
|
|
1802
1878
|
}
|
|
1803
1879
|
|
|
@@ -1860,7 +1936,12 @@ class NomosBridge {
|
|
|
1860
1936
|
if (requirement != null) 'requirement': requirement,
|
|
1861
1937
|
}));
|
|
1862
1938
|
final v = NomosCompat.fromJson((r['compat'] as Map?)?.cast<String, Object?>());
|
|
1863
|
-
if (v != null)
|
|
1939
|
+
if (v != null) {
|
|
1940
|
+
_eventPlane._lastCompat = v;
|
|
1941
|
+
// a session's verdict is per-workspace state on the PLANE (so `session.compat` reads it back).
|
|
1942
|
+
final n = _sessionName;
|
|
1943
|
+
if (n != null) _eventPlane._lastCompatBySession[n] = v;
|
|
1944
|
+
}
|
|
1864
1945
|
return v;
|
|
1865
1946
|
}
|
|
1866
1947
|
|
|
@@ -1892,7 +1973,15 @@ class NomosBridge {
|
|
|
1892
1973
|
}
|
|
1893
1974
|
|
|
1894
1975
|
/// The last computed compat verdict (set by [checkCompat] / a `lawChanged` event). Null until bind.
|
|
1895
|
-
|
|
1976
|
+
/// LAW-scoped: a [NomosSession] answers for ITS workspace, falling back to the plane-wide value when the
|
|
1977
|
+
/// runner's events carried no session key (older bundles).
|
|
1978
|
+
NomosCompat? get compat {
|
|
1979
|
+
final n = _sessionName;
|
|
1980
|
+
if (n != null) {
|
|
1981
|
+
return _eventPlane._lastCompatBySession[n] ?? _eventPlane._lastCompat;
|
|
1982
|
+
}
|
|
1983
|
+
return _eventPlane._lastCompat;
|
|
1984
|
+
}
|
|
1896
1985
|
|
|
1897
1986
|
/// THE FEATURE FLAG (dark-launch): true iff [member] exists on the workspace's offer AND is
|
|
1898
1987
|
/// structurally compatible. Ship UI with a member dark, deploy the law, it lights up — no redeploy.
|
|
@@ -1904,7 +1993,16 @@ class NomosBridge {
|
|
|
1904
1993
|
|
|
1905
1994
|
/// COMPAT SUPERPOWER live events: emits the fresh verdict whenever a synced install/upgrade/retire
|
|
1906
1995
|
/// moves the workspace's offer under this live client. The app re-checks feature flags reactively.
|
|
1907
|
-
|
|
1996
|
+
/// LAW-scoped like [compat]: a [NomosSession] listens to ITS workspace's law changes only.
|
|
1997
|
+
Stream<NomosCompat> get onLawChanged {
|
|
1998
|
+
final n = _sessionName;
|
|
1999
|
+
if (n != null) {
|
|
2000
|
+
return (_eventPlane._lawChangedBySession[n] ??=
|
|
2001
|
+
StreamController<NomosCompat>.broadcast())
|
|
2002
|
+
.stream;
|
|
2003
|
+
}
|
|
2004
|
+
return _eventPlane._lawChanged.stream;
|
|
2005
|
+
}
|
|
1908
2006
|
|
|
1909
2007
|
/// SHAPE COMPATIBILITY as a LIVE BINDING (#72): a stream of verdicts that re-emits whenever the
|
|
1910
2008
|
/// workspace's installed law changes (another peer installs/upgrades/retires a domain and it
|
|
@@ -1912,7 +2010,9 @@ class NomosBridge {
|
|
|
1912
2010
|
Stream<Map<String, Object?>> watchCompatibility(
|
|
1913
2011
|
Map<String, Object?> requirement,
|
|
1914
2012
|
[String? session]) {
|
|
1915
|
-
|
|
2013
|
+
// Controllers live on the EVENT PLANE (where _onMessage dispatches); the registration `_call` stays
|
|
2014
|
+
// THIS instance's (a session's override stamps `args.session`) — see [_eventPlane].
|
|
2015
|
+
final subId = 'c${++_eventPlane._seq}';
|
|
1916
2016
|
late final StreamController<Map<String, Object?>> ctrl;
|
|
1917
2017
|
ctrl = StreamController<Map<String, Object?>>(
|
|
1918
2018
|
onListen: () => _call('watch', {
|
|
@@ -1922,13 +2022,13 @@ class NomosBridge {
|
|
|
1922
2022
|
if (session != null) 'session': session,
|
|
1923
2023
|
}),
|
|
1924
2024
|
onCancel: () async {
|
|
1925
|
-
_compatWatches.remove(subId);
|
|
2025
|
+
_eventPlane._compatWatches.remove(subId);
|
|
1926
2026
|
try {
|
|
1927
2027
|
await _call('unwatch', {'subId': subId});
|
|
1928
2028
|
} catch (_) {}
|
|
1929
2029
|
},
|
|
1930
2030
|
);
|
|
1931
|
-
_compatWatches[subId] = ctrl;
|
|
2031
|
+
_eventPlane._compatWatches[subId] = ctrl;
|
|
1932
2032
|
return ctrl.stream;
|
|
1933
2033
|
}
|
|
1934
2034
|
|
|
@@ -1941,7 +2041,9 @@ class NomosBridge {
|
|
|
1941
2041
|
NomosBbox? bbox,
|
|
1942
2042
|
String? base,
|
|
1943
2043
|
String? session}) {
|
|
1944
|
-
|
|
2044
|
+
// Controllers live on the EVENT PLANE (where _onMessage dispatches); the registration `_call` stays
|
|
2045
|
+
// THIS instance's (a session's override stamps `args.session`) — see [_eventPlane].
|
|
2046
|
+
final subId = 'w${++_eventPlane._seq}';
|
|
1945
2047
|
late final StreamController<List<dynamic>> ctrl;
|
|
1946
2048
|
ctrl = StreamController<List<dynamic>>(
|
|
1947
2049
|
onListen: () => _call('watch', {
|
|
@@ -1956,13 +2058,13 @@ class NomosBridge {
|
|
|
1956
2058
|
if (session != null) 'session': session,
|
|
1957
2059
|
}),
|
|
1958
2060
|
onCancel: () async {
|
|
1959
|
-
_watches.remove(subId);
|
|
2061
|
+
_eventPlane._watches.remove(subId);
|
|
1960
2062
|
try {
|
|
1961
2063
|
await _call('unwatch', {'subId': subId});
|
|
1962
2064
|
} catch (_) {}
|
|
1963
2065
|
},
|
|
1964
2066
|
);
|
|
1965
|
-
_watches[subId] = ctrl;
|
|
2067
|
+
_eventPlane._watches[subId] = ctrl;
|
|
1966
2068
|
return ctrl.stream;
|
|
1967
2069
|
}
|
|
1968
2070
|
|
|
@@ -2027,6 +2129,16 @@ class NomosSession extends NomosBridge {
|
|
|
2027
2129
|
|
|
2028
2130
|
NomosSession(this.parent, this.workspace) : super._view(parent._transport);
|
|
2029
2131
|
|
|
2132
|
+
/// THE SEAM (see [NomosBridge._eventPlane]): all event dispatch + plane state resolves on the PARENT's
|
|
2133
|
+
/// registries (chased to the root, so nested delegation stays correct). This ONE override is why the base
|
|
2134
|
+
/// class's Stream-returning methods work unmodified on a session — no per-method overrides.
|
|
2135
|
+
@override
|
|
2136
|
+
NomosBridge get _eventPlane => parent._eventPlane;
|
|
2137
|
+
|
|
2138
|
+
/// Base implementations scope law-state (compat / onLawChanged) to THIS workspace via this.
|
|
2139
|
+
@override
|
|
2140
|
+
String? get _sessionName => workspace;
|
|
2141
|
+
|
|
2030
2142
|
/// Sibling workspaces resolve on the shared plane — delegate to the parent's registry (never nest a
|
|
2031
2143
|
/// session inside a session).
|
|
2032
2144
|
@override
|
|
@@ -2058,58 +2170,11 @@ class NomosSession extends NomosBridge {
|
|
|
2058
2170
|
@override
|
|
2059
2171
|
Future<void> dispose() async {}
|
|
2060
2172
|
|
|
2061
|
-
//
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
@override
|
|
2067
|
-
Stream<List<dynamic>> watchById(String id, [String? session]) =>
|
|
2068
|
-
parent.watchById(id, session ?? workspace);
|
|
2069
|
-
@override
|
|
2070
|
-
Stream<List<dynamic>> watchSpatial(String spatialId, NomosBbox bbox,
|
|
2071
|
-
[String? session]) =>
|
|
2072
|
-
parent.watchSpatial(spatialId, bbox, session ?? workspace);
|
|
2073
|
-
@override
|
|
2074
|
-
Stream<List<dynamic>> watchActivity({String? base, String? session}) =>
|
|
2075
|
-
parent.watchActivity(base: base, session: session ?? workspace);
|
|
2076
|
-
@override
|
|
2077
|
-
Stream<Map<String, Object?>> watchCompatibility(
|
|
2078
|
-
Map<String, Object?> requirement,
|
|
2079
|
-
[String? session]) =>
|
|
2080
|
-
parent.watchCompatibility(requirement, session ?? workspace);
|
|
2081
|
-
@override
|
|
2082
|
-
Stream<List<dynamic>> watchPendingConflicts([String? session]) =>
|
|
2083
|
-
parent.watchPendingConflicts(session ?? workspace);
|
|
2084
|
-
|
|
2085
|
-
// Live snapshots / streams reflect the shared plane — read them off the parent's controllers.
|
|
2086
|
-
// compat/onLawChanged are LAW-scoped, so a session answers for ITS workspace (falling back to the
|
|
2087
|
-
// plane-wide value when the runner's events carry no session key — older bundles).
|
|
2088
|
-
@override
|
|
2089
|
-
NomosCompat? get compat =>
|
|
2090
|
-
parent._lastCompatBySession[workspace] ?? parent.compat;
|
|
2091
|
-
@override
|
|
2092
|
-
Stream<NomosCompat> get onLawChanged => (parent._lawChangedBySession[
|
|
2093
|
-
workspace] ??=
|
|
2094
|
-
StreamController<NomosCompat>.broadcast()).stream;
|
|
2095
|
-
@override
|
|
2096
|
-
Map<String, Object?>? get lastRecovery => parent.lastRecovery;
|
|
2097
|
-
@override
|
|
2098
|
-
Stream<Map<String, Object?>> get syncEvents => parent.syncEvents;
|
|
2099
|
-
@override
|
|
2100
|
-
Stream<TransferProgress> get transferProgress => parent.transferProgress;
|
|
2101
|
-
@override
|
|
2102
|
-
Stream<SyncStatus> get syncStatus => parent.syncStatus;
|
|
2103
|
-
@override
|
|
2104
|
-
SyncStatus get currentStatus => parent.currentStatus;
|
|
2105
|
-
@override
|
|
2106
|
-
Stream<Map<String, Object?>> get signalEvents => parent.signalEvents;
|
|
2107
|
-
@override
|
|
2108
|
-
Stream<Map<String, Object?>> get flameEvents => parent.flameEvents;
|
|
2109
|
-
@override
|
|
2110
|
-
Stream<Map<String, Object?>> get spanEvents => parent.spanEvents;
|
|
2111
|
-
@override
|
|
2112
|
-
List<Map<String, Object?>> get recentSpans => parent.recentSpans;
|
|
2173
|
+
// NO per-method Stream/watch/snapshot overrides live here anymore — the base class routes ALL event
|
|
2174
|
+
// dispatch + plane state through [_eventPlane] (and law-scopes compat/onLawChanged via [_sessionName]),
|
|
2175
|
+
// and the watch ops still carry this session's workspace because their registration goes through the
|
|
2176
|
+
// overridden [_call]. Adding a `Stream`-returning override below is almost certainly re-opening the
|
|
2177
|
+
// dead-registry bug class — see `test/event_plane_seam_test.dart`.
|
|
2113
2178
|
}
|
|
2114
2179
|
|
|
2115
2180
|
/// THE BRIDGE-BACKED FOREIGN READER — a [NomosForeignReader] over a device's multi-workspace [NomosBridge]
|
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.51.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// ACQUIRE-SESSION: THE GENUINE CLOUD-OPEN LANE (task #86 gaps 1+2). The gauntlet proved the
|
|
2
|
+
// credential-FREE lanes (known workspace, single-flight); this test drives the one lane it could
|
|
3
|
+
// not: a workspace that is NOWHERE on the local plane, where [ifAbsentCredentials] MUST be invoked
|
|
4
|
+
// — exactly once, even under a concurrent double-acquire — and the connect is made with what it
|
|
5
|
+
// returns. Runs hermetically on the offline runner (the "cloud" is the runner's offline shim, so
|
|
6
|
+
// no network is ever contacted; what is under test is the acquire protocol, not the transport).
|
|
7
|
+
//
|
|
8
|
+
// This file also IS the gap-1 regression: it imports ONLY `nomos_headless.dart` and TYPES a
|
|
9
|
+
// `Future<NomosSessionCredentials> Function()` callback — before the barrel fix that did not compile.
|
|
10
|
+
@TestOn('vm')
|
|
11
|
+
library;
|
|
12
|
+
|
|
13
|
+
import 'dart:async';
|
|
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 _docsDeploy => '$_root/bench/scale/fixtures/surfaced/build/surfaced.deploy.json';
|
|
22
|
+
String get _runtimeDir =>
|
|
23
|
+
Platform.environment['NOMOS_DART_RUNTIME'] ??
|
|
24
|
+
'${Platform.environment['HOME'] ?? ''}/.holon/runtime';
|
|
25
|
+
|
|
26
|
+
String? _missingPrereq() {
|
|
27
|
+
if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
|
|
28
|
+
if (!File(_docsDeploy).existsSync()) {
|
|
29
|
+
return 'missing fixture $_docsDeploy — compile it: '
|
|
30
|
+
'(cd bench/scale/fixtures/surfaced && node ../../../../dsl/compile_package.mjs)';
|
|
31
|
+
}
|
|
32
|
+
if (!File('$_runtimeDir/holon.wasm').existsSync() ||
|
|
33
|
+
!File('$_runtimeDir/packages.json').existsSync()) {
|
|
34
|
+
return 'warm ~/.holon/runtime first (run any `githolon` command once)';
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Future<T> _leg<T>(String name, Future<T> Function() body) async {
|
|
40
|
+
final sw = Stopwatch()..start();
|
|
41
|
+
// ignore: avoid_print
|
|
42
|
+
print('▶ ACQUIRE $name …');
|
|
43
|
+
try {
|
|
44
|
+
final r = await body();
|
|
45
|
+
// ignore: avoid_print
|
|
46
|
+
print('✔ ACQUIRE $name (${sw.elapsedMilliseconds}ms)');
|
|
47
|
+
return r;
|
|
48
|
+
} catch (e, st) {
|
|
49
|
+
throw TestFailure('ACQUIRE $name FAILED after ${sw.elapsedMilliseconds}ms:\n$e\n$st');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
void main() {
|
|
54
|
+
test('acquireSession: the absent-workspace lane invokes ifAbsentCredentials exactly once',
|
|
55
|
+
() async {
|
|
56
|
+
final missing = _missingPrereq();
|
|
57
|
+
if (missing != null) {
|
|
58
|
+
markTestSkipped(missing);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
final harness = await NomosTestHarness.ephemeral(
|
|
62
|
+
deployJsonPaths: [_docsDeploy],
|
|
63
|
+
workspace: 'acquire-home',
|
|
64
|
+
nodeEntry: _repoEntry,
|
|
65
|
+
runtimeDir: _runtimeDir,
|
|
66
|
+
phaseTimeout: const Duration(seconds: 120),
|
|
67
|
+
);
|
|
68
|
+
try {
|
|
69
|
+
final plane = harness.bridge;
|
|
70
|
+
|
|
71
|
+
// ── LEG 1 (gap 1, compile-time + runtime): the callback TYPE is reachable from the headless
|
|
72
|
+
// barrel alone, and a credentials object constructs with the documented fields. ──
|
|
73
|
+
await _leg('leg 1: NomosSessionCredentials is exported from nomos_headless', () async {
|
|
74
|
+
// ignore: prefer_function_declarations_over_variables
|
|
75
|
+
final Future<NomosSessionCredentials> Function() typedCallback = () async =>
|
|
76
|
+
const NomosSessionCredentials(cloud: 'https://offline.invalid', allowUnborn: true);
|
|
77
|
+
final c = await typedCallback();
|
|
78
|
+
expect(c.cloud, 'https://offline.invalid');
|
|
79
|
+
expect(c.allowUnborn, isTrue);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── LEG 2: a genuinely-absent workspace WITHOUT a callback is a NAMED StateError, never a
|
|
83
|
+
// silent cloudless mount. ──
|
|
84
|
+
await _leg('leg 2: absent workspace without credentials refuses (StateError)', () async {
|
|
85
|
+
await expectLater(
|
|
86
|
+
plane.acquireSession('nowhere-at-all'),
|
|
87
|
+
throwsA(isA<StateError>().having((e) => e.toString(), 'message',
|
|
88
|
+
contains('ifAbsentCredentials'))));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── LEG 3: THE CLOUD-OPEN LANE — an absent workspace + a concurrent double-acquire: the
|
|
92
|
+
// callback fires EXACTLY ONCE (single-flight), the connect is made with its credentials,
|
|
93
|
+
// and both callers get the SAME mounted session. ──
|
|
94
|
+
var invocations = 0;
|
|
95
|
+
await _leg('leg 3: concurrent double-acquire → one credentials invocation, one handle',
|
|
96
|
+
() async {
|
|
97
|
+
Future<NomosSessionCredentials> creds() async {
|
|
98
|
+
invocations++;
|
|
99
|
+
return const NomosSessionCredentials(
|
|
100
|
+
cloud: 'https://offline.invalid', clientId: 'acquired-client', allowUnborn: true);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
final acquired = await Future.wait([
|
|
104
|
+
plane.acquireSession('cloud-opened-ws', ifAbsentCredentials: creds),
|
|
105
|
+
plane.acquireSession('cloud-opened-ws', ifAbsentCredentials: creds),
|
|
106
|
+
]).timeout(const Duration(seconds: 60),
|
|
107
|
+
onTimeout: () => throw StateError('the cloud-open acquire never completed'));
|
|
108
|
+
expect(invocations, 1,
|
|
109
|
+
reason: 'a concurrent double-acquire must invoke the credentials callback ONCE');
|
|
110
|
+
expect(identical(acquired[0], acquired[1]), isTrue,
|
|
111
|
+
reason: 'both concurrent acquirers get the SAME session handle');
|
|
112
|
+
expect(acquired[0].workspace, 'cloud-opened-ws');
|
|
113
|
+
final listed = await plane.listWorkspaces();
|
|
114
|
+
expect(listed.map((w) => w['session'].toString()), contains('cloud-opened-ws'),
|
|
115
|
+
reason: 'the acquired workspace is mounted on the plane');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── LEG 4: a re-acquire of the now-mounted workspace hits the fast path — the callback is
|
|
119
|
+
// NEVER invoked again. ──
|
|
120
|
+
await _leg('leg 4: re-acquire is credential-free (fast path)', () async {
|
|
121
|
+
final again = await plane.acquireSession('cloud-opened-ws',
|
|
122
|
+
ifAbsentCredentials: () async =>
|
|
123
|
+
fail('the workspace is mounted — credentials must not be requested'));
|
|
124
|
+
expect(again.workspace, 'cloud-opened-ws');
|
|
125
|
+
expect(invocations, 1);
|
|
126
|
+
});
|
|
127
|
+
} finally {
|
|
128
|
+
await harness.dispose().timeout(const Duration(seconds: 30),
|
|
129
|
+
onTimeout: () => throw StateError('harness disposal hung'));
|
|
130
|
+
}
|
|
131
|
+
}, timeout: const Timeout(Duration(minutes: 5)));
|
|
132
|
+
}
|