@githolon/dsl 0.117.1 → 0.119.11

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.
Files changed (37) hide show
  1. package/assets/operation-contract.wasm +0 -0
  2. package/compile_proof.mjs +16 -0
  3. package/dart/CHANGELOG.md +45 -0
  4. package/dart/lib/src/application_status.dart +58 -2
  5. package/dart/lib/src/peer_application_streams.dart +7 -1
  6. package/dart/lib/src/reception/dart_reception.dart +92 -12
  7. package/dart/lib/src/reception/reception_session.dart +10 -2
  8. package/dart/lib/src/reception/workspace_preparation_report.dart +50 -41
  9. package/dart/lib/src/replica_readiness.dart +200 -50
  10. package/dart/lib/src/runner/node-entry.mjs +2481 -1022
  11. package/dart/lib/src/sync_status_projection.dart +346 -4
  12. package/dart/lib/src/sync_status_reducer.dart +44 -0
  13. package/dart/lib/src/test_harness.dart +1 -0
  14. package/dart/lib/src/typed_watch_registry.dart +67 -7
  15. package/dart/pubspec.lock +1 -1
  16. package/dart/pubspec.yaml +2 -2
  17. package/dart/test/copy_repair_status_test.dart +238 -0
  18. package/dart/test/reads_never_wait_test.dart +387 -0
  19. package/dart/test/replica_readiness_test.dart +223 -109
  20. package/dart/test/sync_status_reducer_test.dart +39 -0
  21. package/dart/test/typed_watch_registry_test.dart +69 -0
  22. package/dart/test/unshared_work_status_test.dart +269 -0
  23. package/dart/test/workspace_preparation_upkeep_test.dart +40 -6
  24. package/dart_types/CHANGELOG.md +18 -0
  25. package/dart_types/lib/src/proto/nomos/kernel/v1/kernel.pb.dart +320 -85
  26. package/dart_types/lib/src/proto/nomos/runtime/v2/correspondence_desk.pb.dart +57 -1
  27. package/dart_types/lib/src/proto/nomos/runtime/v2/maintenance_desk.pb.dart +14 -0
  28. package/dart_types/lib/src/proto/nomos/runtime/v2/observability_desk.pbenum.dart +25 -1
  29. package/dart_types/lib/src/runtime_outcome.dart +17 -0
  30. package/dart_types/pubspec.yaml +1 -1
  31. package/package.json +5 -2
  32. package/src/codegen_ts.ts +7 -8
  33. package/src/compile_package_main.ts +11 -4
  34. package/src/reception_unit_artifact.ts +105 -0
  35. package/src/reception_unit_artifact_contract.ts +220 -0
  36. package/src/reception_unit_context.ts +47 -0
  37. package/src/reception_unit_source.ts +205 -0
@@ -35,6 +35,56 @@ final class TransferProgress {
35
35
  );
36
36
  }
37
37
 
