@githolon/dsl 0.76.0 → 0.77.1

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.
@@ -43,6 +43,34 @@ typedef NomosEngine = NomosBridge;
43
43
  /// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
44
44
  const int kWebviewBridgeVersion = 1;
45
45
 
46
+ /// THE EXPECTED RUNNER-BUNDLE VERSION FLOOR — the @githolon/client release this nomos_client version
47
+ /// ships alongside. The bundle stamps its own version into the ready frame; anything OLDER (or a
48
+ /// pre-stamp bundle, which reports nothing) is a stale baked asset and fails loudly at boot.
49
+ /// MUST move with the paired @githolon/client release — a release-hygiene test pairs it with the
50
+ /// runner stamp (the pubspec-pins pattern).
51
+ const String kExpectedRunnerBundleVersion = '0.77.1';
52
+
53
+ /// A STALE BAKED RUNNER BUNDLE: the app's built assets carry an older nomos-runner.js than this
54
+ /// nomos_client release ships — Flutter's build cache kept it across a pub upgrade. Thrown from
55
+ /// [NomosBridge.ready] at boot (loud, immediate, named) instead of letting one-version-old runtime
56
+ /// behavior masquerade as framework bugs.
57
+ class StaleRunnerBundle implements Exception {
58
+ StaleRunnerBundle({required this.found, required this.expected});
59
+
60
+ /// The bundle's self-reported version (null = a pre-stamp bundle, definitionally stale).
61
+ final String? found;
62
+
63
+ /// The minimum version this nomos_client release expects ([kExpectedRunnerBundleVersion]).
64
+ final String expected;
65
+
66
+ @override
67
+ String toString() =>
68
+ 'StaleRunnerBundle: the baked nomos-runner.js is ${found ?? "un-stamped (pre-0.77.1)"} but this '
69
+ 'nomos_client expects >= $expected. Flutter kept an old bundle across the upgrade — run '
70
+ '`flutter clean` and rebuild (verify with shasum of flutter_assets/.../nomos-runner.js), or pass '
71
+ 'NomosBridge(..., allowStaleRunnerBundle: true) for a deliberate mixed-version setup.';
72
+ }
73
+
46
74
  /// The platform channel to the WebView runner: send a JSON-shaped message; receive the runner's messages.
47
75
  /// Web impl jsifies/dartifies over `dart:js_util`; mobile impl JSON-encodes over a WebView JS-channel. The
48
76
  /// bridge treats messages as plain `Map<String, Object?>` — the transport owns the channel serialization.
