@githolon/dsl 0.103.3 → 0.103.5
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 +15 -0
- package/dart/lib/src/runner/node-entry.mjs +411 -149
- package/dart/lib/src/runner/offline-install.mjs +222 -99
- package/dart/lib/src/session_bound.dart +111 -2
- package/dart/lib/src/test_harness.dart +117 -66
- package/dart/lib/src/webview_bridge.dart +726 -61
- package/dart/pubspec.lock +1 -1
- package/dart/pubspec.yaml +4 -5
- package/dart/test/acquire_session_test.dart +62 -5
- package/dart/test/cloud_bound_topology_test.dart +59 -38
- package/dart/test/session_bound_test.dart +25 -10
- package/dart/test/webview_bridge_test.dart +357 -9
- package/dart_types/CHANGELOG.md +5 -0
- package/dart_types/lib/nomos_types.dart +1 -0
- package/dart_types/lib/src/git_object_id.dart +37 -0
- package/dart_types/pubspec.yaml +1 -1
- package/package.json +1 -1
- package/runtime/generated-application-host.js +425 -145
- package/src/births.ts +16 -1
- package/src/business_birth.ts +67 -46
- package/src/codegen_dart.ts +68 -12
- package/src/codegen_dart_application.ts +114 -19
- package/src/codegen_dart_client.ts +149 -6
- package/src/codegen_proof.ts +16 -9
- package/src/codegen_provider_dart.ts +5 -0
- package/src/codegen_ts.ts +115 -19
- package/src/compile_package_main.ts +142 -40
- package/src/fields.ts +43 -0
- package/src/framework/attested_receipts.ts +34 -1
- package/src/framework/domain_lifecycle.ts +140 -83
- package/src/framework/genesis.ts +2 -1
- package/src/framework/home.ts +20 -45
- package/src/framework/workspaces.ts +62 -29
- package/src/idof_lint.ts +65 -51
- package/src/index.ts +21 -1
- package/src/intent.ts +11 -1
- package/src/law_graph.ts +81 -11
- package/src/law_graph_usda.ts +45 -54
- package/src/relation_schema.ts +18 -0
- package/src/shape_compat.ts +3 -0
- package/src/stable_ids_for_module.ts +7 -1
- package/src/value_object.ts +40 -0
|
@@ -41,7 +41,8 @@ import 'package:nomos_types/src/versioned_nomos_ref.dart'
|
|
|
41
41
|
import 'package:nomos_types/src/foreign_ref.dart'
|
|
42
42
|
show NomosForeignReader, NomosForeignWorkspaceReader;
|
|
43
43
|
|
|
44
|
-
import 'session_bound.dart'
|
|
44
|
+
import 'session_bound.dart'
|
|
45
|
+
show DomainClientModelAdoptionRequired, DomainNotInstalledForSession;
|
|
45
46
|
|
|
46
47
|
/// THE ENGINE (architecture/session_bound_clients.md): the multi-workspace RUNTIME — mount/connect,
|
|
47
48
|
/// the session registry, sync, custody + recovery, the home ceremony, and [NomosBridge.follow]
|
|
@@ -61,6 +62,143 @@ final class NomosPage<T> {
|
|
|
61
62
|
final bool done;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/// Typed authority evidence carried by an offer that plans exactly one child birth.
|
|
66
|
+
/// It is offer metadata, never a tenant directive field. The bridge converts it to the
|
|
67
|
+
/// generated protocol's JSON-shaped transport only at the WebView boundary.
|
|
68
|
+
final class NomosBirthCertificate {
|
|
69
|
+
const NomosBirthCertificate({
|
|
70
|
+
required this.child,
|
|
71
|
+
required this.founder,
|
|
72
|
+
required this.verified,
|
|
73
|
+
required this.parentKey,
|
|
74
|
+
required this.parentId,
|
|
75
|
+
required this.epoch,
|
|
76
|
+
required this.signature,
|
|
77
|
+
this.verifiedVia,
|
|
78
|
+
this.idpToken,
|
|
79
|
+
this.capturedClock,
|
|
80
|
+
this.authProviderIssuer,
|
|
81
|
+
this.authProviderJwks,
|
|
82
|
+
this.authProviderAudience,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
final String child;
|
|
86
|
+
final String founder;
|
|
87
|
+
final bool verified;
|
|
88
|
+
final List<int> parentKey;
|
|
89
|
+
final String parentId;
|
|
90
|
+
final int epoch;
|
|
91
|
+
final List<int> signature;
|
|
92
|
+
final String? verifiedVia;
|
|
93
|
+
final String? idpToken;
|
|
94
|
+
final int? capturedClock;
|
|
95
|
+
final String? authProviderIssuer;
|
|
96
|
+
final String? authProviderJwks;
|
|
97
|
+
final String? authProviderAudience;
|
|
98
|
+
|
|
99
|
+
static String _string(
|
|
100
|
+
Map<String, Object?> wire, String camel, String snake) =>
|
|
101
|
+
(wire[camel] ?? wire[snake] ?? '').toString();
|
|
102
|
+
|
|
103
|
+
static List<int> _bytes(
|
|
104
|
+
Map<String, Object?> wire, String camel, String snake) =>
|
|
105
|
+
((wire[camel] ?? wire[snake]) as List? ?? const <Object?>[])
|
|
106
|
+
.map((value) => (value as num).toInt())
|
|
107
|
+
.toList(growable: false);
|
|
108
|
+
|
|
109
|
+
factory NomosBirthCertificate.fromWire(Map<String, Object?> wire) =>
|
|
110
|
+
NomosBirthCertificate(
|
|
111
|
+
child: _string(wire, 'child', 'child'),
|
|
112
|
+
founder: _string(wire, 'founder', 'founder'),
|
|
113
|
+
verified: wire['verified'] == true,
|
|
114
|
+
parentKey: _bytes(wire, 'parentKey', 'parent_key'),
|
|
115
|
+
parentId: _string(wire, 'parentId', 'parent_id'),
|
|
116
|
+
epoch: (wire['epoch'] as num?)?.toInt() ?? 0,
|
|
117
|
+
signature: _bytes(wire, 'signature', 'signature'),
|
|
118
|
+
verifiedVia: (wire['verifiedVia'] ?? wire['verified_via'])?.toString(),
|
|
119
|
+
idpToken: (wire['idpToken'] ?? wire['idp_token'])?.toString(),
|
|
120
|
+
capturedClock:
|
|
121
|
+
((wire['capturedClock'] ?? wire['captured_clock']) as num?)
|
|
122
|
+
?.toInt(),
|
|
123
|
+
authProviderIssuer:
|
|
124
|
+
(wire['authProviderIssuer'] ?? wire['auth_provider_issuer'])
|
|
125
|
+
?.toString(),
|
|
126
|
+
authProviderJwks:
|
|
127
|
+
(wire['authProviderJwks'] ?? wire['auth_provider_jwks'])
|
|
128
|
+
?.toString(),
|
|
129
|
+
authProviderAudience:
|
|
130
|
+
(wire['authProviderAudience'] ?? wire['auth_provider_audience'])
|
|
131
|
+
?.toString(),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
Map<String, Object?> toWire() => <String, Object?>{
|
|
135
|
+
'child': child,
|
|
136
|
+
'founder': founder,
|
|
137
|
+
'verified': verified,
|
|
138
|
+
'parentKey': parentKey,
|
|
139
|
+
'parentId': parentId,
|
|
140
|
+
'epoch': epoch,
|
|
141
|
+
'signature': signature,
|
|
142
|
+
if (verifiedVia != null) 'verifiedVia': verifiedVia,
|
|
143
|
+
if (idpToken != null) 'idpToken': idpToken,
|
|
144
|
+
if (capturedClock != null) 'capturedClock': capturedClock,
|
|
145
|
+
if (authProviderIssuer != null)
|
|
146
|
+
'authProviderIssuer': authProviderIssuer,
|
|
147
|
+
if (authProviderJwks != null) 'authProviderJwks': authProviderJwks,
|
|
148
|
+
if (authProviderAudience != null)
|
|
149
|
+
'authProviderAudience': authProviderAudience,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Facts signed by [NomosBridge.signBirthCert]. The secret remains a separate argument and never
|
|
154
|
+
/// enters this value, so callers cannot accidentally serialize private key material with the cert.
|
|
155
|
+
final class NomosBirthCertificateSpec {
|
|
156
|
+
const NomosBirthCertificateSpec({
|
|
157
|
+
required this.child,
|
|
158
|
+
required this.founder,
|
|
159
|
+
required this.verified,
|
|
160
|
+
required this.parentKey,
|
|
161
|
+
required this.parentId,
|
|
162
|
+
required this.epoch,
|
|
163
|
+
this.verifiedVia,
|
|
164
|
+
this.idpToken,
|
|
165
|
+
this.capturedClock,
|
|
166
|
+
this.authProviderIssuer,
|
|
167
|
+
this.authProviderJwks,
|
|
168
|
+
this.authProviderAudience,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
final String child;
|
|
172
|
+
final String founder;
|
|
173
|
+
final bool verified;
|
|
174
|
+
final String parentKey;
|
|
175
|
+
final String parentId;
|
|
176
|
+
final int epoch;
|
|
177
|
+
final String? verifiedVia;
|
|
178
|
+
final String? idpToken;
|
|
179
|
+
final int? capturedClock;
|
|
180
|
+
final String? authProviderIssuer;
|
|
181
|
+
final String? authProviderJwks;
|
|
182
|
+
final String? authProviderAudience;
|
|
183
|
+
|
|
184
|
+
Map<String, Object?> toWire() => <String, Object?>{
|
|
185
|
+
'child': child,
|
|
186
|
+
'founder': founder,
|
|
187
|
+
'verified': verified,
|
|
188
|
+
'parentKey': parentKey,
|
|
189
|
+
'parentId': parentId,
|
|
190
|
+
'epoch': epoch,
|
|
191
|
+
if (verifiedVia != null) 'verifiedVia': verifiedVia,
|
|
192
|
+
if (idpToken != null) 'idpToken': idpToken,
|
|
193
|
+
if (capturedClock != null) 'capturedClock': capturedClock,
|
|
194
|
+
if (authProviderIssuer != null)
|
|
195
|
+
'authProviderIssuer': authProviderIssuer,
|
|
196
|
+
if (authProviderJwks != null) 'authProviderJwks': authProviderJwks,
|
|
197
|
+
if (authProviderAudience != null)
|
|
198
|
+
'authProviderAudience': authProviderAudience,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
64
202
|
/// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
|
|
65
203
|
const int kWebviewBridgeVersion = 1;
|
|
66
204
|
|
|
@@ -116,12 +254,14 @@ class TransferProgress {
|
|
|
116
254
|
bool get done => total != null && received >= total!;
|
|
117
255
|
factory TransferProgress.fromMessage(Map<String, Object?> m) =>
|
|
118
256
|
TransferProgress(
|
|
119
|
-
received: (m['received']
|
|
120
|
-
total: (m['total']
|
|
257
|
+
received: _wireInteger(m['received']) ?? 0,
|
|
258
|
+
total: _wireInteger(m['total']),
|
|
121
259
|
kind: m['kind']?.toString() ?? 'delta',
|
|
122
260
|
);
|
|
123
261
|
}
|
|
124
262
|
|
|
263
|
+
int? _wireInteger(Object? value) => value is num ? value.toInt() : null;
|
|
264
|
+
|
|
125
265
|
/// One opaque point in this workspace's verified history. Applications use it as the handle passed to a
|
|
126
266
|
/// generated client's `past.at(moment)`; [id] is framework identity, not a business field and need never be
|
|
127
267
|
/// parsed. The remaining fields make a timeline useful without exposing commit or event-sourcing ceremony.
|
|
@@ -856,8 +996,59 @@ class NomosEncryption {
|
|
|
856
996
|
|
|
857
997
|
/// The result of [NomosBridge.offerCreates]: the new local [head], every aggregate the plan minted, and —
|
|
858
998
|
/// for a `.births()` directive — the child workspace(s) the offer brought into being on this local plane.
|
|
999
|
+
enum NomosOperationPhase { locallyAccepted, admittedToRemoteCustody, converged }
|
|
1000
|
+
|
|
1001
|
+
/// Product-facing progress for one accepted write. Transport topology and custody identifiers remain private.
|
|
1002
|
+
final class NomosOperationStatus {
|
|
1003
|
+
const NomosOperationStatus(this.phase, {this.updatedAt});
|
|
1004
|
+
|
|
1005
|
+
final NomosOperationPhase phase;
|
|
1006
|
+
final DateTime? updatedAt;
|
|
1007
|
+
|
|
1008
|
+
bool get isAdmitted => phase != NomosOperationPhase.locallyAccepted;
|
|
1009
|
+
bool get isConverged => phase == NomosOperationPhase.converged;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/// An awaitable witness for one generated write.
|
|
1013
|
+
///
|
|
1014
|
+
/// Creation means the write is already accepted into durable local custody. [admitted] completes when the
|
|
1015
|
+
/// receiving group accepts it into shared custody; [converged] completes only after this application has
|
|
1016
|
+
/// adopted that shared position. Callers never inspect workspaces, URLs, credentials, headers or Git heads.
|
|
1017
|
+
final class NomosOperationReceipt {
|
|
1018
|
+
NomosOperationReceipt._()
|
|
1019
|
+
: _status =
|
|
1020
|
+
const NomosOperationStatus(NomosOperationPhase.locallyAccepted),
|
|
1021
|
+
_changes = StreamController<NomosOperationStatus>.broadcast();
|
|
1022
|
+
|
|
1023
|
+
NomosOperationStatus _status;
|
|
1024
|
+
final StreamController<NomosOperationStatus> _changes;
|
|
1025
|
+
|
|
1026
|
+
NomosOperationStatus get current => _status;
|
|
1027
|
+
|
|
1028
|
+
Stream<NomosOperationStatus> watch() async* {
|
|
1029
|
+
yield _status;
|
|
1030
|
+
yield* _changes.stream;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
Future<void> get admitted => _status.isAdmitted
|
|
1034
|
+
? Future<void>.value()
|
|
1035
|
+
: watch().firstWhere((status) => status.isAdmitted).then((_) {});
|
|
1036
|
+
|
|
1037
|
+
Future<void> get converged => _status.isConverged
|
|
1038
|
+
? Future<void>.value()
|
|
1039
|
+
: watch().firstWhere((status) => status.isConverged).then((_) {});
|
|
1040
|
+
|
|
1041
|
+
void _advance(NomosOperationPhase phase) {
|
|
1042
|
+
if (phase.index <= _status.phase.index) return;
|
|
1043
|
+
_status = NomosOperationStatus(phase, updatedAt: DateTime.now());
|
|
1044
|
+
if (!_changes.isClosed) _changes.add(_status);
|
|
1045
|
+
if (phase == NomosOperationPhase.converged) _changes.close();
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
859
1049
|
class NomosOfferOutcome {
|
|
860
1050
|
final String head;
|
|
1051
|
+
final String intentId;
|
|
861
1052
|
final List<NomosCreated> created;
|
|
862
1053
|
|
|
863
1054
|
/// Every business aggregate touched by the sealed intent, including creates,
|
|
@@ -882,15 +1073,25 @@ class NomosOfferOutcome {
|
|
|
882
1073
|
/// local offer lane.
|
|
883
1074
|
final Map<String, Object?>? gatewayVerdict;
|
|
884
1075
|
|
|
1076
|
+
final NomosOperationReceipt? _receipt;
|
|
1077
|
+
|
|
1078
|
+
/// Awaitable remote-custody witness for this exact write.
|
|
1079
|
+
NomosOperationReceipt get receipt =>
|
|
1080
|
+
_receipt ??
|
|
1081
|
+
(throw StateError(
|
|
1082
|
+
'This legacy offer outcome has no operation receipt; regenerate the application client.'));
|
|
1083
|
+
|
|
885
1084
|
const NomosOfferOutcome({
|
|
886
1085
|
required this.head,
|
|
1086
|
+
this.intentId = '',
|
|
887
1087
|
required this.created,
|
|
888
1088
|
this.affected = const [],
|
|
889
1089
|
this.workspace = '',
|
|
890
1090
|
this.born = const [],
|
|
891
1091
|
this.birthOutcomes = const [],
|
|
892
1092
|
this.gatewayVerdict,
|
|
893
|
-
|
|
1093
|
+
NomosOperationReceipt? receipt,
|
|
1094
|
+
}) : _receipt = receipt;
|
|
894
1095
|
|
|
895
1096
|
/// TRUE when the offer COMMITTED — it advanced the ledger to a new head (a write landed and/or a child
|
|
896
1097
|
/// was born). A REFUSED offer never yields an outcome (it throws / surfaces its typed law error), so a
|
|
@@ -1013,12 +1214,14 @@ class NomosBridge {
|
|
|
1013
1214
|
late final StreamController<SyncStatus> _syncStatusCtrl =
|
|
1014
1215
|
StreamController<SyncStatus>.broadcast(
|
|
1015
1216
|
onListen: _startStatusPoll,
|
|
1016
|
-
onCancel:
|
|
1017
|
-
if (!_syncStatusCtrl.hasListener) _stopStatusPoll();
|
|
1018
|
-
},
|
|
1217
|
+
onCancel: _stopStatusPollIfUnused,
|
|
1019
1218
|
);
|
|
1020
1219
|
SyncStatus _status = const SyncStatus();
|
|
1220
|
+
final Map<String, SyncStatus> _statusBySession = {};
|
|
1221
|
+
final Map<String, StreamController<SyncStatus>> _syncStatusBySession = {};
|
|
1021
1222
|
Timer? _metricsTimer;
|
|
1223
|
+
Future<void>? _metricsPoll;
|
|
1224
|
+
bool _metricsPollAgain = false;
|
|
1022
1225
|
final Completer<void> _ready = Completer<void>();
|
|
1023
1226
|
// THE LOCAL PLANE'S WORKSPACES. A NomosBridge is a device's local holarchy plane; each connected
|
|
1024
1227
|
// workspace is a live session on it. [_sessionNames] tracks what [connect] has opened; [_sessionViews]
|
|
@@ -1036,6 +1239,11 @@ class NomosBridge {
|
|
|
1036
1239
|
// without asking tenant code for cloud URLs, tokens, sessions or signing keys. Kept per source
|
|
1037
1240
|
// session so one local plane may safely host applications from different clouds/identities.
|
|
1038
1241
|
final Map<String, _NomosRetainedCloudAccess> _sessionCloudAccess = {};
|
|
1242
|
+
// One model-decision policy follows the generated application through its
|
|
1243
|
+
// child sessions. The callback receives only the typed review object; child
|
|
1244
|
+
// addresses, archives, credentials and lifecycle directives remain private.
|
|
1245
|
+
final Map<String, Future<void> Function(DomainClientModelAdoptionRequired)>
|
|
1246
|
+
_sessionModelChangeHandlers = {};
|
|
1039
1247
|
// [acquireSession]'s per-workspace single-flight: workspace → the in-flight open every concurrent
|
|
1040
1248
|
// acquirer joins (removed when it completes; a live session then hits the fast path instead).
|
|
1041
1249
|
final Map<String, Future<NomosSession>> _acquiring = {};
|
|
@@ -1044,12 +1252,28 @@ class NomosBridge {
|
|
|
1044
1252
|
// several references, but the runner gets one autoSync registration for that session.
|
|
1045
1253
|
final Set<String> _autoSyncSessions = {};
|
|
1046
1254
|
final Map<String, Future<void>> _autoSyncStarting = {};
|
|
1255
|
+
final Map<String, Future<void>> _credentialRefreshStarting = {};
|
|
1256
|
+
// Exact-write custody receipts. The stable intent identity is framework-private; generated application
|
|
1257
|
+
// methods expose only the typed receipt and its product-language lifecycle.
|
|
1258
|
+
final Map<String, Map<String, NomosOperationReceipt>> _operationReceipts = {};
|
|
1259
|
+
final Map<String, NomosOperationReceipt> _birthReceipts = {};
|
|
1047
1260
|
int _seq = 0;
|
|
1048
1261
|
final String _traceId = _newTraceId();
|
|
1049
1262
|
late final StreamSubscription<Map<String, Object?>> _sub;
|
|
1050
1263
|
|
|
1051
1264
|
NomosBridge(this._transport, {this.allowStaleRunnerBundle = false}) {
|
|
1052
|
-
_sub = _transport.messages.listen(_onMessage);
|
|
1265
|
+
_sub = _transport.messages.listen(_onMessage, onError: _onTransportError);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
void _onTransportError(Object error, StackTrace stackTrace) {
|
|
1269
|
+
if (!_ready.isCompleted) _ready.completeError(error, stackTrace);
|
|
1270
|
+
final pending = _pending.values.toList(growable: false);
|
|
1271
|
+
_pending.clear();
|
|
1272
|
+
_pendingOps.clear();
|
|
1273
|
+
_pendingTraces.clear();
|
|
1274
|
+
for (final completer in pending) {
|
|
1275
|
+
if (!completer.isCompleted) completer.completeError(error, stackTrace);
|
|
1276
|
+
}
|
|
1053
1277
|
}
|
|
1054
1278
|
|
|
1055
1279
|
/// Escape hatch for DELIBERATE mixed-version setups (framework dev, A/B probes): accept a runner
|
|
@@ -1189,29 +1413,59 @@ class NomosBridge {
|
|
|
1189
1413
|
if (!_syncEvents.isClosed) _syncEvents.add(m);
|
|
1190
1414
|
if (event == 'sync') {
|
|
1191
1415
|
final result = (m['result'] as Map?)?.cast<String, Object?>();
|
|
1416
|
+
final session = m['session']?.toString();
|
|
1417
|
+
if (session != null && result != null) {
|
|
1418
|
+
_advanceOperationReceipts(session, result);
|
|
1419
|
+
if (_syncNeedsCredentialRefresh(m, result)) {
|
|
1420
|
+
unawaited(_refreshSessionCredential(session));
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1192
1423
|
if (m['ok'] == false) {
|
|
1193
1424
|
// A rejected/failed round is not a successful sync timestamp. Keep the stable typed envelope on
|
|
1194
1425
|
// telemetry; the app-ready status needs only one legible error and an honest error phase.
|
|
1195
1426
|
final detail = m['error']?.toString() ?? 'automatic sync failed';
|
|
1196
|
-
|
|
1197
|
-
|
|
1427
|
+
_updateScopedStatus(
|
|
1428
|
+
session,
|
|
1429
|
+
(current) => current.copyWith(
|
|
1430
|
+
phase: SyncPhase.error,
|
|
1431
|
+
clearTransfer: true,
|
|
1432
|
+
lastError: detail));
|
|
1198
1433
|
} else if (result?['offline'] == true) {
|
|
1199
1434
|
// Offline is expected transport debt. The durable local branch remains ahead and autoSync owns the
|
|
1200
1435
|
// retry; do not claim a completed sync or overwrite the previous successful timestamp.
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1436
|
+
_updateScopedStatus(
|
|
1437
|
+
session,
|
|
1438
|
+
(current) => current.copyWith(
|
|
1439
|
+
phase: SyncPhase.offline,
|
|
1440
|
+
clearTransfer: true,
|
|
1441
|
+
lastError:
|
|
1442
|
+
(result?['pushError'] as Map?)?['error']?.toString()));
|
|
1443
|
+
} else if (result?['pushFailed'] == true) {
|
|
1444
|
+
// The sync round itself completed, but shared custody refused the
|
|
1445
|
+
// push. `stillAhead` is durable debt, not success: surface the
|
|
1446
|
+
// cloud's typed refusal on the owning workspace so generated
|
|
1447
|
+
// `whenCurrent()` calls fail with the real cause instead of timing
|
|
1448
|
+
// out behind a generic saving state.
|
|
1449
|
+
final detail = _syncPushFailureDetail(result!);
|
|
1450
|
+
_updateScopedStatus(
|
|
1451
|
+
session,
|
|
1452
|
+
(current) => current.copyWith(
|
|
1453
|
+
phase: SyncPhase.error,
|
|
1454
|
+
clearTransfer: true,
|
|
1455
|
+
lastError: detail));
|
|
1205
1456
|
} else {
|
|
1206
1457
|
// A completed round → idle (the next metrics poll folds in residual ahead/behind), stamp the time.
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1458
|
+
_updateScopedStatus(
|
|
1459
|
+
session,
|
|
1460
|
+
(current) => current.copyWith(
|
|
1461
|
+
phase: SyncPhase.idle,
|
|
1462
|
+
clearTransfer: true,
|
|
1463
|
+
clearError: true,
|
|
1464
|
+
lastSyncedAt: DateTime.now()));
|
|
1212
1465
|
}
|
|
1213
1466
|
} else if (m['status'] == 'closed' || m['status'] == 'offline') {
|
|
1214
|
-
|
|
1467
|
+
_updateScopedStatus(m['session']?.toString(),
|
|
1468
|
+
(current) => current.copyWith(phase: SyncPhase.offline));
|
|
1215
1469
|
}
|
|
1216
1470
|
return;
|
|
1217
1471
|
}
|
|
@@ -1226,10 +1480,12 @@ class NomosBridge {
|
|
|
1226
1480
|
// LIVE pack byte-progress { received, total, kind } — the real loading bar (host Content-Length).
|
|
1227
1481
|
final tp = TransferProgress.fromMessage(m);
|
|
1228
1482
|
if (!_transferEvents.isClosed) _transferEvents.add(tp);
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1483
|
+
_updateScopedStatus(
|
|
1484
|
+
m['session']?.toString(),
|
|
1485
|
+
(current) => current.copyWith(
|
|
1486
|
+
phase: tp.done ? SyncPhase.syncing : SyncPhase.transferring,
|
|
1487
|
+
transfer: tp,
|
|
1488
|
+
clearTransfer: tp.done));
|
|
1233
1489
|
return;
|
|
1234
1490
|
}
|
|
1235
1491
|
if (event == 'trace') {
|
|
@@ -1241,8 +1497,11 @@ class NomosBridge {
|
|
|
1241
1497
|
// signal holon projection: ephemeral coordination such as peer frontiers / lag.
|
|
1242
1498
|
if (!_signalEvents.isClosed) _signalEvents.add(m);
|
|
1243
1499
|
// the doorbell says canonical main advanced → we're behind until the next pull lands.
|
|
1244
|
-
|
|
1245
|
-
|
|
1500
|
+
_updateScopedStatus(
|
|
1501
|
+
m['session']?.toString(),
|
|
1502
|
+
(current) => current.phase == SyncPhase.idle
|
|
1503
|
+
? current.copyWith(phase: SyncPhase.behind)
|
|
1504
|
+
: current);
|
|
1246
1505
|
return;
|
|
1247
1506
|
}
|
|
1248
1507
|
if (event == 'span') {
|
|
@@ -1330,6 +1589,102 @@ class NomosBridge {
|
|
|
1330
1589
|
}
|
|
1331
1590
|
}
|
|
1332
1591
|
|
|
1592
|
+
NomosOperationReceipt _registerOperationReceipt(
|
|
1593
|
+
String workspace, String intentId) {
|
|
1594
|
+
if (intentId.isEmpty) {
|
|
1595
|
+
throw StateError(
|
|
1596
|
+
'Nomos accepted a local write without returning its stable intent identity. '
|
|
1597
|
+
'The runtime and kernel release are incompatible.');
|
|
1598
|
+
}
|
|
1599
|
+
final plane = _eventPlane;
|
|
1600
|
+
// A locally accepted write is not current until custody convergence
|
|
1601
|
+
// advances its receipt. Mark its workspace immediately so a same-tick
|
|
1602
|
+
// `whenCurrent()` cannot return using the previous metrics snapshot.
|
|
1603
|
+
final current = plane._currentSessionStatus(workspace);
|
|
1604
|
+
plane._pushSessionStatus(
|
|
1605
|
+
workspace,
|
|
1606
|
+
current.copyWith(
|
|
1607
|
+
phase: SyncPhase.syncing,
|
|
1608
|
+
ahead: current.ahead + 1,
|
|
1609
|
+
clearError: true,
|
|
1610
|
+
),
|
|
1611
|
+
);
|
|
1612
|
+
plane._foldApplicationStatusFromSessions();
|
|
1613
|
+
return plane._operationReceipts
|
|
1614
|
+
.putIfAbsent(workspace, () => {})
|
|
1615
|
+
.putIfAbsent(intentId, NomosOperationReceipt._);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
void _advanceOperationReceipts(
|
|
1619
|
+
String workspace, Map<String, Object?> result) {
|
|
1620
|
+
final pending = _eventPlane._operationReceipts[workspace];
|
|
1621
|
+
if (pending == null || pending.isEmpty) return;
|
|
1622
|
+
|
|
1623
|
+
Iterable<String> ids(String key) => ((result[key] as List?) ?? const [])
|
|
1624
|
+
.whereType<String>()
|
|
1625
|
+
.where((value) => value.isNotEmpty);
|
|
1626
|
+
|
|
1627
|
+
for (final intentId in ids('admittedIntentIds')) {
|
|
1628
|
+
pending[intentId]?._advance(NomosOperationPhase.admittedToRemoteCustody);
|
|
1629
|
+
}
|
|
1630
|
+
for (final intentId in ids('convergedIntentIds')) {
|
|
1631
|
+
final receipt = pending.remove(intentId);
|
|
1632
|
+
receipt?._advance(NomosOperationPhase.converged);
|
|
1633
|
+
}
|
|
1634
|
+
if (pending.isEmpty) _eventPlane._operationReceipts.remove(workspace);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
bool _syncNeedsCredentialRefresh(
|
|
1638
|
+
Map<String, Object?> message, Map<String, Object?> result) {
|
|
1639
|
+
final push = (result['pushError'] as Map?)?.cast<String, Object?>();
|
|
1640
|
+
final status = push?['status'];
|
|
1641
|
+
if (status == 401 || status == 403) return true;
|
|
1642
|
+
final text =
|
|
1643
|
+
'${message['error'] ?? ''} ${push?['error'] ?? ''}'.toLowerCase();
|
|
1644
|
+
return text.contains('unauthenticated') ||
|
|
1645
|
+
text.contains('token expired') ||
|
|
1646
|
+
text.contains('invalid token');
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
String _syncPushFailureDetail(Map<String, Object?> result) {
|
|
1650
|
+
final push = (result['pushError'] as Map?)?.cast<String, Object?>();
|
|
1651
|
+
final status = push?['status'];
|
|
1652
|
+
final reason = push?['error'] ?? push?['body'];
|
|
1653
|
+
final prefix = status == null
|
|
1654
|
+
? 'Shared custody refused this workspace change'
|
|
1655
|
+
: 'Shared custody refused this workspace change (HTTP $status)';
|
|
1656
|
+
return reason == null || reason.toString().trim().isEmpty
|
|
1657
|
+
? prefix
|
|
1658
|
+
: '$prefix: $reason';
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
Future<void> _refreshSessionCredential(String workspace) {
|
|
1662
|
+
final plane = _eventPlane;
|
|
1663
|
+
return plane._credentialRefreshStarting.putIfAbsent(workspace, () async {
|
|
1664
|
+
try {
|
|
1665
|
+
final retained = plane._sessionCloudAccess[workspace];
|
|
1666
|
+
if (retained == null) return;
|
|
1667
|
+
final token = (await retained.signIn())?.trim() ?? '';
|
|
1668
|
+
if (token.isEmpty) {
|
|
1669
|
+
throw StateError(
|
|
1670
|
+
'Nomos could not refresh the signed-in identity for background sync.');
|
|
1671
|
+
}
|
|
1672
|
+
await plane.session(workspace)._call('refreshCredentials', {
|
|
1673
|
+
'authToken': token,
|
|
1674
|
+
});
|
|
1675
|
+
} finally {
|
|
1676
|
+
plane._credentialRefreshStarting.remove(workspace);
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/// The parent-write receipt which brought this child workspace into being, when it was born in this
|
|
1682
|
+
/// mounted application. Ordinary opens of pre-existing workspaces correctly have no creation receipt.
|
|
1683
|
+
NomosOperationReceipt? nomosInternalWorkspaceCreationReceipt() {
|
|
1684
|
+
final workspace = _sessionName;
|
|
1685
|
+
return workspace == null ? null : _eventPlane._birthReceipts[workspace];
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1333
1688
|
// ── the runtime-aware surface (the boundary the generated Dart client wraps) ──
|
|
1334
1689
|
|
|
1335
1690
|
/// THE PRIMARY MULTI-WORKSPACE ENTRY. Return a [NomosSession] handle pinned to [workspace] — the object a
|
|
@@ -1438,6 +1793,15 @@ class NomosBridge {
|
|
|
1438
1793
|
}));
|
|
1439
1794
|
_eventPlane._sessionNames
|
|
1440
1795
|
.add(ws); // the open succeeded — the session is real now
|
|
1796
|
+
// Opening succeeded, but scoped metrics have not yet proved that this
|
|
1797
|
+
// workspace is current. Unknown readiness must fail closed: otherwise a
|
|
1798
|
+
// generated `whenCurrent()` can observe the default idle value and return
|
|
1799
|
+
// before the first measurement arrives.
|
|
1800
|
+
_eventPlane._statusBySession.putIfAbsent(
|
|
1801
|
+
ws,
|
|
1802
|
+
() => const SyncStatus(phase: SyncPhase.syncing),
|
|
1803
|
+
);
|
|
1804
|
+
_eventPlane._foldApplicationStatusFromSessions();
|
|
1441
1805
|
final boundPrincipal = _principalFromToken(authToken);
|
|
1442
1806
|
if (boundPrincipal != null)
|
|
1443
1807
|
_eventPlane._sessionPrincipals[ws] = boundPrincipal;
|
|
@@ -1446,6 +1810,9 @@ class NomosBridge {
|
|
|
1446
1810
|
}
|
|
1447
1811
|
_eventPlane._lastRecovery =
|
|
1448
1812
|
(r['recovery'] as Map?)?.cast<String, Object?>();
|
|
1813
|
+
if (_eventPlane._syncStatusCtrl.hasListener) {
|
|
1814
|
+
unawaited(_eventPlane._pollMetricsOnce());
|
|
1815
|
+
}
|
|
1449
1816
|
}
|
|
1450
1817
|
|
|
1451
1818
|
/// The custody lifecycle stream (rebirth_and_custody_lifecycle.md) — a rebirth/divergence surfaces here as a
|
|
@@ -1580,6 +1947,7 @@ class NomosBridge {
|
|
|
1580
1947
|
// Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead of
|
|
1581
1948
|
// the connect-time one (the runner maps it onto `opts.signWith`). Same contract as [offerOutcome].
|
|
1582
1949
|
String? authorSecret,
|
|
1950
|
+
NomosBirthCertificate? birthCertificate,
|
|
1583
1951
|
}) async {
|
|
1584
1952
|
final r = _asMap(await _call('offerDirective', {
|
|
1585
1953
|
'domain': domain,
|
|
@@ -1592,6 +1960,8 @@ class NomosBridge {
|
|
|
1592
1960
|
if (force != null) 'force': force,
|
|
1593
1961
|
if (session != null) 'session': session,
|
|
1594
1962
|
if (authorSecret != null) 'authorSecret': authorSecret,
|
|
1963
|
+
if (birthCertificate != null)
|
|
1964
|
+
'birthCertificate': birthCertificate.toWire(),
|
|
1595
1965
|
}));
|
|
1596
1966
|
return r['head'].toString();
|
|
1597
1967
|
}
|
|
@@ -1614,6 +1984,7 @@ class NomosBridge {
|
|
|
1614
1984
|
bool? force,
|
|
1615
1985
|
String? session,
|
|
1616
1986
|
String? authorSecret,
|
|
1987
|
+
NomosBirthCertificate? birthCertificate,
|
|
1617
1988
|
}) =>
|
|
1618
1989
|
offerOutcome(
|
|
1619
1990
|
domain: domain,
|
|
@@ -1625,6 +1996,7 @@ class NomosBridge {
|
|
|
1625
1996
|
force: force,
|
|
1626
1997
|
session: session,
|
|
1627
1998
|
authorSecret: authorSecret,
|
|
1999
|
+
birthCertificate: birthCertificate,
|
|
1628
2000
|
);
|
|
1629
2001
|
|
|
1630
2002
|
/// THE ONE OUTCOME-RETURNING OFFER LANE — [offerDirective] answering the full typed [NomosOfferOutcome]
|
|
@@ -1644,6 +2016,7 @@ class NomosBridge {
|
|
|
1644
2016
|
// Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead
|
|
1645
2017
|
// of the connect-time one — the parent-directed lanes (sibling births after a signer repair) use it.
|
|
1646
2018
|
String? authorSecret,
|
|
2019
|
+
NomosBirthCertificate? birthCertificate,
|
|
1647
2020
|
}) async {
|
|
1648
2021
|
final Map<String, Object?> r;
|
|
1649
2022
|
// OFFER SPAN TAXONOMY (bridge legs): stamp the Dart-side send/receive around the op; the runner
|
|
@@ -1665,6 +2038,8 @@ class NomosBridge {
|
|
|
1665
2038
|
if (force != null) 'force': force,
|
|
1666
2039
|
if (session != null) 'session': session,
|
|
1667
2040
|
if (authorSecret != null) 'authorSecret': authorSecret,
|
|
2041
|
+
if (birthCertificate != null)
|
|
2042
|
+
'birthCertificate': birthCertificate.toWire(),
|
|
1668
2043
|
}));
|
|
1669
2044
|
} catch (e) {
|
|
1670
2045
|
throw await _wrapDomainNotInstalled(e,
|
|
@@ -1737,9 +2112,16 @@ class NomosBridge {
|
|
|
1737
2112
|
}
|
|
1738
2113
|
}
|
|
1739
2114
|
// The offer ran on this workspace (NomosRef) — stamp every minted ref with it so it is a complete locator.
|
|
1740
|
-
final ws = (r['workspace'] ?? '').toString();
|
|
2115
|
+
final ws = (r['workspace'] ?? sourceSession ?? '').toString();
|
|
2116
|
+
final intentId = (r['intentId'] ?? '').toString();
|
|
2117
|
+
final receipt = _registerOperationReceipt(ws, intentId);
|
|
2118
|
+
for (final child in bornNames) {
|
|
2119
|
+
_eventPlane._birthReceipts[child] = receipt;
|
|
2120
|
+
}
|
|
1741
2121
|
return NomosOfferOutcome(
|
|
1742
2122
|
head: r['head'].toString(),
|
|
2123
|
+
intentId: intentId,
|
|
2124
|
+
receipt: receipt,
|
|
1743
2125
|
workspace: ws,
|
|
1744
2126
|
created: ((r['created'] as List?) ?? const [])
|
|
1745
2127
|
.whereType<Map>()
|
|
@@ -1878,6 +2260,16 @@ class NomosBridge {
|
|
|
1878
2260
|
'acquireSession("$workspace"): the workspace is already bound to a different signed-in person');
|
|
1879
2261
|
}
|
|
1880
2262
|
plane._sessionPrincipals[workspace] = sourcePrincipal;
|
|
2263
|
+
final source = _sessionName;
|
|
2264
|
+
if (source != null) {
|
|
2265
|
+
final handler = plane._sessionModelChangeHandlers[source];
|
|
2266
|
+
if (handler != null) {
|
|
2267
|
+
plane._sessionModelChangeHandlers.putIfAbsent(
|
|
2268
|
+
workspace,
|
|
2269
|
+
() => handler,
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
1881
2273
|
}
|
|
1882
2274
|
|
|
1883
2275
|
Future<NomosSession> ready(NomosSession opened) async {
|
|
@@ -1909,7 +2301,9 @@ class NomosBridge {
|
|
|
1909
2301
|
return ready(session(workspace));
|
|
1910
2302
|
}
|
|
1911
2303
|
// Consult the plane's TRUTHFUL registry (mounted ∪ parked ∪ resident custody — the runner's
|
|
1912
|
-
// realm.list())
|
|
2304
|
+
// realm.list()). A resident child needs no clone, but a generated application still binds its
|
|
2305
|
+
// retained cloud/session proof here so continuous sync owns that child too. With no retained
|
|
2306
|
+
// credentials (an explicitly local tool/test), preserve the local-only open.
|
|
1913
2307
|
var resident = false;
|
|
1914
2308
|
try {
|
|
1915
2309
|
resident = (await listWorkspaces())
|
|
@@ -1918,11 +2312,26 @@ class NomosBridge {
|
|
|
1918
2312
|
// a pre-connect plane has no registry yet — fall through to the credentials decision below.
|
|
1919
2313
|
}
|
|
1920
2314
|
if (resident) {
|
|
1921
|
-
|
|
2315
|
+
if (ifAbsentCredentials == null) {
|
|
2316
|
+
await connect(
|
|
2317
|
+
workspace: workspace,
|
|
2318
|
+
session: workspace,
|
|
2319
|
+
allowUnborn: true,
|
|
2320
|
+
authorSecret: authorSecret);
|
|
2321
|
+
} else {
|
|
2322
|
+
final c = await ifAbsentCredentials();
|
|
2323
|
+
await connect(
|
|
2324
|
+
cloud: c.cloud,
|
|
1922
2325
|
workspace: workspace,
|
|
1923
2326
|
session: workspace,
|
|
2327
|
+
clientId: c.clientId,
|
|
2328
|
+
authToken: c.authToken,
|
|
2329
|
+
authorSecret: c.authorSecret ?? authorSecret,
|
|
2330
|
+
// The local birth may not have reached remote custody yet. Binding the cloud posture must
|
|
2331
|
+
// not turn that expected parent-first convergence window into an open failure.
|
|
1924
2332
|
allowUnborn: true,
|
|
1925
|
-
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
1926
2335
|
return ready(session(workspace));
|
|
1927
2336
|
}
|
|
1928
2337
|
if (ifAbsentCredentials == null) {
|
|
@@ -2171,6 +2580,21 @@ class NomosBridge {
|
|
|
2171
2580
|
.cast<String, Object?>();
|
|
2172
2581
|
}
|
|
2173
2582
|
|
|
2583
|
+
/// Framework-private generated-model staging seam. The generated client owns
|
|
2584
|
+
/// the packaged USDA candidate and the bound session authors the ordinary
|
|
2585
|
+
/// lifecycle offer; tenant code never handles transport bytes or routing.
|
|
2586
|
+
Future<void> nomosInternalStageGeneratedModel({
|
|
2587
|
+
required String domain,
|
|
2588
|
+
required String candidateHash,
|
|
2589
|
+
required List<int> lawUsdz,
|
|
2590
|
+
}) async {
|
|
2591
|
+
await _call('stageGeneratedModel', {
|
|
2592
|
+
'domain': domain,
|
|
2593
|
+
'candidateHash': candidateHash,
|
|
2594
|
+
'lawUsdz': base64Encode(lawUsdz),
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2174
2598
|
/// Framework-private exact-history restoration seam. Generated clients expose
|
|
2175
2599
|
/// `model.restorePrevious()` and a chained `decision.accept(reason)`; predecessor
|
|
2176
2600
|
/// identity, derived dispositions, heads and receipts stay below this bridge.
|
|
@@ -2249,14 +2673,11 @@ class NomosBridge {
|
|
|
2249
2673
|
/// VA ROOT-OF-TRUST: sign a BirthCert through the kernel `cert_sign` op (the SINGLE source of the cert
|
|
2250
2674
|
/// byte-layout — no Dart copy). `fields` = `{child, founder, verified, parentKey (this device's base64
|
|
2251
2675
|
/// ed25519 PUBLIC half), parentId, epoch}`; `deviceSecret` is that key's base64 SECRET half, handed to the
|
|
2252
|
-
/// LOCAL kernel only (it never reaches a server). Returns
|
|
2253
|
-
Future<
|
|
2254
|
-
|
|
2255
|
-
_asMap(await _call('signBirthCert',
|
|
2256
|
-
|
|
2257
|
-
'deviceSecret': deviceSecret
|
|
2258
|
-
}))['cert'] as Map<String, Object?>? ??
|
|
2259
|
-
const {};
|
|
2676
|
+
/// LOCAL kernel only (it never reaches a server). Returns typed authority evidence suitable for an offer.
|
|
2677
|
+
Future<NomosBirthCertificate> signBirthCert(
|
|
2678
|
+
NomosBirthCertificateSpec spec, String deviceSecret) async =>
|
|
2679
|
+
NomosBirthCertificate.fromWire(_asMap(_asMap(await _call('signBirthCert',
|
|
2680
|
+
{'fields': spec.toWire(), 'deviceSecret': deviceSecret}))['cert']));
|
|
2260
2681
|
|
|
2261
2682
|
/// MINT THE KEYLESS ENROLL-ONCE DELEGATION (`enrollFirstDevice.delegationCert`, the verified-first-device
|
|
2262
2683
|
/// lane). The device SELF-signs it; its authority is the carried `idpToken` (the kernel re-verifies that to
|
|
@@ -2851,14 +3272,113 @@ class NomosBridge {
|
|
|
2851
3272
|
/// THE APP-READY SYNC STATE — a truthful aggregation of sync rounds, the doorbell, transfer progress, and a
|
|
2852
3273
|
/// periodic metrics poll. Map straight to a sync-state page. Starts a lightweight 4s metrics poll while
|
|
2853
3274
|
/// listened to (stops on cancel). [currentStatus] is the latest snapshot for a non-stream read.
|
|
2854
|
-
Stream<SyncStatus> get syncStatus
|
|
2855
|
-
|
|
3275
|
+
Stream<SyncStatus> get syncStatus {
|
|
3276
|
+
final session = _sessionName;
|
|
3277
|
+
return session == null
|
|
3278
|
+
? _eventPlane._syncStatusCtrl.stream
|
|
3279
|
+
: _eventPlane._sessionSyncStatus(session);
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
SyncStatus get currentStatus {
|
|
3283
|
+
final session = _sessionName;
|
|
3284
|
+
return session == null
|
|
3285
|
+
? _eventPlane._status
|
|
3286
|
+
: _eventPlane._currentSessionStatus(session);
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
Stream<SyncStatus> _sessionSyncStatus(String session) =>
|
|
3290
|
+
_eventPlane._syncStatusBySession
|
|
3291
|
+
.putIfAbsent(
|
|
3292
|
+
session,
|
|
3293
|
+
() => StreamController<SyncStatus>.broadcast(
|
|
3294
|
+
onListen: () => _eventPlane._startSessionStatusPoll(session),
|
|
3295
|
+
onCancel: _eventPlane._stopStatusPollIfUnused,
|
|
3296
|
+
),
|
|
3297
|
+
)
|
|
3298
|
+
.stream;
|
|
3299
|
+
|
|
3300
|
+
SyncStatus _currentSessionStatus(String session) =>
|
|
3301
|
+
_eventPlane._statusBySession[session] ?? const SyncStatus();
|
|
2856
3302
|
|
|
2857
3303
|
void _pushStatus(SyncStatus s) {
|
|
2858
3304
|
_status = s;
|
|
2859
3305
|
if (!_syncStatusCtrl.isClosed) _syncStatusCtrl.add(s);
|
|
2860
3306
|
}
|
|
2861
3307
|
|
|
3308
|
+
void _pushSessionStatus(String session, SyncStatus status) {
|
|
3309
|
+
_statusBySession[session] = status;
|
|
3310
|
+
final controller = _syncStatusBySession[session];
|
|
3311
|
+
if (controller != null && !controller.isClosed) controller.add(status);
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
void _updateScopedStatus(
|
|
3315
|
+
String? session,
|
|
3316
|
+
SyncStatus Function(SyncStatus current) update,
|
|
3317
|
+
) {
|
|
3318
|
+
if (session == null || session.isEmpty) {
|
|
3319
|
+
_pushStatus(update(_status));
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
_pushSessionStatus(session, update(_currentSessionStatus(session)));
|
|
3323
|
+
_foldApplicationStatusFromSessions();
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
void _foldApplicationStatusFromSessions() {
|
|
3327
|
+
final statuses = _sessionNames
|
|
3328
|
+
.map((session) => _statusBySession[session])
|
|
3329
|
+
.whereType<SyncStatus>()
|
|
3330
|
+
.toList(growable: false);
|
|
3331
|
+
if (statuses.isEmpty) return;
|
|
3332
|
+
|
|
3333
|
+
SyncPhase phase;
|
|
3334
|
+
if (statuses.any((status) => status.phase == SyncPhase.error)) {
|
|
3335
|
+
phase = SyncPhase.error;
|
|
3336
|
+
} else if (statuses.any((status) => status.phase == SyncPhase.offline)) {
|
|
3337
|
+
phase = SyncPhase.offline;
|
|
3338
|
+
} else if (statuses
|
|
3339
|
+
.any((status) => status.phase == SyncPhase.transferring)) {
|
|
3340
|
+
phase = SyncPhase.transferring;
|
|
3341
|
+
} else if (statuses.any(
|
|
3342
|
+
(status) => status.phase == SyncPhase.behind || status.behind > 0)) {
|
|
3343
|
+
phase = SyncPhase.behind;
|
|
3344
|
+
} else if (statuses.any(
|
|
3345
|
+
(status) => status.phase == SyncPhase.syncing || status.ahead > 0)) {
|
|
3346
|
+
phase = SyncPhase.syncing;
|
|
3347
|
+
} else {
|
|
3348
|
+
phase = SyncPhase.idle;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
int total(int Function(SyncStatus status) read) =>
|
|
3352
|
+
statuses.fold(0, (sum, status) => sum + read(status));
|
|
3353
|
+
final lastSynced = statuses
|
|
3354
|
+
.map((status) => status.lastSyncedAt)
|
|
3355
|
+
.whereType<DateTime>()
|
|
3356
|
+
.fold<DateTime?>(
|
|
3357
|
+
null,
|
|
3358
|
+
(oldest, value) =>
|
|
3359
|
+
oldest == null || value.isBefore(oldest) ? value : oldest);
|
|
3360
|
+
final error = statuses
|
|
3361
|
+
.map((status) => status.lastError)
|
|
3362
|
+
.whereType<String>()
|
|
3363
|
+
.firstOrNull;
|
|
3364
|
+
final transfer = statuses
|
|
3365
|
+
.map((status) => status.transfer)
|
|
3366
|
+
.whereType<TransferProgress>()
|
|
3367
|
+
.firstOrNull;
|
|
3368
|
+
|
|
3369
|
+
_pushStatus(SyncStatus(
|
|
3370
|
+
phase: phase,
|
|
3371
|
+
ahead: total((status) => status.ahead),
|
|
3372
|
+
behind: total((status) => status.behind),
|
|
3373
|
+
saved: total((status) => status.saved),
|
|
3374
|
+
onMain: total((status) => status.onMain),
|
|
3375
|
+
deadLetters: total((status) => status.deadLetters),
|
|
3376
|
+
transfer: transfer,
|
|
3377
|
+
lastSyncedAt: lastSynced,
|
|
3378
|
+
lastError: error,
|
|
3379
|
+
));
|
|
3380
|
+
}
|
|
3381
|
+
|
|
2862
3382
|
void _startStatusPoll() {
|
|
2863
3383
|
_pushStatus(_status); // seed the new listener with the current snapshot
|
|
2864
3384
|
_pollMetricsOnce();
|
|
@@ -2866,33 +3386,109 @@ class NomosBridge {
|
|
|
2866
3386
|
Timer.periodic(const Duration(seconds: 4), (_) => _pollMetricsOnce());
|
|
2867
3387
|
}
|
|
2868
3388
|
|
|
3389
|
+
void _startSessionStatusPoll(String session) {
|
|
3390
|
+
_pushSessionStatus(session, _currentSessionStatus(session));
|
|
3391
|
+
_pollMetricsOnce();
|
|
3392
|
+
_metricsTimer ??=
|
|
3393
|
+
Timer.periodic(const Duration(seconds: 4), (_) => _pollMetricsOnce());
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
void _stopStatusPollIfUnused() {
|
|
3397
|
+
if (_syncStatusCtrl.hasListener ||
|
|
3398
|
+
_syncStatusBySession.values
|
|
3399
|
+
.any((controller) => controller.hasListener)) {
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
_stopStatusPoll();
|
|
3403
|
+
}
|
|
3404
|
+
|
|
2869
3405
|
void _stopStatusPoll() {
|
|
2870
3406
|
_metricsTimer?.cancel();
|
|
2871
3407
|
_metricsTimer = null;
|
|
2872
3408
|
}
|
|
2873
3409
|
|
|
2874
3410
|
Future<void> _pollMetricsOnce() async {
|
|
3411
|
+
final plane = _eventPlane;
|
|
3412
|
+
final inFlight = plane._metricsPoll;
|
|
3413
|
+
if (inFlight != null) {
|
|
3414
|
+
plane._metricsPollAgain = true;
|
|
3415
|
+
return inFlight;
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
final poll = plane._pollMetricsForOpenSessions();
|
|
3419
|
+
plane._metricsPoll = poll;
|
|
2875
3420
|
try {
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
3421
|
+
await poll;
|
|
3422
|
+
} finally {
|
|
3423
|
+
if (identical(plane._metricsPoll, poll)) plane._metricsPoll = null;
|
|
3424
|
+
if (plane._metricsPollAgain) {
|
|
3425
|
+
plane._metricsPollAgain = false;
|
|
3426
|
+
unawaited(plane._pollMetricsOnce());
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
Future<void> _pollMetricsForOpenSessions() async {
|
|
3432
|
+
try {
|
|
3433
|
+
// SyncStatus belongs to the local application plane, not whichever
|
|
3434
|
+
// workspace happened to connect first. Once that plane has more than
|
|
3435
|
+
// one open workspace the raw bridge deliberately refuses an unscoped
|
|
3436
|
+
// `metrics` call, so ask every live session explicitly and fold their
|
|
3437
|
+
// counters into one application status.
|
|
3438
|
+
final sessions = _eventPlane._sessionNames.toList(growable: false);
|
|
3439
|
+
// A listener can attach just before the first connect finishes. Never
|
|
3440
|
+
// issue an unscoped diagnostic request in that gap: by the time the
|
|
3441
|
+
// runner handles it, several generated children may legitimately be
|
|
3442
|
+
// open and the plane must then reject the ambiguous request. The next
|
|
3443
|
+
// periodic tick (or sync event) will measure the now-open sessions.
|
|
3444
|
+
if (sessions.isEmpty) return;
|
|
3445
|
+
final measurements = await Future.wait(
|
|
3446
|
+
sessions.map((session) => metrics(session)),
|
|
3447
|
+
);
|
|
3448
|
+
|
|
3449
|
+
int? metric(Map<String, Object?> measurement, String key) {
|
|
3450
|
+
if (!measurement.containsKey(key)) return null;
|
|
3451
|
+
final value = measurement[key];
|
|
3452
|
+
if (value is num) return value.toInt();
|
|
3453
|
+
// The runtime's `behind` metric is deliberately a head-comparison
|
|
3454
|
+
// verdict, whereas SyncStatus exposes the same fact as a count. Keep
|
|
3455
|
+
// that typed boundary explicit instead of casting the boolean to num.
|
|
3456
|
+
if (value is bool) return value ? 1 : 0;
|
|
3457
|
+
throw FormatException(
|
|
3458
|
+
"Nomos sync metric '$key' must be numeric or boolean, got ${value.runtimeType}",
|
|
3459
|
+
);
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
for (var index = 0; index < sessions.length; index += 1) {
|
|
3463
|
+
final session = sessions[index];
|
|
3464
|
+
final measurement = measurements[index];
|
|
3465
|
+
final current = _currentSessionStatus(session);
|
|
3466
|
+
final ahead = metric(measurement, 'ahead') ?? current.ahead;
|
|
3467
|
+
final behind = metric(measurement, 'behind') ?? current.behind;
|
|
3468
|
+
// Counters prove custody debt, but they do not prove that a failed or
|
|
3469
|
+
// offline transport recovered. Only a subsequent successful sync/link
|
|
3470
|
+
// event may clear those lifecycle states.
|
|
3471
|
+
final phase = current.transfer != null ||
|
|
3472
|
+
current.phase == SyncPhase.error ||
|
|
3473
|
+
current.phase == SyncPhase.offline
|
|
3474
|
+
? current.phase
|
|
3475
|
+
: (behind > 0
|
|
3476
|
+
? SyncPhase.behind
|
|
3477
|
+
: (ahead > 0 ? SyncPhase.syncing : SyncPhase.idle));
|
|
3478
|
+
_pushSessionStatus(
|
|
3479
|
+
session,
|
|
3480
|
+
current.copyWith(
|
|
3481
|
+
ahead: ahead,
|
|
3482
|
+
behind: behind,
|
|
3483
|
+
saved: metric(measurement, 'saved'),
|
|
3484
|
+
onMain: metric(measurement, 'onMain'),
|
|
3485
|
+
deadLetters: metric(measurement, 'deadLetters'),
|
|
3486
|
+
phase: phase,
|
|
3487
|
+
clearError: phase != SyncPhase.error && phase != SyncPhase.offline,
|
|
3488
|
+
),
|
|
3489
|
+
);
|
|
3490
|
+
}
|
|
3491
|
+
_foldApplicationStatusFromSessions();
|
|
2896
3492
|
} catch (e) {
|
|
2897
3493
|
_pushStatus(
|
|
2898
3494
|
_status.copyWith(phase: SyncPhase.error, lastError: e.toString()));
|
|
@@ -3183,8 +3779,18 @@ class NomosBridge {
|
|
|
3183
3779
|
return ctrl.stream;
|
|
3184
3780
|
}
|
|
3185
3781
|
|
|
3186
|
-
/// Tear down
|
|
3782
|
+
/// Tear down the application realm, then stop the transport subscription and
|
|
3783
|
+
/// fail any remaining in-flight calls. The native kernel plane may remain
|
|
3784
|
+
/// resident for custody/cache reuse, but its application-owned sessions,
|
|
3785
|
+
/// watches and continuous-sync loops must not survive this client.
|
|
3187
3786
|
Future<void> dispose() async {
|
|
3787
|
+
try {
|
|
3788
|
+
await _call('disposeRealm').timeout(const Duration(seconds: 10));
|
|
3789
|
+
} catch (_) {
|
|
3790
|
+
// A dead/unstarted embedding has no reachable runner to release. The
|
|
3791
|
+
// enclosing engine host is still disposed below by NomosScope; never let
|
|
3792
|
+
// teardown strand the Flutter lifecycle on an unavailable transport.
|
|
3793
|
+
}
|
|
3188
3794
|
await _sub.cancel();
|
|
3189
3795
|
final pending = _pending.entries
|
|
3190
3796
|
.map((entry) => (
|
|
@@ -3228,6 +3834,11 @@ class NomosBridge {
|
|
|
3228
3834
|
if (!_flameEvents.isClosed) await _flameEvents.close();
|
|
3229
3835
|
if (!_spanEvents.isClosed) await _spanEvents.close();
|
|
3230
3836
|
if (!_transferEvents.isClosed) await _transferEvents.close();
|
|
3837
|
+
final sessionStatuses = _syncStatusBySession.values.toList(growable: false);
|
|
3838
|
+
_syncStatusBySession.clear();
|
|
3839
|
+
for (final status in sessionStatuses) {
|
|
3840
|
+
if (!status.isClosed) unawaited(status.close());
|
|
3841
|
+
}
|
|
3231
3842
|
if (!_syncStatusCtrl.isClosed) await _syncStatusCtrl.close();
|
|
3232
3843
|
}
|
|
3233
3844
|
}
|
|
@@ -3300,6 +3911,7 @@ class NomosSession extends NomosBridge {
|
|
|
3300
3911
|
String? authorSecret,
|
|
3301
3912
|
String? inheritedPrincipal}) {
|
|
3302
3913
|
final retained = parent._sessionCloudAccess[this.workspace];
|
|
3914
|
+
final modelChange = parent._sessionModelChangeHandlers[this.workspace];
|
|
3303
3915
|
final opened = parent.acquireSession(
|
|
3304
3916
|
workspace,
|
|
3305
3917
|
ifAbsentCredentials: ifAbsentCredentials ??
|
|
@@ -3314,10 +3926,63 @@ class NomosSession extends NomosBridge {
|
|
|
3314
3926
|
// credential posture (home -> shared home -> child workspace remains one stateful application).
|
|
3315
3927
|
if (retained != null)
|
|
3316
3928
|
parent._sessionCloudAccess.putIfAbsent(workspace, () => retained);
|
|
3929
|
+
if (modelChange != null) {
|
|
3930
|
+
parent._sessionModelChangeHandlers.putIfAbsent(
|
|
3931
|
+
workspace,
|
|
3932
|
+
() => modelChange,
|
|
3933
|
+
);
|
|
3934
|
+
}
|
|
3317
3935
|
return session;
|
|
3318
3936
|
});
|
|
3319
3937
|
}
|
|
3320
3938
|
|
|
3939
|
+
/// Generator-only parent-to-child model inheritance. The compiled USDZ is
|
|
3940
|
+
/// opaque transport; the target child's kernel re-derives its identity and
|
|
3941
|
+
/// judges the ordinary signed stageDomain offer before custody changes.
|
|
3942
|
+
Future<void> nomosInternalEnsureGeneratedModelInChild({
|
|
3943
|
+
required String childWorkspace,
|
|
3944
|
+
required String domain,
|
|
3945
|
+
required String candidateHash,
|
|
3946
|
+
required List<int> lawUsdz,
|
|
3947
|
+
}) async {
|
|
3948
|
+
final source = _sessionName;
|
|
3949
|
+
if (source == null || source.isEmpty) {
|
|
3950
|
+
throw StateError(
|
|
3951
|
+
'Generated child model inheritance needs a bound parent session.');
|
|
3952
|
+
}
|
|
3953
|
+
await _call('stageGeneratedModelFromParent', {
|
|
3954
|
+
'sourceSession': source,
|
|
3955
|
+
'session': childWorkspace,
|
|
3956
|
+
'domain': domain,
|
|
3957
|
+
'candidateHash': candidateHash,
|
|
3958
|
+
'lawUsdz': base64Encode(lawUsdz),
|
|
3959
|
+
});
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
/// Retain the application's typed model-decision policy for generated child
|
|
3963
|
+
/// workspaces. This is not a deployment callback and carries no protocol data.
|
|
3964
|
+
void nomosInternalRetainModelChangeHandler(
|
|
3965
|
+
Future<void> Function(DomainClientModelAdoptionRequired) handler,
|
|
3966
|
+
) {
|
|
3967
|
+
final workspace = _sessionName;
|
|
3968
|
+
if (workspace == null || workspace.isEmpty) {
|
|
3969
|
+
throw StateError(
|
|
3970
|
+
'Model change handling needs a bound application session.');
|
|
3971
|
+
}
|
|
3972
|
+
_eventPlane._sessionModelChangeHandlers[workspace] = handler;
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
Future<void> nomosInternalHandleModelChange(
|
|
3976
|
+
DomainClientModelAdoptionRequired change,
|
|
3977
|
+
) async {
|
|
3978
|
+
final workspace = _sessionName;
|
|
3979
|
+
final handler = workspace == null
|
|
3980
|
+
? null
|
|
3981
|
+
: _eventPlane._sessionModelChangeHandlers[workspace];
|
|
3982
|
+
if (handler == null) throw change;
|
|
3983
|
+
await handler(change);
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3321
3986
|
/// Generator/runtime-host seam: retain this application's refreshable cloud access privately so
|
|
3322
3987
|
/// generated reference reads can mount another authorized workspace after a cold start.
|
|
3323
3988
|
///
|