@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.
- package/assets/operation-contract.wasm +0 -0
- package/compile_proof.mjs +16 -0
- package/dart/CHANGELOG.md +45 -0
- package/dart/lib/src/application_status.dart +58 -2
- package/dart/lib/src/peer_application_streams.dart +7 -1
- package/dart/lib/src/reception/dart_reception.dart +92 -12
- package/dart/lib/src/reception/reception_session.dart +10 -2
- package/dart/lib/src/reception/workspace_preparation_report.dart +50 -41
- package/dart/lib/src/replica_readiness.dart +200 -50
- package/dart/lib/src/runner/node-entry.mjs +2481 -1022
- package/dart/lib/src/sync_status_projection.dart +346 -4
- package/dart/lib/src/sync_status_reducer.dart +44 -0
- package/dart/lib/src/test_harness.dart +1 -0
- package/dart/lib/src/typed_watch_registry.dart +67 -7
- package/dart/pubspec.lock +1 -1
- package/dart/pubspec.yaml +2 -2
- package/dart/test/copy_repair_status_test.dart +238 -0
- package/dart/test/reads_never_wait_test.dart +387 -0
- package/dart/test/replica_readiness_test.dart +223 -109
- package/dart/test/sync_status_reducer_test.dart +39 -0
- package/dart/test/typed_watch_registry_test.dart +69 -0
- package/dart/test/unshared_work_status_test.dart +269 -0
- package/dart/test/workspace_preparation_upkeep_test.dart +40 -6
- package/dart_types/CHANGELOG.md +18 -0
- package/dart_types/lib/src/proto/nomos/kernel/v1/kernel.pb.dart +320 -85
- package/dart_types/lib/src/proto/nomos/runtime/v2/correspondence_desk.pb.dart +57 -1
- package/dart_types/lib/src/proto/nomos/runtime/v2/maintenance_desk.pb.dart +14 -0
- package/dart_types/lib/src/proto/nomos/runtime/v2/observability_desk.pbenum.dart +25 -1
- package/dart_types/lib/src/runtime_outcome.dart +17 -0
- package/dart_types/pubspec.yaml +1 -1
- package/package.json +5 -2
- package/src/codegen_ts.ts +7 -8
- package/src/compile_package_main.ts +11 -4
- package/src/reception_unit_artifact.ts +105 -0
- package/src/reception_unit_artifact_contract.ts +220 -0
- package/src/reception_unit_context.ts +47 -0
- 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
|
|
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
|
-
|
|
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
|
|
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'
|
|
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
|
-
///
|
|
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
|
|
126
|
-
///
|
|
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
|
-
|
|
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
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.
|
|
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.
|
|
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
|