@serfab/cadre-core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +255 -253
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +1121 -81
  9. package/dist/cadre-node.js +2276 -262
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-cohort.d.ts +26 -0
  15. package/dist/control-cohort.js +26 -0
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +959 -54
  18. package/dist/control-database.js +1421 -148
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +90 -10
  21. package/dist/control-formation-recorder.js +130 -14
  22. package/dist/control-formation-recorder.js.map +1 -1
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +651 -72
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-stream.d.ts +38 -4
  27. package/dist/control-stream.js +70 -4
  28. package/dist/control-stream.js.map +1 -1
  29. package/dist/control-write-retry.d.ts +161 -0
  30. package/dist/control-write-retry.js +412 -0
  31. package/dist/control-write-retry.js.map +1 -0
  32. package/dist/delegate-admission.d.ts +134 -0
  33. package/dist/delegate-admission.js +244 -0
  34. package/dist/delegate-admission.js.map +1 -0
  35. package/dist/device-token.d.ts +11 -11
  36. package/dist/device-token.js +14 -13
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +26 -0
  39. package/dist/ed25519-key.js +56 -1
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/file-durable-slot.d.ts +20 -0
  42. package/dist/file-durable-slot.js +53 -0
  43. package/dist/file-durable-slot.js.map +1 -0
  44. package/dist/formation-approval.d.ts +157 -0
  45. package/dist/formation-approval.js +375 -0
  46. package/dist/formation-approval.js.map +1 -0
  47. package/dist/fs-atomic.d.ts +38 -0
  48. package/dist/fs-atomic.js +97 -0
  49. package/dist/fs-atomic.js.map +1 -0
  50. package/dist/identity-key.d.ts +49 -0
  51. package/dist/identity-key.js +81 -0
  52. package/dist/identity-key.js.map +1 -0
  53. package/dist/index.d.ts +17 -7
  54. package/dist/index.js +40 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/key-store-file.d.ts +2 -24
  57. package/dist/key-store-file.js +10 -77
  58. package/dist/key-store-file.js.map +1 -1
  59. package/dist/membership-connection-gater.d.ts +146 -0
  60. package/dist/membership-connection-gater.js +180 -0
  61. package/dist/membership-connection-gater.js.map +1 -0
  62. package/dist/node-local-snapshot.d.ts +109 -0
  63. package/dist/node-local-snapshot.js +182 -0
  64. package/dist/node-local-snapshot.js.map +1 -0
  65. package/dist/peer-addr-book.d.ts +92 -0
  66. package/dist/peer-addr-book.js +220 -0
  67. package/dist/peer-addr-book.js.map +1 -0
  68. package/dist/peer-authorization.d.ts +135 -18
  69. package/dist/peer-authorization.js +152 -21
  70. package/dist/peer-authorization.js.map +1 -1
  71. package/dist/peer-record.d.ts +21 -8
  72. package/dist/peer-record.js +26 -10
  73. package/dist/peer-record.js.map +1 -1
  74. package/dist/relay-addrs.d.ts +45 -0
  75. package/dist/relay-addrs.js +103 -0
  76. package/dist/relay-addrs.js.map +1 -0
  77. package/dist/relay-reservation.d.ts +266 -0
  78. package/dist/relay-reservation.js +613 -0
  79. package/dist/relay-reservation.js.map +1 -0
  80. package/dist/seed-bootstrap.d.ts +228 -56
  81. package/dist/seed-bootstrap.js +340 -198
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +47 -21
  84. package/dist/seed-trust-policy.js +24 -15
  85. package/dist/seed-trust-policy.js.map +1 -1
  86. package/dist/strand-addr-protocol.d.ts +42 -13
  87. package/dist/strand-addr-protocol.js +58 -52
  88. package/dist/strand-addr-protocol.js.map +1 -1
  89. package/dist/strand-backfill.d.ts +109 -0
  90. package/dist/strand-backfill.js +279 -0
  91. package/dist/strand-backfill.js.map +1 -0
  92. package/dist/strand-cohort.d.ts +4 -37
  93. package/dist/strand-cohort.js +4 -15
  94. package/dist/strand-cohort.js.map +1 -1
  95. package/dist/strand-database.d.ts +11 -16
  96. package/dist/strand-database.js +19 -7
  97. package/dist/strand-database.js.map +1 -1
  98. package/dist/strand-formation-manager.d.ts +36 -7
  99. package/dist/strand-formation-manager.js +115 -15
  100. package/dist/strand-formation-manager.js.map +1 -1
  101. package/dist/strand-formation-protocol.d.ts +103 -4
  102. package/dist/strand-formation-protocol.js +232 -4
  103. package/dist/strand-formation-protocol.js.map +1 -1
  104. package/dist/strand-instance-manager.d.ts +88 -19
  105. package/dist/strand-instance-manager.js +178 -34
  106. package/dist/strand-instance-manager.js.map +1 -1
  107. package/dist/strand-member-registry.d.ts +5 -5
  108. package/dist/strand-member-registry.js +18 -10
  109. package/dist/strand-member-registry.js.map +1 -1
  110. package/dist/strand-membership-writer.d.ts +484 -88
  111. package/dist/strand-membership-writer.js +751 -128
  112. package/dist/strand-membership-writer.js.map +1 -1
  113. package/dist/strand-solicitation.d.ts +99 -33
  114. package/dist/strand-solicitation.js +100 -52
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-transport-key.d.ts +40 -0
  117. package/dist/strand-transport-key.js +56 -0
  118. package/dist/strand-transport-key.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +9 -7
  120. package/dist/strand-wake-protocol.js +22 -46
  121. package/dist/strand-wake-protocol.js.map +1 -1
  122. package/dist/strand-watcher.d.ts +17 -2
  123. package/dist/strand-watcher.js +47 -3
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/trusted-owner-store-file.d.ts +12 -0
  126. package/dist/trusted-owner-store-file.js +39 -0
  127. package/dist/trusted-owner-store-file.js.map +1 -0
  128. package/dist/trusted-owner-store.d.ts +64 -0
  129. package/dist/trusted-owner-store.js +126 -0
  130. package/dist/trusted-owner-store.js.map +1 -0
  131. package/dist/types.d.ts +307 -44
  132. package/dist/types.js +13 -0
  133. package/dist/types.js.map +1 -1
  134. package/package.json +20 -8
  135. package/src/arachnode-stub.ts +159 -159
  136. package/src/bootstrap-peer-store-file.ts +44 -0
  137. package/src/bootstrap-peer-store.ts +172 -0
  138. package/src/cadre-node.ts +5158 -2981
  139. package/src/canonical-json.ts +30 -30
  140. package/src/control-authorization.ts +114 -0
  141. package/src/control-cohort.ts +130 -103
  142. package/src/control-database.ts +2406 -948
  143. package/src/control-formation-recorder.ts +305 -151
  144. package/src/control-schema.ts +651 -72
  145. package/src/control-stream.ts +205 -128
  146. package/src/control-write-retry.ts +483 -0
  147. package/src/delegate-admission.ts +302 -0
  148. package/src/device-token.ts +94 -89
  149. package/src/diagnostics/connection-path.ts +277 -277
  150. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  151. package/src/ed25519-key.ts +66 -1
  152. package/src/enrollment.ts +199 -199
  153. package/src/file-durable-slot.ts +61 -0
  154. package/src/formation-approval.ts +604 -0
  155. package/src/fs-atomic.ts +107 -0
  156. package/src/hibernation-manager.ts +437 -437
  157. package/src/identity-key.ts +109 -0
  158. package/src/index.ts +137 -5
  159. package/src/key-store-file.ts +132 -203
  160. package/src/key-store.ts +99 -99
  161. package/src/membership-connection-gater.ts +204 -0
  162. package/src/node-local-snapshot.ts +260 -0
  163. package/src/peer-addr-book.ts +250 -0
  164. package/src/peer-authorization.ts +259 -81
  165. package/src/peer-record.ts +164 -146
  166. package/src/push-credentials.ts +84 -84
  167. package/src/push-node.ts +64 -64
  168. package/src/push-notifier-apns.ts +247 -247
  169. package/src/push-notifier-fcm.ts +220 -220
  170. package/src/push-notifier-shared.ts +28 -28
  171. package/src/push-notifier.ts +52 -52
  172. package/src/relay-addrs.ts +111 -0
  173. package/src/relay-reservation.ts +823 -0
  174. package/src/seed-bootstrap.ts +409 -209
  175. package/src/seed-trust-policy.ts +140 -107
  176. package/src/strand-addr-protocol.ts +441 -410
  177. package/src/strand-backfill.ts +366 -0
  178. package/src/strand-cohort.ts +38 -77
  179. package/src/strand-database.ts +202 -204
  180. package/src/strand-formation-manager.ts +491 -368
  181. package/src/strand-formation-protocol.ts +692 -391
  182. package/src/strand-instance-manager.ts +201 -46
  183. package/src/strand-member-key.ts +39 -39
  184. package/src/strand-member-registry.ts +182 -177
  185. package/src/strand-membership-writer.ts +938 -146
  186. package/src/strand-solicitation.ts +472 -407
  187. package/src/strand-transport-key.ts +58 -0
  188. package/src/strand-wake-payload.ts +32 -32
  189. package/src/strand-wake-protocol.ts +314 -331
  190. package/src/strand-watcher.ts +314 -251
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +1309 -1032
@@ -1,251 +1,314 @@
1
- import debug from 'debug';
2
- import type { StrandFilter, StrandRow } from './types.js';
3
-
4
- const log = debug('sereus:cadre:strand-watcher');
5
-
6
- /**
7
- * Outcome of evaluating a strand against the current filter.
8
- * - `pass`: admit (final)
9
- * - `reject`: exclude
10
- * - `defer`: admit provisionally; sAppId not yet known, re-evaluate next poll
11
- */
12
- type FilterDecision = 'pass' | 'reject' | 'defer';
13
-
14
- /**
15
- * Extended strand row that includes the sAppId for filtering purposes.
16
- * The sAppId is provided by the hosting application, not from the control network.
17
- */
18
- export interface StrandRowWithApp extends StrandRow {
19
- /** sApp ID if known (for filtering) */
20
- sAppId?: string;
21
- }
22
-
23
- /**
24
- * Callback for strand changes
25
- */
26
- export interface StrandWatcherCallbacks {
27
- onStrandAdded: (strand: StrandRow) => Promise<void>;
28
- onStrandRemoved: (strandId: string) => Promise<void>;
29
- }
30
-
31
- /**
32
- * Interface for querying strands from control network
33
- */
34
- export interface StrandQueryable {
35
- queryStrands(): Promise<StrandRow[]>;
36
- }
37
-
38
- /**
39
- * Interface for looking up sAppId for a strand (for filtering)
40
- */
41
- export interface SAppIdLookup {
42
- /** Get the sAppId for a strand, if known */
43
- getSAppId(strandId: string): string | undefined;
44
- }
45
-
46
- /**
47
- * Watches the control network's Strand table for changes and triggers
48
- * strand instance start/stop via callbacks.
49
- *
50
- * Uses polling until Optimystic supports reactive subscriptions.
51
- */
52
- export class StrandWatcher {
53
- private readonly filter: StrandFilter;
54
- private readonly pollInterval: number;
55
- private readonly callbacks: StrandWatcherCallbacks;
56
- private readonly queryable: StrandQueryable;
57
- private readonly sAppIdLookup?: SAppIdLookup;
58
-
59
- private knownStrands: Map<string, StrandRow> = new Map();
60
- /** Ids admitted under a `defer` decision; re-evaluated each poll until they resolve. */
61
- private provisional: Set<string> = new Set();
62
- private pollTimer: ReturnType<typeof setInterval> | null = null;
63
- private initialPollTimer: ReturnType<typeof setTimeout> | null = null;
64
- private running = false;
65
-
66
- constructor(
67
- queryable: StrandQueryable,
68
- callbacks: StrandWatcherCallbacks,
69
- filter: StrandFilter = { mode: 'all' },
70
- pollInterval: number = 5000,
71
- sAppIdLookup?: SAppIdLookup
72
- ) {
73
- this.queryable = queryable;
74
- this.callbacks = callbacks;
75
- this.filter = filter;
76
- this.pollInterval = pollInterval;
77
- this.sAppIdLookup = sAppIdLookup;
78
- log('StrandWatcher created with filter: %o, interval: %dms', filter, pollInterval);
79
- }
80
-
81
- /**
82
- * Evaluate a strand against the current filter, distinguishing a not-yet-known
83
- * sAppId (`defer`) from a known non-match (`reject`). A `defer` admission is
84
- * provisional and re-checked on subsequent polls.
85
- */
86
- private evaluateFilter(strand: StrandRow): FilterDecision {
87
- switch (this.filter.mode) {
88
- case 'all':
89
- return 'pass';
90
- case 'none':
91
- return 'reject';
92
- case 'strandId':
93
- return strand.Id === this.filter.strandId ? 'pass' : 'reject';
94
- case 'sAppId': {
95
- if (!this.sAppIdLookup) {
96
- // No way to ever decide - admit permanently (no lookup configured).
97
- return 'pass';
98
- }
99
- const sAppId = this.sAppIdLookup.getSAppId(strand.Id);
100
- if (sAppId === undefined) {
101
- // sAppId not yet known - admit provisionally and re-evaluate next poll.
102
- log('sAppId unknown for strand %s - deferring filter decision', strand.Id);
103
- return 'defer';
104
- }
105
- const matches = sAppId === this.filter.sAppId;
106
- log('sAppId filter: strand %s has sAppId %s, filter wants %s, match=%s',
107
- strand.Id, sAppId, this.filter.sAppId, matches);
108
- return matches ? 'pass' : 'reject';
109
- }
110
- default:
111
- return 'pass';
112
- }
113
- }
114
-
115
- /**
116
- * Poll for strand changes
117
- */
118
- private async poll(): Promise<void> {
119
- if (!this.running) return;
120
-
121
- try {
122
- const currentStrands = await this.queryable.queryStrands();
123
- const currentMap = new Map(currentStrands.map(s => [s.Id, s]));
124
-
125
- // Find added strands
126
- for (const strand of currentStrands) {
127
- if (this.knownStrands.has(strand.Id)) continue;
128
- const decision = this.evaluateFilter(strand);
129
- if (decision === 'reject') continue;
130
- log('Strand added: %s', strand.Id);
131
- this.knownStrands.set(strand.Id, strand);
132
- if (decision === 'defer') {
133
- // Provisional admission: sAppId unknown, re-check on later polls.
134
- this.provisional.add(strand.Id);
135
- }
136
- try {
137
- await this.callbacks.onStrandAdded(strand);
138
- } catch (error) {
139
- log('Error handling strand add for %s: %o', strand.Id, error);
140
- }
141
- }
142
-
143
- // Re-evaluate provisional admissions whose sAppId may now be resolvable.
144
- // Snapshot ids so we can mutate provisional/knownStrands inside the loop.
145
- for (const strandId of [...this.provisional]) {
146
- const strand = currentMap.get(strandId);
147
- if (!strand) continue; // handled by the removed-strand loop below
148
- const decision = this.evaluateFilter(strand);
149
- if (decision === 'pass') {
150
- // Resolved to a match - admission is now final.
151
- this.provisional.delete(strandId);
152
- } else if (decision === 'reject') {
153
- // Resolved to a non-match - stop the provisionally-admitted strand.
154
- log('Provisional strand rejected on re-evaluation: %s', strandId);
155
- this.knownStrands.delete(strandId);
156
- this.provisional.delete(strandId);
157
- try {
158
- await this.callbacks.onStrandRemoved(strandId);
159
- } catch (error) {
160
- log('Error handling strand remove for %s: %o', strandId, error);
161
- }
162
- }
163
- // decision === 'defer': still unknown, leave provisional for next poll.
164
- }
165
-
166
- // Find removed strands
167
- for (const [strandId] of this.knownStrands) {
168
- if (!currentMap.has(strandId)) {
169
- log('Strand removed: %s', strandId);
170
- this.knownStrands.delete(strandId);
171
- this.provisional.delete(strandId);
172
- try {
173
- await this.callbacks.onStrandRemoved(strandId);
174
- } catch (error) {
175
- log('Error handling strand remove for %s: %o', strandId, error);
176
- }
177
- }
178
- }
179
- } catch (error) {
180
- log('Error polling strands: %o', error);
181
- }
182
- }
183
-
184
- /**
185
- * Start watching for strand changes
186
- */
187
- async start(): Promise<void> {
188
- if (this.running) {
189
- log('StrandWatcher already running');
190
- return;
191
- }
192
-
193
- log('Starting StrandWatcher');
194
- this.running = true;
195
-
196
- // Set up periodic polling
197
- this.pollTimer = setInterval(() => {
198
- void this.poll();
199
- }, this.pollInterval);
200
-
201
- // Defer the first poll so start() doesn't block the caller.
202
- // Strands added via addStrand() typically arrive after start() completes.
203
- this.initialPollTimer = setTimeout(() => {
204
- this.initialPollTimer = null;
205
- void this.poll();
206
- }, 100);
207
-
208
- log('StrandWatcher started');
209
- }
210
-
211
- /**
212
- * Stop watching for strand changes
213
- */
214
- async stop(): Promise<void> {
215
- if (!this.running) {
216
- return;
217
- }
218
-
219
- log('Stopping StrandWatcher');
220
- this.running = false;
221
-
222
- if (this.initialPollTimer) {
223
- clearTimeout(this.initialPollTimer);
224
- this.initialPollTimer = null;
225
- }
226
-
227
- if (this.pollTimer) {
228
- clearInterval(this.pollTimer);
229
- this.pollTimer = null;
230
- }
231
-
232
- this.knownStrands.clear();
233
- this.provisional.clear();
234
- log('StrandWatcher stopped');
235
- }
236
-
237
- /**
238
- * Get currently known strands
239
- */
240
- getKnownStrands(): Map<string, StrandRow> {
241
- return new Map(this.knownStrands);
242
- }
243
-
244
- /**
245
- * Force an immediate poll (useful for testing)
246
- */
247
- async forcePoll(): Promise<void> {
248
- await this.poll();
249
- }
250
- }
251
-
1
+ import debug from 'debug';
2
+ import type { StrandFilter, StrandRow } from './types.js';
3
+
4
+ const log = debug('sereus:cadre:strand-watcher');
5
+
6
+ /**
7
+ * Outcome of evaluating a strand against the current filter.
8
+ * - `pass`: admit (final)
9
+ * - `reject`: exclude
10
+ * - `defer`: admit provisionally; sAppId not yet known, re-evaluate next poll
11
+ */
12
+ type FilterDecision = 'pass' | 'reject' | 'defer';
13
+
14
+ /**
15
+ * Ceiling on the retry delay for a strand whose launch keeps failing. Attempts
16
+ * are never abandoned — the failure this backoff exists for (a transient network
17
+ * or storage fault) can last a long time — but a permanently-unlaunchable strand
18
+ * must not re-attempt on every poll and storm the host app with `strand:error`.
19
+ */
20
+ const MAX_RETRY_BACKOFF_MS = 5 * 60 * 1000;
21
+
22
+ /** Per-strand consecutive-failure state driving the launch retry backoff. */
23
+ interface StrandFailureState {
24
+ /** Consecutive `onStrandAdded` failures since the last success. */
25
+ failures: number;
26
+ /** Earliest wall-clock time (ms) at which the next attempt may run. */
27
+ nextAttemptAt: number;
28
+ }
29
+
30
+ /**
31
+ * Extended strand row that includes the sAppId for filtering purposes.
32
+ * The sAppId is provided by the hosting application, not from the control network.
33
+ */
34
+ export interface StrandRowWithApp extends StrandRow {
35
+ /** sApp ID if known (for filtering) */
36
+ sAppId?: string;
37
+ }
38
+
39
+ /**
40
+ * Callback for strand changes
41
+ */
42
+ export interface StrandWatcherCallbacks {
43
+ onStrandAdded: (strand: StrandRow) => Promise<void>;
44
+ onStrandRemoved: (strandId: string) => Promise<void>;
45
+ }
46
+
47
+ /**
48
+ * Interface for querying strands from control network
49
+ */
50
+ export interface StrandQueryable {
51
+ queryStrands(): Promise<StrandRow[]>;
52
+ }
53
+
54
+ /**
55
+ * Interface for looking up sAppId for a strand (for filtering)
56
+ */
57
+ export interface SAppIdLookup {
58
+ /** Get the sAppId for a strand, if known */
59
+ getSAppId(strandId: string): string | undefined;
60
+ }
61
+
62
+ /**
63
+ * Watches the control network's Strand table for changes and triggers
64
+ * strand instance start/stop via callbacks.
65
+ *
66
+ * Uses polling until Optimystic supports reactive subscriptions.
67
+ */
68
+ export class StrandWatcher {
69
+ private readonly filter: StrandFilter;
70
+ private readonly pollInterval: number;
71
+ private readonly callbacks: StrandWatcherCallbacks;
72
+ private readonly queryable: StrandQueryable;
73
+ private readonly sAppIdLookup?: SAppIdLookup;
74
+ /**
75
+ * Wall-clock source for the retry backoff. Read fresh at each use rather than
76
+ * snapshotted per poll: a poll awaits `onStrandAdded`, and a launch that fails
77
+ * slowly (e.g. a dial timeout) would otherwise be scheduled off the clock as it
78
+ * read *before* the attempt, putting `nextAttemptAt` in the past. Injectable so
79
+ * tests can advance time deterministically without fake timers.
80
+ */
81
+ private readonly now: () => number;
82
+
83
+ private knownStrands: Map<string, StrandRow> = new Map();
84
+ /** Ids admitted under a `defer` decision; re-evaluated each poll until they resolve. */
85
+ private provisional: Set<string> = new Set();
86
+ /** Ids whose last launch attempt threw, with the backoff gating their retry. */
87
+ private failureStates: Map<string, StrandFailureState> = new Map();
88
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
89
+ private initialPollTimer: ReturnType<typeof setTimeout> | null = null;
90
+ private running = false;
91
+
92
+ constructor(
93
+ queryable: StrandQueryable,
94
+ callbacks: StrandWatcherCallbacks,
95
+ filter: StrandFilter = { mode: 'all' },
96
+ pollInterval: number = 5000,
97
+ sAppIdLookup?: SAppIdLookup,
98
+ now: () => number = Date.now
99
+ ) {
100
+ this.queryable = queryable;
101
+ this.callbacks = callbacks;
102
+ this.filter = filter;
103
+ this.pollInterval = pollInterval;
104
+ this.sAppIdLookup = sAppIdLookup;
105
+ this.now = now;
106
+ log('StrandWatcher created with filter: %o, interval: %dms', filter, pollInterval);
107
+ }
108
+
109
+ /**
110
+ * Evaluate a strand against the current filter, distinguishing a not-yet-known
111
+ * sAppId (`defer`) from a known non-match (`reject`). A `defer` admission is
112
+ * provisional and re-checked on subsequent polls.
113
+ */
114
+ private evaluateFilter(strand: StrandRow): FilterDecision {
115
+ switch (this.filter.mode) {
116
+ case 'all':
117
+ return 'pass';
118
+ case 'none':
119
+ return 'reject';
120
+ case 'strandId':
121
+ return strand.Id === this.filter.strandId ? 'pass' : 'reject';
122
+ case 'sAppId': {
123
+ if (!this.sAppIdLookup) {
124
+ // No way to ever decide - admit permanently (no lookup configured).
125
+ return 'pass';
126
+ }
127
+ const sAppId = this.sAppIdLookup.getSAppId(strand.Id);
128
+ if (sAppId === undefined) {
129
+ // sAppId not yet known - admit provisionally and re-evaluate next poll.
130
+ log('sAppId unknown for strand %s - deferring filter decision', strand.Id);
131
+ return 'defer';
132
+ }
133
+ const matches = sAppId === this.filter.sAppId;
134
+ log('sAppId filter: strand %s has sAppId %s, filter wants %s, match=%s',
135
+ strand.Id, sAppId, this.filter.sAppId, matches);
136
+ return matches ? 'pass' : 'reject';
137
+ }
138
+ default:
139
+ return 'pass';
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Record a failed launch attempt and schedule when the strand may be retried:
145
+ * `pollInterval * 2^(failures-1)`, capped at {@link MAX_RETRY_BACKOFF_MS}.
146
+ */
147
+ private recordFailure(strandId: string): void {
148
+ const failures = (this.failureStates.get(strandId)?.failures ?? 0) + 1;
149
+ const delay = Math.min(this.pollInterval * 2 ** (failures - 1), MAX_RETRY_BACKOFF_MS);
150
+ this.failureStates.set(strandId, { failures, nextAttemptAt: this.now() + delay });
151
+ log('Strand %s launch failed (%d consecutive) - next attempt in %dms', strandId, failures, delay);
152
+ }
153
+
154
+ /**
155
+ * Poll for strand changes.
156
+ */
157
+ private async poll(): Promise<void> {
158
+ if (!this.running) return;
159
+
160
+ try {
161
+ const currentStrands = await this.queryable.queryStrands();
162
+ const currentMap = new Map(currentStrands.map(s => [s.Id, s]));
163
+
164
+ // Find added strands
165
+ for (const strand of currentStrands) {
166
+ if (this.knownStrands.has(strand.Id)) continue;
167
+ const failure = this.failureStates.get(strand.Id);
168
+ // Clock read per candidate, not once per poll: an earlier strand's launch
169
+ // may have taken a long time, and this one's gate must reflect that.
170
+ if (failure && this.now() < failure.nextAttemptAt) continue; // backing off after a failed launch
171
+ const decision = this.evaluateFilter(strand);
172
+ if (decision === 'reject') continue;
173
+ log('Strand added: %s', strand.Id);
174
+ // Set BEFORE the await: this doubles as the in-flight guard that stops the
175
+ // interval timer from starting a second concurrent launch for the same strand.
176
+ this.knownStrands.set(strand.Id, strand);
177
+ if (decision === 'defer') {
178
+ // Provisional admission: sAppId unknown, re-check on later polls.
179
+ this.provisional.add(strand.Id);
180
+ }
181
+ try {
182
+ await this.callbacks.onStrandAdded(strand);
183
+ this.failureStates.delete(strand.Id);
184
+ } catch (error) {
185
+ log('Error handling strand add for %s: %o', strand.Id, error);
186
+ // A failed launch leaves nothing running (StrandInstanceManager drops the
187
+ // record), so forget the strand and let a later poll retry it — gated by
188
+ // the backoff recorded here.
189
+ this.knownStrands.delete(strand.Id);
190
+ this.provisional.delete(strand.Id);
191
+ this.recordFailure(strand.Id);
192
+ }
193
+ }
194
+
195
+ // Re-evaluate provisional admissions whose sAppId may now be resolvable.
196
+ // Snapshot ids so we can mutate provisional/knownStrands inside the loop.
197
+ for (const strandId of [...this.provisional]) {
198
+ const strand = currentMap.get(strandId);
199
+ if (!strand) continue; // handled by the removed-strand loop below
200
+ const decision = this.evaluateFilter(strand);
201
+ if (decision === 'pass') {
202
+ // Resolved to a match - admission is now final.
203
+ this.provisional.delete(strandId);
204
+ } else if (decision === 'reject') {
205
+ // Resolved to a non-match - stop the provisionally-admitted strand.
206
+ log('Provisional strand rejected on re-evaluation: %s', strandId);
207
+ this.knownStrands.delete(strandId);
208
+ this.provisional.delete(strandId);
209
+ try {
210
+ await this.callbacks.onStrandRemoved(strandId);
211
+ } catch (error) {
212
+ log('Error handling strand remove for %s: %o', strandId, error);
213
+ }
214
+ }
215
+ // decision === 'defer': still unknown, leave provisional for next poll.
216
+ }
217
+
218
+ // Find removed strands. A strand whose launch failed is no longer in
219
+ // knownStrands, so it correctly never fires onStrandRemoved — nothing was
220
+ // ever started for it.
221
+ for (const [strandId] of this.knownStrands) {
222
+ if (!currentMap.has(strandId)) {
223
+ log('Strand removed: %s', strandId);
224
+ this.knownStrands.delete(strandId);
225
+ this.provisional.delete(strandId);
226
+ try {
227
+ await this.callbacks.onStrandRemoved(strandId);
228
+ } catch (error) {
229
+ log('Error handling strand remove for %s: %o', strandId, error);
230
+ }
231
+ }
232
+ }
233
+
234
+ // Drop backoff state for strands whose control-network row is gone; a row
235
+ // that reappears is a fresh strand and gets a fresh first attempt.
236
+ for (const strandId of [...this.failureStates.keys()]) {
237
+ if (!currentMap.has(strandId)) {
238
+ this.failureStates.delete(strandId);
239
+ }
240
+ }
241
+ } catch (error) {
242
+ log('Error polling strands: %o', error);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Start watching for strand changes
248
+ */
249
+ async start(): Promise<void> {
250
+ if (this.running) {
251
+ log('StrandWatcher already running');
252
+ return;
253
+ }
254
+
255
+ log('Starting StrandWatcher');
256
+ this.running = true;
257
+
258
+ // Set up periodic polling
259
+ this.pollTimer = setInterval(() => {
260
+ void this.poll();
261
+ }, this.pollInterval);
262
+
263
+ // Defer the first poll so start() doesn't block the caller.
264
+ // Strands added via addStrand() typically arrive after start() completes.
265
+ this.initialPollTimer = setTimeout(() => {
266
+ this.initialPollTimer = null;
267
+ void this.poll();
268
+ }, 100);
269
+
270
+ log('StrandWatcher started');
271
+ }
272
+
273
+ /**
274
+ * Stop watching for strand changes
275
+ */
276
+ async stop(): Promise<void> {
277
+ if (!this.running) {
278
+ return;
279
+ }
280
+
281
+ log('Stopping StrandWatcher');
282
+ this.running = false;
283
+
284
+ if (this.initialPollTimer) {
285
+ clearTimeout(this.initialPollTimer);
286
+ this.initialPollTimer = null;
287
+ }
288
+
289
+ if (this.pollTimer) {
290
+ clearInterval(this.pollTimer);
291
+ this.pollTimer = null;
292
+ }
293
+
294
+ this.knownStrands.clear();
295
+ this.provisional.clear();
296
+ this.failureStates.clear();
297
+ log('StrandWatcher stopped');
298
+ }
299
+
300
+ /**
301
+ * Get currently known strands
302
+ */
303
+ getKnownStrands(): Map<string, StrandRow> {
304
+ return new Map(this.knownStrands);
305
+ }
306
+
307
+ /**
308
+ * Force an immediate poll (useful for testing)
309
+ */
310
+ async forcePoll(): Promise<void> {
311
+ await this.poll();
312
+ }
313
+ }
314
+
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Node-only file-backed {@link TrustedOwnerStore}: one JSON file per party under
3
+ * a configured directory (the node's state directory — `cadre-cli` passes
4
+ * `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated
5
+ * trusted-owner anchor.
6
+ *
7
+ * Nothing but the file itself lives here: the anchor's envelope, load policy,
8
+ * per-entry validation and serialised snapshot writes are cross-platform in
9
+ * `PersistentTrustedOwnerStore` / `node-local-snapshot.ts`, and the file is a
10
+ * `FileDurableSlot`. This module exists only so the `node:fs` edge stays out of
11
+ * the package's cross-platform default entry (`./index.js`) — import it from
12
+ * the dedicated subpath instead:
13
+ *
14
+ * ```ts
15
+ * import { FileTrustedOwnerStore } from '@serfab/cadre-core/trusted-owner-store-file';
16
+ * ```
17
+ *
18
+ * A React Native / browser entry graph never resolves this path, so the
19
+ * `node:fs` edge never reaches a bundler that cannot satisfy it (same
20
+ * isolation pattern as `key-store-file.ts`).
21
+ */
22
+ import { FileDurableSlot } from './file-durable-slot.js';
23
+ import { PersistentTrustedOwnerStore } from './trusted-owner-store.js';
24
+ import type { TrustedOwnerStore } from './trusted-owner-store.js';
25
+
26
+ /** Base name of the anchor file: `<dir>/trusted-owners.<encoded partyId>.json`. */
27
+ const SLOT_NAME = 'trusted-owners';
28
+
29
+ /**
30
+ * File-backed {@link TrustedOwnerStore}. Open via {@link open}, which loads the
31
+ * existing file and returns the cross-platform store over it; an absent, corrupt, or wrong-party file is a cold
32
+ * start (empty anchor), while a present-but-unreadable file throws. Those rules
33
+ * and their reasoning live on `NodeLocalSnapshot.open` — they are properties of
34
+ * every persistent backend, not of this one.
35
+ */
36
+ export const FileTrustedOwnerStore = {
37
+ /** Load (or cold-start) the party's anchor from a file in `dir`. */
38
+ async open(dir: string, partyId: string): Promise<TrustedOwnerStore> {
39
+ return PersistentTrustedOwnerStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId);
40
+ }
41
+ };