@@ -743,13 +771,37 @@ class NomosBridge {
743
771
  final String _traceId = _newTraceId();
744
772
  late final StreamSubscription<Map<String, Object?>> _sub;
745
773
 
746
- NomosBridge(this._transport) {
774
+ NomosBridge(this._transport, {this.allowStaleRunnerBundle = false}) {
747
775
  _sub = _transport.messages.listen(_onMessage);
748
776
  }
749
777
 
778
+ /// Escape hatch for DELIBERATE mixed-version setups (framework dev, A/B probes): accept a runner
779
+ /// bundle older than [kExpectedRunnerBundleVersion] instead of failing [ready].
780
+ final bool allowStaleRunnerBundle;
781
+
782
+ /// null = acceptable; otherwise the typed boot error. "unbundled-dev" (the in-repo source lane)
783
+ /// always passes; a missing stamp means a pre-stamp (definitionally stale) bundle.
784
+ StaleRunnerBundle? _staleBundleError(Object? bundleVersion) {
785
+ final v = bundleVersion is String ? bundleVersion : null;
786
+ if (v == 'unbundled-dev') return null;
787
+ if (v != null && _semverAtLeast(v, kExpectedRunnerBundleVersion)) return null;
788
+ return StaleRunnerBundle(found: v, expected: kExpectedRunnerBundleVersion);
789
+ }
790
+
791
+ static bool _semverAtLeast(String v, String min) {
792
+ List<int> parse(String s) =>
793
+ s.split('-').first.split('.').map((p) => int.tryParse(p) ?? 0).toList();
794
+ final a = parse(v), b = parse(min);
795
+ for (var i = 0; i < 3; i++) {
796
+ final x = i < a.length ? a[i] : 0, y = i < b.length ? b[i] : 0;
797
+ if (x != y) return x > y;
798
+ }
799
+ return true;
800
+ }
801
+
750
802
  /// A SESSION VIEW's super-constructor: shares the parent's [_transport] but does NOT subscribe (the parent
751
803
  /// owns the one message loop + all correlation). Only [NomosSession] uses it.
752
- NomosBridge._view(this._transport);
804
+ NomosBridge._view(this._transport) : allowStaleRunnerBundle = false;
753
805
 
754
806
  static Random _traceRandom() {
755
807
  try {
@@ -798,6 +850,18 @@ class NomosBridge {
798
850
  }
799
851
  final event = m['event'];
800
852
  if (event == 'ready') {
853
+ // THE STALE-BAKED-BUNDLE CHECK: Flutter's build system can silently keep an OLD runner bundle
854
+ // baked into an app across a correct pub upgrade — invisible version skew that cost a real
855
+ // tenant two archaeology hunts. The bundle stamps its release version into the ready frame;
856
+ // an older-than-expected (or missing — i.e. pre-stamp) bundle fails LOUDLY at boot with the
857
+ // remedy, instead of misbehaving subtly at runtime. "unbundled-dev" (the in-repo source lane
858
+ // the test harness drives) always passes; `allowStaleRunnerBundle: true` on the constructor is
859
+ // the deliberate-mixed-versions escape hatch.
860
+ final stale = _staleBundleError(m['bundleVersion']);
861
+ if (stale != null && !allowStaleRunnerBundle) {
862
+ if (!_ready.isCompleted) _ready.completeError(stale);
863
+ return;
864
+ }
801
865
  if (!_ready.isCompleted) _ready.complete();
802
866
  return;
803
867
  }
@@ -17,7 +17,7 @@ class FakeRunner implements BridgeTransport {
17
17
 
18
18
  FakeRunner({this.resident = const []}) {
19
19
  scheduleMicrotask(
20
- () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
20
+ () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready', 'bundleVersion': 'unbundled-dev'}));
21
21
  }
22
22
 
23
23
  int get connectCount => sent.where((m) => m['op'] == 'connect').length;
@@ -0,0 +1,40 @@
1
+ // THE VERSION-PAIRING TRIPWIRE (the pubspec-pins pattern): kExpectedRunnerBundleVersion must equal
2
+ // the @githolon/client version this repo is about to release (cloud/web-client/package.json) — a
3
+ // client release without the Dart floor bump (or vice versa) fails the suite, so the stale-bundle
4
+ // check can neither lag (missing real staleness) nor lead (refusing the current bundle).
5
+ import 'dart:convert';
6
+ import 'dart:io';
7
+
8
+ import 'package:nomos_client/nomos_headless.dart';
9
+ import 'package:test/test.dart';
10
+
11
+ void main() {
12
+ test('kExpectedRunnerBundleVersion pairs with cloud/web-client/package.json', () {
13
+ final f = File('${Directory.current.path}/../../cloud/web-client/package.json');
14
+ if (!f.existsSync()) {
15
+ markTestSkipped('monorepo layout not present (published-package test run)');
16
+ return;
17
+ }
18
+ final v = (jsonDecode(f.readAsStringSync()) as Map)['version'] as String;
19
+ if (kExpectedRunnerBundleVersion == v) return; // released state: exact pair
20
+ // PENDING-RELEASE state: the floor may lead package.json by exactly the bump a queued
21
+ // changeset will apply (the changesets flow bumps package.json only when the version PR
22
+ // merges). Anything else — lagging, or leading with no changeset queued — is drift.
23
+ final changesets = Directory('${Directory.current.path}/../../.changeset')
24
+ .listSync()
25
+ .whereType<File>()
26
+ .where((c) => c.path.endsWith('.md') && !c.path.endsWith('README.md'))
27
+ .where((c) => c.readAsStringSync().contains('@githolon/client'));
28
+ expect(changesets, isNotEmpty,
29
+ reason: 'kExpectedRunnerBundleVersion ($kExpectedRunnerBundleVersion) != @githolon/client '
30
+ '($v) and NO client changeset is queued — bump the floor WITH the client release '
31
+ '(the stale-bundle floor must move with the bundle it ships beside)');
32
+ List<int> parse(String x) =>
33
+ x.split('.').map((p) => int.tryParse(p) ?? 0).toList();
34
+ final a = parse(kExpectedRunnerBundleVersion), b = parse(v);
35
+ final leads = a[0] > b[0] ||
36
+ (a[0] == b[0] && (a[1] > b[1] || (a[1] == b[1] && a[2] > b[2])));
37
+ expect(leads, isTrue,
38
+ reason: 'the floor must LEAD (never lag) package.json while a release is queued');
39
+ });
40
+ }
@@ -7,7 +7,7 @@ import { createInterface } from "node:readline";
7
7
  const post = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
8
8
  const wedgeOp = process.env.NOMOS_WEDGE_OP || "connect"; // default: wedge the KEYED reconnect (co2's shape)
9
9
  const wedgeOnlyKeyed = process.env.NOMOS_WEDGE_ONLY_KEYED !== "0";
10
- post({ v: 1, event: "ready" });
10
+ post({ v: 1, event: "ready", bundleVersion: "unbundled-dev" });
11
11
  const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
12
12
  rl.on("line", (line) => {
13
13
  const s = line.trim();
@@ -0,0 +1,65 @@
1
+ // THE STALE-BAKED-BUNDLE CHECK (the countermeasure to the co2 incidents where Flutter's build
2
+ // cache silently kept an old nomos-runner.js across a correct pub upgrade). ready carries the
3
+ // bundle's self-stamped version; the bridge refuses stale/pre-stamp bundles LOUDLY at boot.
4
+ import 'dart:async';
5
+
6
+ import 'package:nomos_client/nomos_headless.dart';
7
+ import 'package:test/test.dart';
8
+
9
+ class _StampedRunner implements BridgeTransport {
10
+ _StampedRunner(this.stamp) {
11
+ scheduleMicrotask(() => _incoming.add({
12
+ 'v': kWebviewBridgeVersion,
13
+ 'event': 'ready',
14
+ if (stamp != null) 'bundleVersion': stamp,
15
+ }));
16
+ }
17
+ final String? stamp;
18
+ final _incoming = StreamController<Map<String, Object?>>.broadcast();
19
+ @override
20
+ Stream<Map<String, Object?>> get messages => _incoming.stream;
21
+ @override
22
+ void send(Map<String, Object?> message) {}
23
+ }
24
+
25
+ void main() {
26
+ test('a PRE-STAMP bundle (no bundleVersion) fails ready with StaleRunnerBundle', () async {
27
+ final b = NomosBridge(_StampedRunner(null));
28
+ await expectLater(b.ready, throwsA(isA<StaleRunnerBundle>()));
29
+ });
30
+
31
+ test('an OLDER stamp fails ready, and the error names found/expected + the remedy', () async {
32
+ final b = NomosBridge(_StampedRunner('0.53.1'));
33
+ Object? err;
34
+ try {
35
+ await b.ready;
36
+ } catch (e) {
37
+ err = e;
38
+ }
39
+ expect(err, isA<StaleRunnerBundle>());
40
+ final s = err.toString();
41
+ expect(s, contains('0.53.1'));
42
+ expect(s, contains(kExpectedRunnerBundleVersion));
43
+ expect(s, contains('flutter clean'));
44
+ });
45
+
46
+ test('the CURRENT stamp passes', () async {
47
+ final b = NomosBridge(_StampedRunner(kExpectedRunnerBundleVersion));
48
+ await b.ready; // completes
49
+ });
50
+
51
+ test('a NEWER stamp passes (forward-compatible)', () async {
52
+ final b = NomosBridge(_StampedRunner('99.0.0'));
53
+ await b.ready;
54
+ });
55
+
56
+ test('unbundled-dev (the in-repo source lane) always passes', () async {
57
+ final b = NomosBridge(_StampedRunner('unbundled-dev'));
58
+ await b.ready;
59
+ });
60
+
61
+ test('allowStaleRunnerBundle is the deliberate escape hatch', () async {
62
+ final b = NomosBridge(_StampedRunner('0.1.0'), allowStaleRunnerBundle: true);
63
+ await b.ready;
64
+ });
65
+ }
@@ -0,0 +1,98 @@
1
+ // REGRESSION (co2, 0.76 offer-ack decoupling): the generated watch must emit the locally
2
+ // committed projection promptly after an offer's acknowledgement — with NO subsequent bridge op.
3
+ // co2's Flutter AppSim (NodeRunnerTransport → node-entry → realm sessions → watchById → Riverpod)
4
+ // saw the post-offer tick NEVER arrive after upgrading to @githolon/client 0.76 / nomos_client 0.52.
5
+ @TestOn('vm')
6
+ library;
7
+
8
+ import 'dart:async';
9
+ import 'dart:io';
10
+
11
+ import 'package:nomos_client/nomos_headless.dart';
12
+ import 'package:test/test.dart';
13
+
14
+ String get _root => '${Directory.current.path}/../..';
15
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
16
+ String get _gbDeploy => '$_root/examples/guestbook/build/guestbook.deploy.json';
17
+ String get _runtimeDir =>
18
+ Platform.environment['NOMOS_DART_RUNTIME'] ??
19
+ '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
20
+
21
+ String? _missingPrereq() {
22
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
23
+ if (!File(_gbDeploy).existsSync()) {
24
+ return 'missing $_gbDeploy — compile it (cd examples/guestbook && npx nomos-compile)';
25
+ }
26
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
27
+ !File('$_runtimeDir/packages.json').existsSync()) {
28
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
29
+ }
30
+ return null;
31
+ }
32
+
33
+ void main() {
34
+ test('watch emits the post-offer projection under COMPLETE SILENCE (no follow-up ops)',
35
+ timeout: const Timeout(Duration(minutes: 4)), () async {
36
+ final missing = _missingPrereq();
37
+ if (missing != null) {
38
+ // ignore: avoid_print
39
+ print('SKIP: $missing');
40
+ return;
41
+ }
42
+ final harness = await NomosTestHarness.ephemeral(
43
+ deployJsonPaths: [_gbDeploy],
44
+ workspace: 'stall-ws',
45
+ nodeEntry: _repoEntry,
46
+ resolveFrom: '$_root/cloud/web-client',
47
+ runtimeDir: _runtimeDir,
48
+ onDiagnostic: (l) {}, // keep output small
49
+ );
50
+ addTearDown(harness.dispose);
51
+ final s = harness.bridge;
52
+
53
+ // 1. create one entry, find its id.
54
+ await s.offerDirective(
55
+ domain: 'guestbook',
56
+ directiveId: 'signGuestbook',
57
+ payload: {
58
+ 'author': 'ada',
59
+ 'message': 'hi',
60
+ 'mood': 'delighted',
61
+ 'signedAt': DateTime.now().toUtc().toIso8601String(),
62
+ });
63
+ final rows = await s.query('entriesByAuthor', {'author': 'ada'});
64
+ expect(rows.length, 1);
65
+ final id = (rows.first as Map)['id'] as String;
66
+
67
+ // 2. subscribe (the app's watchById lane) and wait for the initial tick.
68
+ final ticks = <List<dynamic>>[];
69
+ final gotInitial = Completer<void>();
70
+ final gotMutated = Completer<void>();
71
+ final sub = s.watchById(id).listen((r) {
72
+ ticks.add(r);
73
+ if (!gotInitial.isCompleted) {
74
+ gotInitial.complete();
75
+ return;
76
+ }
77
+ final tags = ((r.first as Map)['data'] as Map)['tags'];
78
+ if (tags is List && tags.contains('t-after') && !gotMutated.isCompleted) {
79
+ gotMutated.complete();
80
+ }
81
+ });
82
+ addTearDown(sub.cancel);
83
+ await gotInitial.future.timeout(const Duration(seconds: 20),
84
+ onTimeout: () => throw StateError('initial watch tick never arrived'));
85
+
86
+ // 3. THE TEST — mutate the watched row, then COMPLETE SILENCE: no further bridge ops.
87
+ // The post-ack deferred nudge (0.76) must still deliver the tick.
88
+ await s.offerDirective(
89
+ domain: 'guestbook',
90
+ directiveId: 'tagEntry',
91
+ payload: {'entryId': id, 'tags': ['t-after']});
92
+ await gotMutated.future.timeout(const Duration(seconds: 8), onTimeout: () {
93
+ throw StateError(
94
+ 'REGRESSION: the post-offer watch tick never arrived under silence '
95
+ '(ticks seen: ${ticks.length})');
96
+ });
97
+ });
98
+ }
@@ -20,7 +20,7 @@ class FakeRunner implements BridgeTransport {
20
20
  FakeRunner() {
21
21
  // the runner announces readiness at construction.
22
22
  scheduleMicrotask(
23
- () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
23
+ () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready', 'bundleVersion': 'unbundled-dev'}));
24
24
  }
25
25
 
26
26
  @override
@@ -183,7 +183,7 @@ class ReplayTransport implements BridgeTransport {
183
183
  Map<String, Object?>? lastRequest;
184
184
  ReplayTransport(this.result) {
185
185
  scheduleMicrotask(
186
- () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
186
+ () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready', 'bundleVersion': 'unbundled-dev'}));
187
187
  }
188
188
  @override
189
189
  Stream<Map<String, Object?>> get messages => _incoming.stream;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.76.0",
3
+ "version": "0.77.1",
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",