@githolon/dsl 0.103.4 → 0.103.6
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 +8 -0
- package/dart/lib/src/runner/node-entry.mjs +333 -117
- package/dart/lib/src/runner/offline-install.mjs +173 -67
- package/dart/lib/src/session_bound.dart +111 -2
- package/dart/lib/src/test_harness.dart +51 -41
- package/dart/lib/src/webview_bridge.dart +714 -60
- package/dart/pubspec.lock +1 -1
- package/dart/pubspec.yaml +2 -2
- 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 +326 -7
- 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 +347 -113
- package/src/births.ts +16 -1
- package/src/business_birth.ts +55 -34
- 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 +159 -43
- package/src/fields.ts +43 -0
- package/src/framework/domain_lifecycle.ts +108 -88
- package/src/framework/genesis.ts +2 -1
- package/src/framework/home.ts +6 -43
- package/src/framework/workspaces.ts +42 -25
- 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/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,6 +1252,11 @@ 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;
|
|
@@ -1200,29 +1413,59 @@ class NomosBridge {
|
|
|
1200
1413
|
if (!_syncEvents.isClosed) _syncEvents.add(m);
|
|
1201
1414
|
if (event == 'sync') {
|
|
1202
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
|
+
}
|
|
1203
1423
|
if (m['ok'] == false) {
|
|
1204
1424
|
// A rejected/failed round is not a successful sync timestamp. Keep the stable typed envelope on
|
|
1205
1425
|
// telemetry; the app-ready status needs only one legible error and an honest error phase.
|
|
1206
1426
|
final detail = m['error']?.toString() ?? 'automatic sync failed';
|
|
1207
|
-
|
|
1208
|
-
|
|
1427
|
+
_updateScopedStatus(
|
|
1428
|
+
session,
|
|
1429
|
+
(current) => current.copyWith(
|
|
1430
|
+
phase: SyncPhase.error,
|
|
1431
|
+
clearTransfer: true,
|
|
1432
|
+
lastError: detail));
|
|
1209
1433
|
} else if (result?['offline'] == true) {
|
|
1210
1434
|
// Offline is expected transport debt. The durable local branch remains ahead and autoSync owns the
|
|
1211
1435
|
// retry; do not claim a completed sync or overwrite the previous successful timestamp.
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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));
|
|
1216
1456
|
} else {
|
|
1217
1457
|
// A completed round → idle (the next metrics poll folds in residual ahead/behind), stamp the time.
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1458
|
+
_updateScopedStatus(
|
|
1459
|
+
session,
|
|
1460
|
+
(current) => current.copyWith(
|
|
1461
|
+
phase: SyncPhase.idle,
|
|
1462
|
+
clearTransfer: true,
|
|
1463
|
+
clearError: true,
|
|
1464
|
+
lastSyncedAt: DateTime.now()));
|
|
1223
1465
|
}
|
|
1224
1466
|
} else if (m['status'] == 'closed' || m['status'] == 'offline') {
|
|
1225
|
-
|
|
1467
|
+
_updateScopedStatus(m['session']?.toString(),
|
|
1468
|
+
(current) => current.copyWith(phase: SyncPhase.offline));
|
|
1226
1469
|
}
|
|
1227
1470
|
return;
|
|
1228
1471
|
}
|
|
@@ -1237,10 +1480,12 @@ class NomosBridge {
|
|
|
1237
1480
|
// LIVE pack byte-progress { received, total, kind } — the real loading bar (host Content-Length).
|
|
1238
1481
|
final tp = TransferProgress.fromMessage(m);
|
|
1239
1482
|
if (!_transferEvents.isClosed) _transferEvents.add(tp);
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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));
|
|
1244
1489
|
return;
|
|
1245
1490
|
}
|
|
1246
1491
|
if (event == 'trace') {
|
|
@@ -1252,8 +1497,11 @@ class NomosBridge {
|
|
|
1252
1497
|
// signal holon projection: ephemeral coordination such as peer frontiers / lag.
|
|
1253
1498
|
if (!_signalEvents.isClosed) _signalEvents.add(m);
|
|
1254
1499
|
// the doorbell says canonical main advanced → we're behind until the next pull lands.
|
|
1255
|
-
|
|
1256
|
-
|
|
1500
|
+
_updateScopedStatus(
|
|
1501
|
+
m['session']?.toString(),
|
|
1502
|
+
(current) => current.phase == SyncPhase.idle
|
|
1503
|
+
? current.copyWith(phase: SyncPhase.behind)
|
|
1504
|
+
: current);
|
|
1257
1505
|
return;
|
|
1258
1506
|
}
|
|
1259
1507
|
if (event == 'span') {
|
|
@@ -1341,6 +1589,102 @@ class NomosBridge {
|
|
|
1341
1589
|
}
|
|
1342
1590
|
}
|
|
1343
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
|
+
|
|
1344
1688
|
// ── the runtime-aware surface (the boundary the generated Dart client wraps) ──
|
|
1345
1689
|
|
|
1346
1690
|
/// THE PRIMARY MULTI-WORKSPACE ENTRY. Return a [NomosSession] handle pinned to [workspace] — the object a
|
|
@@ -1449,6 +1793,15 @@ class NomosBridge {
|
|
|
1449
1793
|
}));
|
|
1450
1794
|
_eventPlane._sessionNames
|
|
1451
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();
|
|
1452
1805
|
final boundPrincipal = _principalFromToken(authToken);
|
|
1453
1806
|
if (boundPrincipal != null)
|
|
1454
1807
|
_eventPlane._sessionPrincipals[ws] = boundPrincipal;
|
|
@@ -1457,6 +1810,9 @@ class NomosBridge {
|
|
|
1457
1810
|
}
|
|
1458
1811
|
_eventPlane._lastRecovery =
|
|
1459
1812
|
(r['recovery'] as Map?)?.cast<String, Object?>();
|
|
1813
|
+
if (_eventPlane._syncStatusCtrl.hasListener) {
|
|
1814
|
+
unawaited(_eventPlane._pollMetricsOnce());
|
|
1815
|
+
}
|
|
1460
1816
|
}
|
|
1461
1817
|
|
|
1462
1818
|
/// The custody lifecycle stream (rebirth_and_custody_lifecycle.md) — a rebirth/divergence surfaces here as a
|
|
@@ -1591,6 +1947,7 @@ class NomosBridge {
|
|
|
1591
1947
|
// Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead of
|
|
1592
1948
|
// the connect-time one (the runner maps it onto `opts.signWith`). Same contract as [offerOutcome].
|
|
1593
1949
|
String? authorSecret,
|
|
1950
|
+
NomosBirthCertificate? birthCertificate,
|
|
1594
1951
|
}) async {
|
|
1595
1952
|
final r = _asMap(await _call('offerDirective', {
|
|
1596
1953
|
'domain': domain,
|
|
@@ -1603,6 +1960,8 @@ class NomosBridge {
|
|
|
1603
1960
|
if (force != null) 'force': force,
|
|
1604
1961
|
if (session != null) 'session': session,
|
|
1605
1962
|
if (authorSecret != null) 'authorSecret': authorSecret,
|
|
1963
|
+
if (birthCertificate != null)
|
|
1964
|
+
'birthCertificate': birthCertificate.toWire(),
|
|
1606
1965
|
}));
|
|
1607
1966
|
return r['head'].toString();
|
|
1608
1967
|
}
|
|
@@ -1625,6 +1984,7 @@ class NomosBridge {
|
|
|
1625
1984
|
bool? force,
|
|
1626
1985
|
String? session,
|
|
1627
1986
|
String? authorSecret,
|
|
1987
|
+
NomosBirthCertificate? birthCertificate,
|
|
1628
1988
|
}) =>
|
|
1629
1989
|
offerOutcome(
|
|
1630
1990
|
domain: domain,
|
|
@@ -1636,6 +1996,7 @@ class NomosBridge {
|
|
|
1636
1996
|
force: force,
|
|
1637
1997
|
session: session,
|
|
1638
1998
|
authorSecret: authorSecret,
|
|
1999
|
+
birthCertificate: birthCertificate,
|
|
1639
2000
|
);
|
|
1640
2001
|
|
|
1641
2002
|
/// THE ONE OUTCOME-RETURNING OFFER LANE — [offerDirective] answering the full typed [NomosOfferOutcome]
|
|
@@ -1655,6 +2016,7 @@ class NomosBridge {
|
|
|
1655
2016
|
// Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead
|
|
1656
2017
|
// of the connect-time one — the parent-directed lanes (sibling births after a signer repair) use it.
|
|
1657
2018
|
String? authorSecret,
|
|
2019
|
+
NomosBirthCertificate? birthCertificate,
|
|
1658
2020
|
}) async {
|
|
1659
2021
|
final Map<String, Object?> r;
|
|
1660
2022
|
// OFFER SPAN TAXONOMY (bridge legs): stamp the Dart-side send/receive around the op; the runner
|
|
@@ -1676,6 +2038,8 @@ class NomosBridge {
|
|
|
1676
2038
|
if (force != null) 'force': force,
|
|
1677
2039
|
if (session != null) 'session': session,
|
|
1678
2040
|
if (authorSecret != null) 'authorSecret': authorSecret,
|
|
2041
|
+
if (birthCertificate != null)
|
|
2042
|
+
'birthCertificate': birthCertificate.toWire(),
|
|
1679
2043
|
}));
|
|
1680
2044
|
} catch (e) {
|
|
1681
2045
|
throw await _wrapDomainNotInstalled(e,
|
|
@@ -1748,9 +2112,16 @@ class NomosBridge {
|
|
|
1748
2112
|
}
|
|
1749
2113
|
}
|
|
1750
2114
|
// The offer ran on this workspace (NomosRef) — stamp every minted ref with it so it is a complete locator.
|
|
1751
|
-
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
|
+
}
|
|
1752
2121
|
return NomosOfferOutcome(
|
|
1753
2122
|
head: r['head'].toString(),
|
|
2123
|
+
intentId: intentId,
|
|
2124
|
+
receipt: receipt,
|
|
1754
2125
|
workspace: ws,
|
|
1755
2126
|
created: ((r['created'] as List?) ?? const [])
|
|
1756
2127
|
.whereType<Map>()
|
|
@@ -1889,6 +2260,16 @@ class NomosBridge {
|
|
|
1889
2260
|
'acquireSession("$workspace"): the workspace is already bound to a different signed-in person');
|
|
1890
2261
|
}
|
|
1891
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
|
+
}
|
|
1892
2273
|
}
|
|
1893
2274
|
|
|
1894
2275
|
Future<NomosSession> ready(NomosSession opened) async {
|
|
@@ -1920,7 +2301,9 @@ class NomosBridge {
|
|
|
1920
2301
|
return ready(session(workspace));
|
|
1921
2302
|
}
|
|
1922
2303
|
// Consult the plane's TRUTHFUL registry (mounted ∪ parked ∪ resident custody — the runner's
|
|
1923
|
-
// 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.
|
|
1924
2307
|
var resident = false;
|
|
1925
2308
|
try {
|
|
1926
2309
|
resident = (await listWorkspaces())
|
|
@@ -1929,11 +2312,26 @@ class NomosBridge {
|
|
|
1929
2312
|
// a pre-connect plane has no registry yet — fall through to the credentials decision below.
|
|
1930
2313
|
}
|
|
1931
2314
|
if (resident) {
|
|
1932
|
-
|
|
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,
|
|
1933
2325
|
workspace: workspace,
|
|
1934
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.
|
|
1935
2332
|
allowUnborn: true,
|
|
1936
|
-
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
1937
2335
|
return ready(session(workspace));
|
|
1938
2336
|
}
|
|
1939
2337
|
if (ifAbsentCredentials == null) {
|
|
@@ -2182,6 +2580,21 @@ class NomosBridge {
|
|
|
2182
2580
|
.cast<String, Object?>();
|
|
2183
2581
|
}
|
|
2184
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
|
+
|
|
2185
2598
|
/// Framework-private exact-history restoration seam. Generated clients expose
|
|
2186
2599
|
/// `model.restorePrevious()` and a chained `decision.accept(reason)`; predecessor
|
|
2187
2600
|
/// identity, derived dispositions, heads and receipts stay below this bridge.
|
|
@@ -2260,14 +2673,11 @@ class NomosBridge {
|
|
|
2260
2673
|
/// VA ROOT-OF-TRUST: sign a BirthCert through the kernel `cert_sign` op (the SINGLE source of the cert
|
|
2261
2674
|
/// byte-layout — no Dart copy). `fields` = `{child, founder, verified, parentKey (this device's base64
|
|
2262
2675
|
/// ed25519 PUBLIC half), parentId, epoch}`; `deviceSecret` is that key's base64 SECRET half, handed to the
|
|
2263
|
-
/// LOCAL kernel only (it never reaches a server). Returns
|
|
2264
|
-
Future<
|
|
2265
|
-
|
|
2266
|
-
_asMap(await _call('signBirthCert',
|
|
2267
|
-
|
|
2268
|
-
'deviceSecret': deviceSecret
|
|
2269
|
-
}))['cert'] as Map<String, Object?>? ??
|
|
2270
|
-
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']));
|
|
2271
2681
|
|
|
2272
2682
|
/// MINT THE KEYLESS ENROLL-ONCE DELEGATION (`enrollFirstDevice.delegationCert`, the verified-first-device
|
|
2273
2683
|
/// lane). The device SELF-signs it; its authority is the carried `idpToken` (the kernel re-verifies that to
|
|
@@ -2862,14 +3272,113 @@ class NomosBridge {
|
|
|
2862
3272
|
/// THE APP-READY SYNC STATE — a truthful aggregation of sync rounds, the doorbell, transfer progress, and a
|
|
2863
3273
|
/// periodic metrics poll. Map straight to a sync-state page. Starts a lightweight 4s metrics poll while
|
|
2864
3274
|
/// listened to (stops on cancel). [currentStatus] is the latest snapshot for a non-stream read.
|
|
2865
|
-
Stream<SyncStatus> get syncStatus
|
|
2866
|
-
|
|
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();
|
|
2867
3302
|
|
|
2868
3303
|
void _pushStatus(SyncStatus s) {
|
|
2869
3304
|
_status = s;
|
|
2870
3305
|
if (!_syncStatusCtrl.isClosed) _syncStatusCtrl.add(s);
|
|
2871
3306
|
}
|
|
2872
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
|
+
|
|
2873
3382
|
void _startStatusPoll() {
|
|
2874
3383
|
_pushStatus(_status); // seed the new listener with the current snapshot
|
|
2875
3384
|
_pollMetricsOnce();
|
|
@@ -2877,33 +3386,109 @@ class NomosBridge {
|
|
|
2877
3386
|
Timer.periodic(const Duration(seconds: 4), (_) => _pollMetricsOnce());
|
|
2878
3387
|
}
|
|
2879
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
|
+
|
|
2880
3405
|
void _stopStatusPoll() {
|
|
2881
3406
|
_metricsTimer?.cancel();
|
|
2882
3407
|
_metricsTimer = null;
|
|
2883
3408
|
}
|
|
2884
3409
|
|
|
2885
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;
|
|
2886
3420
|
try {
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
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();
|
|
2907
3492
|
} catch (e) {
|
|
2908
3493
|
_pushStatus(
|
|
2909
3494
|
_status.copyWith(phase: SyncPhase.error, lastError: e.toString()));
|
|
@@ -3194,8 +3779,18 @@ class NomosBridge {
|
|
|
3194
3779
|
return ctrl.stream;
|
|
3195
3780
|
}
|
|
3196
3781
|
|
|
3197
|
-
/// 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.
|
|
3198
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
|
+
}
|
|
3199
3794
|
await _sub.cancel();
|
|
3200
3795
|
final pending = _pending.entries
|
|
3201
3796
|
.map((entry) => (
|
|
@@ -3239,6 +3834,11 @@ class NomosBridge {
|
|
|
3239
3834
|
if (!_flameEvents.isClosed) await _flameEvents.close();
|
|
3240
3835
|
if (!_spanEvents.isClosed) await _spanEvents.close();
|
|
3241
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
|
+
}
|
|
3242
3842
|
if (!_syncStatusCtrl.isClosed) await _syncStatusCtrl.close();
|
|
3243
3843
|
}
|
|
3244
3844
|
}
|
|
@@ -3311,6 +3911,7 @@ class NomosSession extends NomosBridge {
|
|
|
3311
3911
|
String? authorSecret,
|
|
3312
3912
|
String? inheritedPrincipal}) {
|
|
3313
3913
|
final retained = parent._sessionCloudAccess[this.workspace];
|
|
3914
|
+
final modelChange = parent._sessionModelChangeHandlers[this.workspace];
|
|
3314
3915
|
final opened = parent.acquireSession(
|
|
3315
3916
|
workspace,
|
|
3316
3917
|
ifAbsentCredentials: ifAbsentCredentials ??
|
|
@@ -3325,10 +3926,63 @@ class NomosSession extends NomosBridge {
|
|
|
3325
3926
|
// credential posture (home -> shared home -> child workspace remains one stateful application).
|
|
3326
3927
|
if (retained != null)
|
|
3327
3928
|
parent._sessionCloudAccess.putIfAbsent(workspace, () => retained);
|
|
3929
|
+
if (modelChange != null) {
|
|
3930
|
+
parent._sessionModelChangeHandlers.putIfAbsent(
|
|
3931
|
+
workspace,
|
|
3932
|
+
() => modelChange,
|
|
3933
|
+
);
|
|
3934
|
+
}
|
|
3328
3935
|
return session;
|
|
3329
3936
|
});
|
|
3330
3937
|
}
|
|
3331
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
|
+
|
|
3332
3986
|
/// Generator/runtime-host seam: retain this application's refreshable cloud access privately so
|
|
3333
3987
|
/// generated reference reads can mount another authorized workspace after a cold start.
|
|
3334
3988
|
///
|