38
+ /// What this device holds of a workspace for reading (Jack's rule, 11 Oct 2026: the UI never waits
39
+ /// on the network). A read answers at once from what is on the device; when nothing servable is
40
+ /// here yet it answers empty, and this says why, so a presentation shows "downloading" or
41
+ /// "updating" beside what it has instead of a spinner over everything.
42
+ enum LocalCopyReadiness {
43
+ /// Reads answer from this device's copy.
44
+ ready,
45
+
46
+ /// Never on this device: its FIRST download is under way (Jack, 11 Oct 2026). Reads answer empty
47
+ /// and writes are refused at once until it is here. One-time per workspace per device: a landed
48
+ /// copy persists across kills, updates and reboots, so this never returns for it. Once the copy
49
+ /// lands it is [arriving] (its indexes are being prepared) until a read answers from it.
50
+ firstDownload,
51
+
52
+ /// Not on this device yet: never fetched, fetched or refetched with nothing servable yet, or
53
+ /// still opening. The copy's sync was asked for as the application's own work; reads and
54
+ /// watches answer again when it lands.
55
+ arriving,
56
+
57
+ /// On this device, but its read index is being rebuilt under the law its head has adopted, and
58
+ /// this application cannot decode the index it replaces. Reads answer again once it is rebuilt.
59
+ updating,
60
+
61
+ /// On this device, but the copy is DAMAGED and is being repaired: its local Git history is
62
+ /// incomplete (a commit the kernel needs is missing, `commit … unreadable`), the copy cannot be
63
+ /// read, or it is being refetched from its provider. Reads answer what they can (often empty);
64
+ /// writes cannot be admitted against it. A sync round over such a copy may still "complete"
65
+ /// (nothing pulled, head unchanged), so a completed round is NOT evidence that this workspace is
66
+ /// in sync. Derived from a read answering `localReplicaNotReady` with an incomplete-history or
67
+ /// refetching reason, and from a sync round failing because a commit of this copy is unreadable.
68
+ /// Cleared when a read answers from the copy again, or its copy is replaced by a refetch.
69
+ repairing,
70
+ }
71
+
72
+ /// Why a "not readable here yet" answer or a failed round means the copy is damaged and being
73
+ /// repaired, rather than arriving for the first time. Matches the Peer Office's own words
74
+ /// (`local Git history is incomplete`, `cannot be read`, `being refetched`, `drop the local copy`)
75
+ /// and the kernel's (`commit <sha> unreadable`, `incomplete custody`).
76
+ final RegExp _damagedCopyWords = RegExp(
77
+ r'local Git history is incomplete|local copy of .* cannot be read|being refetched|'
78
+ r'drop the local copy|incomplete custody|commit [0-9a-f]{7,40} unreadable|'
79
+ r'nomos\.runtime\.local_copy_refetching',
80
+ caseSensitive: false,
81
+ );
82
+
83
+ /// True when [text] (an unavailable answer's detail, or a failed round's message) says this
84
+ /// device's copy of a workspace is damaged and being repaired.
85
+ bool nmsSaysCopyDamaged(String? text) =>
86
+ text != null && text.isNotEmpty && _damagedCopyWords.hasMatch(text);
87
+
38
88
  enum SyncPhase {
39
89
  checking,
40
90
  idle,
@@ -244,6 +294,57 @@ final class WorkspaceFollowStatus {
244
294
  !current && (custodianWarming?.isStalled(now) ?? false);
245
295
  }
246
296
 
297
+ /// Work a sync round left owed between this device and a workspace's server: changes made here
298
+ /// that have not reached it ([outbound]), or the server's changes this device has not taken in.
299
+ ///
300
+ /// A completed round is NOT evidence that owed work went away (Jack's iPad, 11 Oct 2026: a round
301
+ /// ended `deferred=1`, the next did nothing, pulled nothing and kept the head, and the app said
302
+ /// "in sync" while the estate was 16 commits behind). The projection keeps this until a round
303
+ /// shows progress: its offers admitted, its main advanced or pulled, its bookmark or head moved,
304
+ /// or the copy replaced by a refetch.
305
+ final class UnsharedSyncWork {
306
+ const UnsharedSyncWork({
307
+ required this.since,
308
+ required this.outbound,
309
+ this.detail,
310
+ this.localHead = '',
311
+ this.trackedHead = '',
312
+ });
313
+
314
+ /// When this peer first saw the work owed (the first round that left it).
315
+ final DateTime since;
316
+
317
+ /// The device holds changes above the server's bookmark that a round could not share. False:
318
+ /// the owed work is the server's, not yet received here.
319
+ final bool outbound;
320
+
321
+ /// The round's own explanation (a deferral, a blocked offer), never exception text.
322
+ final String? detail;
323
+
324
+ /// The heads the last owed round ended at: a later round that moves either one made progress.
325
+ final String localHead;
326
+ final String trackedHead;
327
+
328
+ /// How long the work has been owed at [now].
329
+ Duration age(DateTime now) {
330
+ final value = now.difference(since);
331
+ return value.isNegative ? Duration.zero : value;
332
+ }
333
+
334
+ @override
335
+ bool operator ==(Object other) =>
336
+ other is UnsharedSyncWork &&
337
+ other.since == since &&
338
+ other.outbound == outbound &&
339
+ other.detail == detail &&
340
+ other.localHead == localHead &&
341
+ other.trackedHead == trackedHead;
342
+
343
+ @override
344
+ int get hashCode =>
345
+ Object.hash(since, outbound, detail, localHead, trackedHead);
346
+ }
347
+
247
348
  /// Disposable UI projection of typed peer-runtime sync observations.
248
349
  final class SyncStatus {
249
350
  const SyncStatus({
@@ -267,10 +368,27 @@ final class SyncStatus {
267
368
  this.preparingWorkspaces = 0,
268
369
  this.retryingWorkspaces = 0,
269
370
  this.preparationActivity,
371
+ this.localCopy = LocalCopyReadiness.ready,
372
+ this.repairingCopies = const {},
373
+ this.unsharedWork = const {},
270
374
  });
271
375
 
272
376
  final SyncPhase phase;
273
377
 
378
+ /// Every workspace with sync work owed between this device and its server
379
+ /// ([UnsharedSyncWork]), the same on the application's status and on each session's. Status
380
+ /// only: a presentation must not count such a workspace as in sync.
381
+ final Map<String, UnsharedSyncWork> unsharedWork;
382
+
383
+ /// Every workspace whose copy on this device is damaged and being repaired
384
+ /// ([LocalCopyReadiness.repairing]), with when this peer first saw it so. Status only: a
385
+ /// presentation must not count such a workspace as in sync, and holds editing back for it.
386
+ final Map<String, DateTime> repairingCopies;
387
+
388
+ /// Session status only: whether this workspace's reads answered from a copy on this device, or
389
+ /// answered empty because it is still arriving or updating. Set by the reads themselves.
390
+ final LocalCopyReadiness localCopy;
391
+
274
392
  /// The live link to this workspace's upstream is open, so a settled status is current NOW:
275
393
  /// the upstream pushes every new frontier and an idle follow runs no round. On the
276
394
  /// application status: every connected workspace is settled and live.
@@ -350,6 +468,9 @@ final class SyncStatus {
350
468
  int? retryingWorkspaces,
351
469
  String? preparationActivity,
352
470
  bool clearPreparationActivity = false,
471
+ LocalCopyReadiness? localCopy,
472
+ Map<String, DateTime>? repairingCopies,
473
+ Map<String, UnsharedSyncWork>? unsharedWork,
353
474
  }) =>
354
475
  SyncStatus(
355
476
  phase: phase ?? this.phase,
@@ -374,9 +495,31 @@ final class SyncStatus {
374
495
  preparationActivity: clearPreparationActivity
375
496
  ? null
376
497
  : preparationActivity ?? this.preparationActivity,
498
+ localCopy: localCopy ?? this.localCopy,
499
+ repairingCopies: repairingCopies ?? this.repairingCopies,
500
+ unsharedWork: unsharedWork ?? this.unsharedWork,
377
501
  );
378
502
  }
379
503
 
504
+ bool _sameUnshared(
505
+ Map<String, UnsharedSyncWork> left, Map<String, UnsharedSyncWork> right) {
506
+ if (identical(left, right)) return true;
507
+ if (left.length != right.length) return false;
508
+ for (final entry in left.entries) {
509
+ if (right[entry.key] != entry.value) return false;
510
+ }
511
+ return true;
512
+ }
513
+
514
+ bool _sameRepairs(Map<String, DateTime> left, Map<String, DateTime> right) {
515
+ if (identical(left, right)) return true;
516
+ if (left.length != right.length) return false;
517
+ for (final entry in left.entries) {
518
+ if (right[entry.key] != entry.value) return false;
519
+ }
520
+ return true;
521
+ }
522
+
380
523
  bool _sameTransfer(TransferProgress? left, TransferProgress? right) =>
381
524
  identical(left, right) ||
382
525
  (left != null &&
@@ -427,7 +570,10 @@ bool _sameStatus(SyncStatus left, SyncStatus right) =>
427
570
  left.followReportAt == right.followReportAt &&
428
571
  left.preparingWorkspaces == right.preparingWorkspaces &&
429
572
  left.retryingWorkspaces == right.retryingWorkspaces &&
430
- left.preparationActivity == right.preparationActivity;
573
+ left.preparationActivity == right.preparationActivity &&
574
+ left.localCopy == right.localCopy &&
575
+ _sameRepairs(left.repairingCopies, right.repairingCopies) &&
576
+ _sameUnshared(left.unsharedWork, right.unsharedWork);
431
577
 
432
578
  /// One projection owner for application-wide and per-session sync status.
433
579
  /// It has no timers, networking, retries, credentials, or custody authority.
@@ -449,6 +595,172 @@ final class NmsSyncStatusProjection {
449
595
  int _preparingWorkspaces = 0;
450
596
  int _retryingWorkspaces = 0;
451
597
  String? _preparationActivity;
598
+ final Map<String, DateTime> _repairing = {};
599
+
600
+ /// Workspaces whose copy here is damaged and being repaired, with when that was first seen.
601
+ Map<String, DateTime> get repairingCopies => Map.unmodifiable(_repairing);
602
+
603
+ /// Records that [workspace]'s copy on this device is damaged and being repaired (status only).
604
+ /// Its session (if any) reports [LocalCopyReadiness.repairing] and every status carries it in
605
+ /// [SyncStatus.repairingCopies] until [markRepaired]. Idempotent: the first sighting's time stays.
606
+ void markRepairing(String workspace, {DateTime? at}) {
607
+ if (workspace.isEmpty || _repairing.containsKey(workspace)) return;
608
+ _repairing[workspace] = at ?? DateTime.now();
609
+ _republishRepairs(workspace);
610
+ }
611
+
612
+ /// [workspace]'s copy answers again (a read answered from it, or a refetch replaced it).
613
+ void markRepaired(String workspace) {
614
+ if (_repairing.remove(workspace) == null) return;
615
+ _republishRepairs(workspace);
616
+ }
617
+
618
+ final Map<String, UnsharedSyncWork> _unshared = {};
619
+
620
+ /// Workspaces whose owed record a deferral or a blocked receipt set: only progress clears it.
621
+ final Set<String> _heldBackByDeferral = {};
622
+
623
+ /// Sessions (and the application, under the empty key) whose idle round this projection showed
624
+ /// as syncing because work was owed: once it is no longer owed, they are idle again.
625
+ final Set<String> _heldBack = {};
626
+
627
+ /// Workspaces with sync work owed between this device and their server.
628
+ Map<String, UnsharedSyncWork> get unsharedWork => Map.unmodifiable(_unshared);
629
+
630
+ /// Folds one completed round of [workspace] into the owed-work record (status only).
631
+ ///
632
+ /// A round that leaves work owed (deferred, an offer it could not share, a blocked receipt,
633
+ /// still ahead of a server that has not admitted it) records it, keeping the first sighting's
634
+ /// time. A later round clears it only when it shows progress: offers admitted, main advanced or
635
+ /// pulled, the bookmark or the head moved, or the copy replaced. A round that did nothing at the
636
+ /// same heads leaves it owed. Returns whether the record changed.
637
+ bool observeRound(
638
+ String workspace,
639
+ nomos_protocol.SyncWorkspaceResponse result, {
640
+ DateTime? at,
641
+ String? detail,
642
+ }) {
643
+ if (workspace.isEmpty) return false;
644
+ final previous = _unshared[workspace];
645
+ final localHead = result.currentHead;
646
+ final trackedHead = result.previousHead;
647
+ final receipt = result.hasOfferReceipt() ? result.offerReceipt : null;
648
+ final admittedHere = receipt != null &&
649
+ receipt.hasAdmission() &&
650
+ receipt.admission.main.isNotEmpty &&
651
+ receipt.admission.main == localHead;
652
+ // Admitted remotely, only waiting for the server's seal: the work reached the server.
653
+ final awaitingSeal = result.stillAhead && admittedHere;
654
+ // The server was only opening the workspace (it restarted): it judged nothing, so this round
655
+ // found nothing owed. Its `deferred`/`directOfferDeferred` are the timed-retry reading for
656
+ // older readers (workspace_sync.ts #finishWarming). Counting them left a level workspace
657
+ // "not caught up - retrying" for ever: the next round levels over its stream, moves no head,
658
+ // and a round that did nothing never clears owed work. Local work above the bookmark is
659
+ // still owed (`stillAhead` with differing heads).
660
+ final warming = result.hasCustodianWarmingRetryAfterMillis();
661
+ // This copy's main IS the main it tracks and the round deferred nothing: what is outstanding
662
+ // is the server's own seal of that main, not anything this copy has yet to receive or send
663
+ // (workspace_sync.ts #present: `stillAhead` is also "the server's main is not durable yet").
664
+ // Prod web, 12 Oct 2026: after two workspaces' law was promoted, every reload said they
665
+ // "haven't caught up with the server - retrying" while the copy was level and edits flowed.
666
+ final levelAwaitingSeal = result.stillAhead &&
667
+ localHead.isNotEmpty &&
668
+ localHead == trackedHead &&
669
+ result.deferred == 0 &&
670
+ !result.directOfferDeferred;
671
+ final ahead = warming
672
+ ? localHead.isNotEmpty && localHead != trackedHead
673
+ : result.stillAhead && !levelAwaitingSeal;
674
+ final owed =
675
+ (!warming && (result.deferred > 0 || result.directOfferDeferred)) ||
676
+ (receipt?.hasBlocked() ?? false) ||
677
+ (ahead && !awaitingSeal);
678
+ final progressed = result.mainAdvanced ||
679
+ result.pulled ||
680
+ result.admitted > 0 ||
681
+ result.custodyReplaced ||
682
+ admittedHere ||
683
+ (previous != null &&
684
+ (previous.localHead != localHead ||
685
+ previous.trackedHead != trackedHead));
686
+ // A round the server (or a dependency) actually held back: counted deferrals or a blocked
687
+ // receipt. Only progress clears what such a round left owed (CAP-923, the iPad sequence).
688
+ final heldBack =
689
+ !warming && (result.deferred > 0 || (receipt?.hasBlocked() ?? false));
690
+ // A clean round at one head: this copy's main is the main it tracks, nothing deferred or
691
+ // blocked. It clears an inbound record no deferral set (a round answered "another round is
692
+ // converging this workspace", another device's admission heard before its round, a main
693
+ // not yet sealed): nothing was ever held back, so there is no progress to wait for.
694
+ final level = localHead.isNotEmpty && localHead == trackedHead;
695
+ if (!owed) {
696
+ if (previous == null) return false;
697
+ final settledLevel = level &&
698
+ !previous.outbound &&
699
+ !_heldBackByDeferral.contains(workspace);
700
+ if (!progressed && !settledLevel) return false;
701
+ _unshared.remove(workspace);
702
+ _heldBackByDeferral.remove(workspace);
703
+ } else {
704
+ if (heldBack) _heldBackByDeferral.add(workspace);
705
+ final next = UnsharedSyncWork(
706
+ since: previous?.since ?? at ?? DateTime.now(),
707
+ outbound: localHead.isNotEmpty && localHead != trackedHead,
708
+ detail: detail ?? previous?.detail,
709
+ localHead: localHead,
710
+ trackedHead: trackedHead,
711
+ );
712
+ if (previous == next) return false;
713
+ _unshared[workspace] = next;
714
+ }
715
+ _republishUnshared(workspace);
716
+ return true;
717
+ }
718
+
719
+ /// [workspace]'s owed work is resolved some other way (its copy replaced or dropped).
720
+ void clearUnshared(String workspace) {
721
+ _heldBackByDeferral.remove(workspace);
722
+ if (_unshared.remove(workspace) == null) return;
723
+ _republishUnshared(workspace);
724
+ }
725
+
726
+ void _republishUnshared(String workspace) {
727
+ SyncStatus released(
728
+ String key, SyncStatus status) =>
729
+ _heldBack.contains(key) &&
730
+ !(key.isEmpty
731
+ ? _unshared.isNotEmpty
732
+ : _unshared.containsKey(key)) &&
733
+ status.phase == SyncPhase.syncing
734
+ ? status.copyWith(phase: SyncPhase.idle, ahead: 0, behind: 0)
735
+ : status;
736
+ pushSession(workspace, released(workspace, statusFor(workspace)));
737
+ for (final other in _bySession.keys.toList(growable: false)) {
738
+ if (other != workspace)
739
+ pushSession(other, released(other, _bySession[other]!));
740
+ }
741
+ _pushApplication(released('', _current));
742
+ }
743
+
744
+ void _republishRepairs(String workspace) {
745
+ {
746
+ final session = statusFor(workspace);
747
+ final repairing = _repairing.containsKey(workspace);
748
+ pushSession(
749
+ workspace,
750
+ session.copyWith(
751
+ localCopy: repairing
752
+ ? LocalCopyReadiness.repairing
753
+ : session.localCopy == LocalCopyReadiness.repairing
754
+ ? LocalCopyReadiness.ready
755
+ : session.localCopy,
756
+ ),
757
+ );
758
+ }
759
+ for (final other in _bySession.keys.toList(growable: false)) {
760
+ if (other != workspace) pushSession(other, _bySession[other]!);
761
+ }
762
+ _pushApplication(_current);
763
+ }
452
764
 
453
765
  Stream<SyncStatus> get applicationStream => _application.stream;
454
766
  SyncStatus get applicationStatus => _current;
@@ -519,7 +831,22 @@ final class NmsSyncStatusProjection {
519
831
  foldApplication(connectedSessions);
520
832
  }
521
833
 
522
- void pushSession(String session, SyncStatus status) {
834
+ void pushSession(String session, SyncStatus input) {
835
+ // A damaged copy stays "repairing" whatever else a round or a read reports, until it is
836
+ // repaired: a completed round over it is not evidence that it is readable or writable.
837
+ var status = input.copyWith(
838
+ repairingCopies: repairingCopies, unsharedWork: unsharedWork);
839
+ if (_repairing.containsKey(session) &&
840
+ status.localCopy != LocalCopyReadiness.repairing) {
841
+ status = status.copyWith(localCopy: LocalCopyReadiness.repairing);
842
+ }
843
+ // Owed work is never settled, whatever a later round that did nothing reports.
844
+ if (_unshared.containsKey(session) && status.phase == SyncPhase.idle) {
845
+ status = status.copyWith(phase: SyncPhase.syncing, clearCounts: true);
846
+ _heldBack.add(session);
847
+ } else {
848
+ _heldBack.remove(session);
849
+ }
523
850
  final previous = _bySession[session];
524
851
  if (previous != null && _sameStatus(previous, status)) return;
525
852
  _bySession[session] = status;
@@ -609,7 +936,14 @@ final class NmsSyncStatusProjection {
609
936
  .any((status) => !liveNow(status) && status.lastSyncedAt == null)) {
610
937
  lastSynced = null;
611
938
  }
612
- if (allLive) lastSynced = DateTime.now();
939
+ // Live is current now. While it STAYS live, keep the moment it became live: a fresh
940
+ // timestamp on every recompute made each follow or round report a "new" status, so every
941
+ // listener rebuilt for nothing (CAP-930: the app's status stream emitted 70x in 5 s).
942
+ if (allLive) {
943
+ lastSynced = _current.live && _current.lastSyncedAt != null
944
+ ? _current.lastSyncedAt
945
+ : DateTime.now();
946
+ }
613
947
 
614
948
  _pushApplication(
615
949
  SyncStatus(
@@ -635,7 +969,15 @@ final class NmsSyncStatusProjection {
635
969
  );
636
970
  }
637
971
 
638
- void _pushApplication(SyncStatus status) {
972
+ void _pushApplication(SyncStatus input) {
973
+ var status = input.copyWith(
974
+ repairingCopies: repairingCopies, unsharedWork: unsharedWork);
975
+ if (_unshared.isNotEmpty && status.phase == SyncPhase.idle) {
976
+ status = status.copyWith(phase: SyncPhase.syncing, clearCounts: true);
977
+ _heldBack.add('');
978
+ } else {
979
+ _heldBack.remove('');
980
+ }
639
981
  if (_sameStatus(_current, status)) return;
640
982
  _current = status;
641
983
  if (!_application.isClosed) _application.add(status);
@@ -96,6 +96,10 @@ SyncStatus reduceSyncCompletion(
96
96
  : SyncPhase.idle;
97
97
  final settled = phase == SyncPhase.idle;
98
98
  return SyncStatus(
99
+ // What the reads found of this copy is theirs to say; a round does not reset it.
100
+ localCopy: current.localCopy,
101
+ repairingCopies: current.repairingCopies,
102
+ unsharedWork: current.unsharedWork,
99
103
  phase: phase,
100
104
  ahead: settled ? 0 : null,
101
105
  behind: settled ? 0 : null,
@@ -109,6 +113,10 @@ SyncStatus reduceSyncCompletion(
109
113
  );
110
114
  }
111
115
 
116
+ /// The round's own words for work it left owed (a deferral or a blocked offer), or null.
117
+ String? nmsOwedWorkDetail(protocol.SyncWorkspaceResponse result) =>
118
+ result.hasOfferReceipt() ? _receiptIssue(result.offerReceipt) : null;
119
+
112
120
  /// Preserve the receiving office's explanation, not just its aggregate count.
113
121
  String? _receiptIssue(protocol.CustodyOfferReceipt receipt) {
114
122
  if (receipt.hasFailure()) return receipt.failure.message;
@@ -171,3 +179,39 @@ SyncStatus locallyAdmitted(SyncStatus current) => current.copyWith(
171
179
  clearCounts: true,
172
180
  clearError: true,
173
181
  );
182
+
183
+ final RegExp _workspaceInMessage = RegExp(r'\bworkspace=([A-Za-z0-9_.\-]+)');
184
+
185
+ /// The workspace whose copy on this device a completed round says is damaged and being repaired,
186
+ /// or null. A round fails with the kernel unable to read one of the copy's commits
187
+ /// (`nomos.runtime.component_failure` … `commit <sha> unreadable`, `incomplete custody`), or
188
+ /// completes waiting while the copy is refetched (`nomos.runtime.local_copy_refetching`). The
189
+ /// workspace is the failure's own, else the round's result's, else the one its message names,
190
+ /// else the session the event was scoped to. Status only: nothing is retried or repaired here.
191
+ String? nmsDamagedCopyOf(
192
+ protocol.SyncCompleted completed, {
193
+ String? session,
194
+ }) {
195
+ String? message;
196
+ String? workspace;
197
+ if (completed.hasFailure()) {
198
+ final failure = completed.failure;
199
+ message = '${failure.code}: ${failure.message}';
200
+ if (failure.hasWorkspace() && failure.workspace.isNotEmpty) {
201
+ workspace = failure.workspace;
202
+ }
203
+ } else if (completed.hasResult() && completed.result.hasDiscoveryError()) {
204
+ message = completed.result.discoveryError.message;
205
+ }
206
+ if (!nmsSaysCopyDamaged(message)) return null;
207
+ if (workspace == null &&
208
+ completed.hasResult() &&
209
+ completed.result.workspace.isNotEmpty) {
210
+ workspace = completed.result.workspace;
211
+ }
212
+ workspace ??= _workspaceInMessage.firstMatch(message!)?.group(1);
213
+ if (workspace == null && session != null && session.isNotEmpty) {
214
+ workspace = session;
215
+ }
216
+ return workspace;
217
+ }
@@ -1718,6 +1718,7 @@ class NomosTestHarness {
1718
1718
  !{
1719
1719
  NomosUnavailableReason.localReplicaNotReady,
1720
1720
  NomosUnavailableReason.workspaceOpening,
1721
+ NomosUnavailableReason.firstDownload,
1721
1722
  }.contains(unavailable.reason) ||
1722
1723
  DateTime.now().isAfter(deadline)) rethrow;
1723
1724
  onDiagnostic?.call(
@@ -5,7 +5,13 @@ import 'package:nomos_types/nomos_types.dart'
5
5
  show NomosErrorInfo, NomosUnavailable, nomosErrorFromInfo;
6
6
 
7
7
  import 'nomos_trace.dart' show NomosTrace;
8
- import 'replica_readiness.dart' show NmsReplicaWake;
8
+ import 'replica_readiness.dart'
9
+ show
10
+ NmsLocalCopyReport,
11
+ NmsReplicaWake,
12
+ nmsIsReplicaNotReady,
13
+ nmsLocalCopyReadiness;
14
+ import 'sync_status_projection.dart' show LocalCopyReadiness;
9
15
 
10
16
  final class NmsTypedWatchHandle<T> {
11
17
  const NmsTypedWatchHandle({required this.id, required this.stream});
@@ -16,9 +22,14 @@ final class NmsTypedWatchHandle<T> {
16
22
 
17
23
  /// Correlates generated Runtime v2 watch events with Dart stream listeners.
18
24
  /// It neither polls nor evaluates a query; the peer runtime owns both.
25
+ ///
26
+ /// A watch never waits before it shows something. When its workspace's copy is not readable
27
+ /// here yet (still arriving, or its index updating under a new law), the watch emits the query's
28
+ /// empty answer at once, reports the copy's readiness, and registers in the background; the
29
+ /// registration's first result then replaces the empty one.
19
30
  final class NmsTypedWatchRegistry {
20
31
  /// [synced] names each workspace whose sync round just completed; a watch
21
- /// waiting for its copy registers at that moment rather than after a sleep.
32
+ /// whose copy was not here yet registers at that moment rather than after a sleep.
22
33
  NmsTypedWatchRegistry({Stream<String>? synced}) : _synced = synced;
23
34
 
24
35
  final Stream<String>? _synced;
@@ -32,6 +43,8 @@ final class NmsTypedWatchRegistry {
32
43
  required Future<void> Function(String subscriptionId) start,
33
44
  required Future<void> Function(String subscriptionId) stop,
34
45
  String label = '',
46
+ nomos_protocol.WatchChanged? Function(String subscriptionId)? pending,
47
+ NmsLocalCopyReport? report,
35
48
  }) {
36
49
  if (_closed) throw StateError('Reception watch registry is closed');
37
50
  final id = '$session:watch:${++_sequence}';
@@ -40,6 +53,8 @@ final class NmsTypedWatchRegistry {
40
53
  traceFields: NomosTrace.enabled ? 'sub=$id q=$label' : '',
41
54
  synced: _synced,
42
55
  decode: decode,
56
+ pending: pending == null ? null : () => pending(id),
57
+ report: report,
43
58
  start: () {
44
59
  if (_closed) throw StateError('Reception watch registry is closed');
45
60
  _watches[id] = watch;
@@ -92,21 +107,35 @@ final class _TypedWatch<T> implements _RegisteredWatch {
92
107
  final String _traceFields;
93
108
  final Stream<String>? _synced;
94
109
  final T Function(nomos_protocol.WatchChanged) _decode;
110
+ final nomos_protocol.WatchChanged? Function()? _pending;
111
+ final NmsLocalCopyReport? _report;
95
112
  final Future<void> Function() _start;
96
113
  final Future<void> Function() _stop;
97
114
  late final StreamController<T> controller;
98
115
  Future<void>? _starting;
99
116
  Future<void>? _stopping;
100
117
 
118
+ /// The peer has delivered a result for this watch: it is registered, whatever its own
119
+ /// registration call last answered.
120
+ bool _delivered = false;
121
+
122
+ /// The empty answer was emitted while the copy is not readable here yet.
123
+ bool _emittedPending = false;
124
+ NmsReplicaWake? _wake;
125
+
101
126
  _TypedWatch({
102
127
  required String traceFields,
103
128
  required Stream<String>? synced,
104
129
  required T Function(nomos_protocol.WatchChanged) decode,
130
+ required nomos_protocol.WatchChanged? Function()? pending,
131
+ required NmsLocalCopyReport? report,
105
132
  required Future<void> Function() start,
106
133
  required Future<void> Function() stop,
107
134
  }) : _traceFields = traceFields,
108
135
  _synced = synced,
109
136
  _decode = decode,
137
+ _pending = pending,
138
+ _report = report,
110
139
  _start = start,
111
140
  _stop = stop {
112
141
  controller = StreamController<T>(
@@ -122,29 +151,58 @@ final class _TypedWatch<T> implements _RegisteredWatch {
122
151
  }
123
152
 
124
153
  /// A workspace that is still opening, syncing or building its read index answers a
125
- /// retryable [NomosUnavailable]. That is a wait, not a result: the watch registers once
126
- /// the replica is ready instead of ending its stream on a state that has already passed.
154
+ /// retryable [NomosUnavailable]. That is not a result to end the stream on, and it is not a
155
+ /// reason to show nothing: the watch emits the query's empty answer at once (when it has one),
156
+ /// reports the copy as arriving or updating, and keeps registering in the background. Each
157
+ /// re-ask is paced by [NmsReplicaWake] (cut short by the workspace's sync) and keeps the
158
+ /// workspace urgent in the peer's background queue; nobody waits on it.
127
159
  Future<void> _startWhenReady() async {
128
- final wake = NmsReplicaWake(_synced);
160
+ final wake = _wake = NmsReplicaWake(_synced);
129
161
  try {
130
162
  for (var attempt = 0;; attempt++) {
131
163
  wake.asking();
132
164
  try {
133
- return await _start();
165
+ await _start();
166
+ return;
134
167
  } on NomosUnavailable catch (unavailable) {
135
168
  if (!unavailable.retryable) rethrow;
169
+ if (nmsIsReplicaNotReady(unavailable)) {
170
+ if (!_delivered) _report?.call(nmsLocalCopyReadiness(unavailable));
171
+ _emitPending();
172
+ }
173
+ if (_delivered || controller.isClosed || _stopping != null) return;
136
174
  await wake.wait(unavailable, attempt);
137
- if (controller.isClosed || _stopping != null) return;
175
+ if (_delivered || controller.isClosed || _stopping != null) return;
138
176
  }
139
177
  }
140
178
  } finally {
179
+ _wake = null;
141
180
  await wake.close();
142
181
  }
143
182
  }
144
183
 
184
+ void _emitPending() {
185
+ if (_emittedPending || _delivered || controller.isClosed) return;
186
+ final pending = _pending?.call();
187
+ if (pending == null) return;
188
+ _emittedPending = true;
189
+ try {
190
+ controller.add(_decode(pending));
191
+ } catch (error, stackTrace) {
192
+ controller.addError(error, stackTrace);
193
+ }
194
+ }
195
+
145
196
  @override
146
197
  void deliver(nomos_protocol.WatchChanged changed) {
147
198
  if (controller.isClosed) return;
199
+ if (!changed.hasFailure() && !_delivered) {
200
+ // Registered (possibly by a registration the peer finished after answering "not ready"):
201
+ // stop re-asking, and the copy now answers from this device.
202
+ _delivered = true;
203
+ _wake?.wakeNow();
204
+ _report?.call(LocalCopyReadiness.ready);
205
+ }
148
206
  if (changed.hasFailure()) {
149
207
  controller.addError(
150
208
  nomosErrorFromInfo(NomosErrorInfo(
@@ -177,6 +235,8 @@ final class _TypedWatch<T> implements _RegisteredWatch {
177
235
  }
178
236
 
179
237
  Future<void> _cancel() => _stopping ??= () async {
238
+ // A registration pacing its next ask stops now rather than at its timer.
239
+ _wake?.wakeNow();
180
240
  if (_starting == null) return;
181
241
  await _starting;
182
242
  await _stop();
package/dart/pubspec.lock CHANGED
@@ -222,7 +222,7 @@ packages:
222
222
  path: "../dart_types"
223
223
  relative: true
224
224
  source: path
225
- version: "0.15.0"
225
+ version: "0.15.2"
226
226
  package_config:
227
227
  dependency: "direct main"
228
228
  description:
package/dart/pubspec.yaml CHANGED
@@ -7,7 +7,7 @@ repository: https://github.com/Captain-App/nomos2
7
7
  description: >-
8
8
  Nomos 2 Dart client — generated domain values, intent payloads and application
9
9
  lifecycle types over the typed Nomos kernel protocol and USDA custody model.
10
- version: 0.70.4
10
+ version: 0.70.11
11
11
 
12
12
  environment:
13
13
  sdk: ^3.5.0
@@ -18,7 +18,7 @@ dependencies:
18
18
  # The pure-data core (wire types, offer codec, NomosReads, schema validation, error
19
19
  # info). nomos_client depends on and re-exports it, so existing imports are unchanged.
20
20
  # For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
21
- nomos_types: ^0.15.0
21
+ nomos_types: ^0.15.2
22
22
  fixnum: ^1.1.1
23
23
  json_schema: ^5.2.2
24
24
  crypto: ^3.0.0