instar 1.3.1600 → 1.3.1602

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 (114) hide show
  1. package/dist/commands/init.d.ts +7 -0
  2. package/dist/commands/init.d.ts.map +1 -1
  3. package/dist/commands/init.js +8 -1
  4. package/dist/commands/init.js.map +1 -1
  5. package/dist/commands/server.d.ts.map +1 -1
  6. package/dist/commands/server.js +65 -0
  7. package/dist/commands/server.js.map +1 -1
  8. package/dist/core/AppConversationService.d.ts +39 -1
  9. package/dist/core/AppConversationService.d.ts.map +1 -1
  10. package/dist/core/AppConversationService.js +137 -2
  11. package/dist/core/AppConversationService.js.map +1 -1
  12. package/dist/core/AppConversationStore.d.ts +2 -0
  13. package/dist/core/AppConversationStore.d.ts.map +1 -1
  14. package/dist/core/AppConversationStore.js +4 -0
  15. package/dist/core/AppConversationStore.js.map +1 -1
  16. package/dist/core/AppMediaReplicator.d.ts +241 -0
  17. package/dist/core/AppMediaReplicator.d.ts.map +1 -0
  18. package/dist/core/AppMediaReplicator.js +903 -0
  19. package/dist/core/AppMediaReplicator.js.map +1 -0
  20. package/dist/core/AppMediaStore.d.ts +28 -0
  21. package/dist/core/AppMediaStore.d.ts.map +1 -1
  22. package/dist/core/AppMediaStore.js +87 -13
  23. package/dist/core/AppMediaStore.js.map +1 -1
  24. package/dist/core/BrowserCareProbes.d.ts +15 -0
  25. package/dist/core/BrowserCareProbes.d.ts.map +1 -0
  26. package/dist/core/BrowserCareProbes.js +78 -0
  27. package/dist/core/BrowserCareProbes.js.map +1 -0
  28. package/dist/core/MachineLockStateReader.d.ts +34 -0
  29. package/dist/core/MachineLockStateReader.d.ts.map +1 -0
  30. package/dist/core/MachineLockStateReader.js +114 -0
  31. package/dist/core/MachineLockStateReader.js.map +1 -0
  32. package/dist/core/MeshRpc.d.ts +1 -1
  33. package/dist/core/MeshRpc.d.ts.map +1 -1
  34. package/dist/core/MeshRpc.js +4 -0
  35. package/dist/core/MeshRpc.js.map +1 -1
  36. package/dist/core/PostUpdateMigrator.d.ts +14 -0
  37. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  38. package/dist/core/PostUpdateMigrator.js +60 -2
  39. package/dist/core/PostUpdateMigrator.js.map +1 -1
  40. package/dist/core/WindowRunLivenessAuthority.d.ts.map +1 -1
  41. package/dist/core/WindowRunLivenessAuthority.js +11 -1
  42. package/dist/core/WindowRunLivenessAuthority.js.map +1 -1
  43. package/dist/core/appConversationText.d.ts +5 -0
  44. package/dist/core/appConversationText.d.ts.map +1 -1
  45. package/dist/core/appConversationText.js +17 -2
  46. package/dist/core/appConversationText.js.map +1 -1
  47. package/dist/core/appInboundDispatch.d.ts +14 -0
  48. package/dist/core/appInboundDispatch.d.ts.map +1 -1
  49. package/dist/core/appInboundDispatch.js +25 -0
  50. package/dist/core/appInboundDispatch.js.map +1 -1
  51. package/dist/core/types.d.ts +10 -0
  52. package/dist/core/types.d.ts.map +1 -1
  53. package/dist/core/types.js.map +1 -1
  54. package/dist/data/standards-guard-index.json +1 -1
  55. package/dist/data/standards-guard-index.meta.json +2 -2
  56. package/dist/data/standards-registry.meta.json +1 -1
  57. package/dist/monitoring/machineMaintenance/BrowserCare.d.ts +166 -0
  58. package/dist/monitoring/machineMaintenance/BrowserCare.d.ts.map +1 -0
  59. package/dist/monitoring/machineMaintenance/BrowserCare.js +334 -0
  60. package/dist/monitoring/machineMaintenance/BrowserCare.js.map +1 -0
  61. package/dist/monitoring/machineMaintenance/lockState.d.ts +34 -0
  62. package/dist/monitoring/machineMaintenance/lockState.d.ts.map +1 -0
  63. package/dist/monitoring/machineMaintenance/lockState.js +53 -0
  64. package/dist/monitoring/machineMaintenance/lockState.js.map +1 -0
  65. package/dist/monitoring/machineMaintenance/readings.d.ts +4 -0
  66. package/dist/monitoring/machineMaintenance/readings.d.ts.map +1 -1
  67. package/dist/monitoring/machineMaintenance/readings.js +2 -1
  68. package/dist/monitoring/machineMaintenance/readings.js.map +1 -1
  69. package/dist/native/instar-desktop-host +0 -0
  70. package/dist/scaffold/templates.d.ts.map +1 -1
  71. package/dist/scaffold/templates.js +2 -1
  72. package/dist/scaffold/templates.js.map +1 -1
  73. package/dist/server/AgentServer.d.ts.map +1 -1
  74. package/dist/server/AgentServer.js +8 -1
  75. package/dist/server/AgentServer.js.map +1 -1
  76. package/dist/server/CapabilityIndex.js +3 -3
  77. package/dist/server/CapabilityIndex.js.map +1 -1
  78. package/dist/server/mobileConversationRoutes.d.ts +48 -0
  79. package/dist/server/mobileConversationRoutes.d.ts.map +1 -1
  80. package/dist/server/mobileConversationRoutes.js +259 -33
  81. package/dist/server/mobileConversationRoutes.js.map +1 -1
  82. package/dist/server/mobileMediaRoutes.d.ts.map +1 -1
  83. package/dist/server/mobileMediaRoutes.js +11 -1
  84. package/dist/server/mobileMediaRoutes.js.map +1 -1
  85. package/dist/server/mobilePairingRoutes.d.ts.map +1 -1
  86. package/dist/server/mobilePairingRoutes.js +34 -12
  87. package/dist/server/mobilePairingRoutes.js.map +1 -1
  88. package/dist/server/routes.d.ts.map +1 -1
  89. package/dist/server/routes.js +47 -3
  90. package/dist/server/routes.js.map +1 -1
  91. package/dist/testing/selfActionRegistry.d.ts.map +1 -1
  92. package/dist/testing/selfActionRegistry.js +41 -0
  93. package/dist/testing/selfActionRegistry.js.map +1 -1
  94. package/package.json +1 -1
  95. package/scripts/blocking-decision-surfaces-baseline.json +22 -2
  96. package/src/data/builtin-manifest.json +64 -64
  97. package/src/data/standards-guard-index.json +1 -1
  98. package/src/data/standards-guard-index.meta.json +2 -2
  99. package/src/data/standards-registry.meta.json +1 -1
  100. package/src/data/state-coherence-registry.json +37 -0
  101. package/src/scaffold/templates.ts +2 -1
  102. package/src/templates/scripts/browser/chrome-close.sh +97 -0
  103. package/src/templates/scripts/browser/chrome-js.sh +2 -0
  104. package/src/templates/scripts/browser/chrome-launch.sh +16 -3
  105. package/src/templates/scripts/browser/chrome-ledger.sh +49 -0
  106. package/src/templates/scripts/browser/chrome-renew.sh +38 -0
  107. package/src/templates/scripts/browser/chrome-unsaved.sh +83 -0
  108. package/upgrades/1.3.1601.md +68 -0
  109. package/upgrades/1.3.1602.md +24 -0
  110. package/upgrades/side-effects/machine-self-maintenance-browser-care-lock-reading.md +98 -0
  111. package/upgrades/side-effects/mobile-native-chat-media-cross-machine.md +131 -0
  112. package/upgrades/side-effects/native-conversations-gaps.md +129 -0
  113. package/upgrades/side-effects/window-run-liveness-later-loss.md +81 -0
  114. package/upgrades/1.3.1600.md +0 -34
@@ -0,0 +1,903 @@
1
+ /**
2
+ * AppMediaReplicator — copying app media between the agent's machines
3
+ * (docs/specs/mobile-native-chat-media.md §4.12, the rest of increment 1).
4
+ *
5
+ * Metadata (`media-message`, `purge`, … events) already replicates with the `appConversations`
6
+ * store kind. The bytes do not: every machine pulls every unpurged blob an event it holds
7
+ * references, through the `app-media-pull` mesh verb (the same signed, recipient-bound MeshRpc
8
+ * transport `working-set-pull` rides; its own code).
9
+ *
10
+ * serve — answers a registered peer: a ≤ 1 MiB slice of a blob an unpurged local event
11
+ * references (async positional reads, O_NOFOLLOW + inode check, never a re-hash),
12
+ * this machine's inventory (full sorted pages, or a diff against a version the peer
13
+ * already has), or a nudge (the peer asks this machine to pull hashes it lacks).
14
+ * pull — a 5 s tick, at most 8 slices per tick, lowest priority; paused below the disk floor,
15
+ * paused for at most an hour at a time while the load verdict is saturated/impaired
16
+ * (then one slice per tick until it clears). Slices land in `pulls/<sha>.part`; a
17
+ * complete file is hashed in a worker and kept ONLY if length and SHA-256 equal the
18
+ * referencing event's.
19
+ * blame — a partial copy comes from ONE peer: the peer that served offset 0 serves every later
20
+ * slice of it; if that peer stops answering, the partial copy is discarded and the
21
+ * file restarts from another peer. So a failed whole-file hash is blamed on exactly
22
+ * the one peer whose bytes it was. A serving peer re-hashes a blob whose size or
23
+ * mtime changed since its last check and quarantines it locally instead of serving it.
24
+ * brakes — per blob: exponential backoff 30 s → 1 h; after 7 days with no peer able to serve
25
+ * it the blob is `unrecoverable` (one notice per machine) until a new event or a peer
26
+ * inventory names a holder. Per peer: 5 consecutive verification failures open its
27
+ * breaker for 15 minutes; 3 opens in a day keep it open until the next day (one notice).
28
+ * copies — hourly peer inventories; `singleCopyBlobs` counts held, unpurged blobs no peer
29
+ * inventory younger than 2 hours lists (null, never 0, when that cannot be measured),
30
+ * with the alarm ladder of §4.12.
31
+ *
32
+ * Nothing here decides by content: hashes, sizes, fixed limits and the existing load verdict.
33
+ */
34
+ import crypto from 'node:crypto';
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { SafeFsExecutor } from './SafeFsExecutor.js';
38
+ import { hashFile } from './appMediaWorker.js';
39
+ import { isSha256Hex } from './appMediaTypes.js';
40
+ export const APP_MEDIA_SLICE_BYTES = 1024 * 1024;
41
+ export const MAX_SERVES_AT_ONCE = 2;
42
+ export const PULL_TICK_MS = 5_000;
43
+ export const SLICES_PER_TICK = 8;
44
+ export const LOAD_PAUSE_MAX_MS = 60 * 60_000;
45
+ export const BACKOFF_MIN_MS = 30_000;
46
+ export const BACKOFF_MAX_MS = 60 * 60_000;
47
+ export const GIVE_UP_MS = 7 * 24 * 60 * 60_000;
48
+ export const PEER_FAILURES_TO_OPEN = 5;
49
+ export const PEER_OPEN_MS = 15 * 60_000;
50
+ export const PEER_OPENS_TO_LATCH = 3;
51
+ export const INVENTORY_EVERY_MS = 60 * 60_000;
52
+ export const INVENTORY_FRESH_MS = 2 * 60 * 60_000;
53
+ export const INVENTORY_PAGE = 5_000;
54
+ export const INVENTORY_MAX_PAGES = 50;
55
+ export const NUDGE_MAX = 500;
56
+ export const SINGLE_COPY_GRACE_MS = 60 * 60_000;
57
+ export const SINGLE_COPY_OFFLINE_MS = 24 * 60 * 60_000;
58
+ export const UNMEASURABLE_ALARM_MS = 2 * 60 * 60_000;
59
+ /** The owner waits this long for missing blobs before dispatching with "still copying" (§4.12). */
60
+ export const OWNER_PULL_WAIT_MS = 60_000;
61
+ /** A "still copying" row older than this becomes a "could not be copied" line. */
62
+ export const PENDING_DELIVERY_TTL_MS = 7 * 24 * 60 * 60_000;
63
+ /** The app-media-path helper waits this long for a pull (§4.5). */
64
+ export const MEDIA_PATH_WAIT_MS = 30_000;
65
+ const WANTED_REFRESH_MS = 60_000;
66
+ const OWN_INVENTORY_REUSE_MS = 5 * 60_000;
67
+ const SLICE_TIMEOUT_MS = 20_000;
68
+ const INVENTORY_TIMEOUT_MS = 20_000;
69
+ const OP = 'src/core/AppMediaReplicator.ts';
70
+ const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
71
+ const versionOf = (hashes) => crypto.createHash('sha256').update(hashes.join('\n')).digest('hex');
72
+ export class AppMediaReplicator {
73
+ d;
74
+ transport = null;
75
+ now;
76
+ db;
77
+ serving = 0;
78
+ ticking = false;
79
+ wanted = [];
80
+ wantedAt = 0;
81
+ wantedDirty = true;
82
+ /** On-demand pulls (a download, app-media-path, an owner dispatch) go first and ignore the load pause. */
83
+ priority = new Set();
84
+ waiters = new Map();
85
+ peerCursor = new Map();
86
+ /** The one peer each partial copy comes from (§ blame, above). In memory: after a restart a part with no known source is restarted. */
87
+ partPeer = new Map();
88
+ /** When an on-demand ask or a nudge last skipped this file's backoff (at most once per backoff window). */
89
+ lastHurryAt = new Map();
90
+ peerInventories = new Map();
91
+ ownInventories = [];
92
+ loadPausedSince = null;
93
+ lastInventoryRound = 0;
94
+ inventoryRunning = false;
95
+ // Alarm state (§4.12): when the count first went above 0, when peers were nudged, when it went null.
96
+ singleSince = null;
97
+ nudgedAt = null;
98
+ unmeasurableSince = null;
99
+ /** First time each held hash was seen held alone (the 24 h offline-peer rule). */
100
+ singleFirstSeen = new Map();
101
+ /* @self-action-controller: app-media-replication */
102
+ /** Notice latch: one notice per condition episode, re-armed only when the condition clears. */
103
+ raised = new Map();
104
+ tickTimer = null;
105
+ counters = { slicesServed: 0, slicesPulled: 0, blobsLanded: 0, verifyFailures: 0, busyRefusals: 0, nudgesSent: 0 };
106
+ constructor(d) {
107
+ this.d = d;
108
+ this.now = d.now ?? Date.now;
109
+ this.db = d.media.indexDb;
110
+ d.media.setReplicationHealth(() => ({ unrecoverableBlobs: this.unrecoverableCount(), singleCopyBlobs: this.singleCopy().count }));
111
+ }
112
+ attachTransport(t) { this.transport = t; this.wantedDirty = true; }
113
+ hasTransport() { return this.transport !== null; }
114
+ start() {
115
+ if (this.tickTimer)
116
+ return;
117
+ this.tickTimer = setInterval(() => { void this.tick(); }, PULL_TICK_MS);
118
+ this.tickTimer.unref?.();
119
+ }
120
+ stop() {
121
+ if (this.tickTimer)
122
+ clearInterval(this.tickTimer);
123
+ this.tickTimer = null;
124
+ for (const [sha, ws] of this.waiters) {
125
+ for (const w of ws)
126
+ w(false);
127
+ this.waiters.delete(sha);
128
+ }
129
+ }
130
+ report(detail) { try {
131
+ this.d.report?.(detail);
132
+ }
133
+ catch { /* @silent-fallback-ok — reporting never fails the copier */ } }
134
+ /** New events landed (replication or a local send): the wanted list is recomputed on the next tick. */
135
+ markWantedDirty(hashes) {
136
+ this.wantedDirty = true;
137
+ // A new event naming an unrecoverable hash re-arms it (§4.12).
138
+ for (const h of hashes ?? [])
139
+ this.rearm(h);
140
+ }
141
+ rearm(sha) {
142
+ this.db.prepare('UPDATE pulls SET unrecoverable = 0, attempts = 0, nextAttemptAt = 0, lastServedAt = ? WHERE sha256 = ? AND unrecoverable = 1').run(this.now(), sha);
143
+ }
144
+ // ── Serve ────────────────────────────────────────────────────────────────────────
145
+ async serve(raw) {
146
+ const cmd = (raw ?? {});
147
+ if (cmd.op === 'slice')
148
+ return this.serveSlice(cmd.sha256, cmd.offset);
149
+ if (cmd.op === 'inventory')
150
+ return this.serveInventory(cmd.since, cmd.after);
151
+ if (cmd.op === 'nudge')
152
+ return this.serveNudge(cmd.hashes);
153
+ return { ok: false, reason: 'bad-op' };
154
+ }
155
+ /** Unpurged references from any event this machine holds (live or deleted conversations). */
156
+ referenced(sha) {
157
+ const r = this.d.store.mediaBlobRefs(sha);
158
+ return r && r.allRefs > 0 ? { size: r.size } : null;
159
+ }
160
+ async serveSlice(sha, offset) {
161
+ if (!isSha256Hex(sha) || typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0)
162
+ return { ok: false, reason: 'bad-request' };
163
+ if (!this.referenced(sha))
164
+ return { ok: false, reason: 'not-referenced' };
165
+ const blob = this.d.media.locateBlob(sha);
166
+ if (!blob)
167
+ return { ok: false, reason: 'not-held' };
168
+ if (this.serving >= MAX_SERVES_AT_ONCE) {
169
+ this.counters.busyRefusals++;
170
+ return { ok: false, reason: 'busy' };
171
+ }
172
+ this.serving++;
173
+ let handle = null;
174
+ try {
175
+ // A blob changed since its last check is re-hashed first; a damaged one is quarantined, never served.
176
+ if (!(await this.d.media.verifyBeforeServe(sha)))
177
+ return { ok: false, reason: 'not-held' };
178
+ const st = fs.lstatSync(blob);
179
+ if (!st.isFile())
180
+ return { ok: false, reason: 'not-held' };
181
+ handle = await fs.promises.open(blob, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
182
+ const opened = await handle.stat();
183
+ if (opened.ino !== st.ino || opened.dev !== st.dev)
184
+ return { ok: false, reason: 'not-held' };
185
+ if (offset > opened.size)
186
+ return { ok: false, reason: 'bad-offset' };
187
+ const len = Math.min(APP_MEDIA_SLICE_BYTES, opened.size - offset);
188
+ const buf = Buffer.alloc(len);
189
+ let got = 0;
190
+ while (got < len) {
191
+ const { bytesRead } = await handle.read(buf, got, len - got, offset + got);
192
+ if (bytesRead === 0)
193
+ break;
194
+ got += bytesRead;
195
+ }
196
+ this.counters.slicesServed++;
197
+ return { ok: true, size: opened.size, offset, dataB64: buf.subarray(0, got).toString('base64') };
198
+ }
199
+ catch {
200
+ return { ok: false, reason: 'not-held' }; // @silent-fallback-ok — a blob that cannot be opened safely reads as absent; the peer tries elsewhere
201
+ }
202
+ finally {
203
+ this.serving--;
204
+ if (handle)
205
+ await handle.close().catch(() => undefined);
206
+ }
207
+ }
208
+ /** This machine's held, unpurged hashes; recomputed at most every 5 minutes, last 3 versions kept for diffs. */
209
+ ownInventory() {
210
+ const latest = this.ownInventories[this.ownInventories.length - 1];
211
+ if (latest && this.now() - latest.at < OWN_INVENTORY_REUSE_MS)
212
+ return latest;
213
+ const hashes = this.d.media.heldHashes().filter((h) => this.referenced(h));
214
+ const version = versionOf(hashes);
215
+ if (latest && latest.version === version) {
216
+ latest.at = this.now();
217
+ return latest;
218
+ }
219
+ const snap = { version, hashes, set: new Set(hashes), at: this.now() };
220
+ this.ownInventories = [...this.ownInventories, snap].slice(-3);
221
+ return snap;
222
+ }
223
+ serveInventory(since, after) {
224
+ const cur = this.ownInventory();
225
+ if (typeof since === 'string' && after === undefined) {
226
+ if (since === cur.version)
227
+ return { ok: true, version: cur.version, unchanged: true };
228
+ const old = this.ownInventories.find((s) => s.version === since);
229
+ if (old) {
230
+ const curSet = new Set(cur.hashes);
231
+ const added = cur.hashes.filter((h) => !old.set.has(h));
232
+ const removed = old.hashes.filter((h) => !curSet.has(h));
233
+ if (added.length + removed.length <= INVENTORY_PAGE)
234
+ return { ok: true, version: cur.version, diff: { added, removed } };
235
+ }
236
+ }
237
+ const start = typeof after === 'string' ? cur.hashes.findIndex((h) => h > after) : 0;
238
+ const from = start < 0 ? cur.hashes.length : start;
239
+ const page = cur.hashes.slice(from, from + INVENTORY_PAGE);
240
+ const next = from + INVENTORY_PAGE < cur.hashes.length ? page[page.length - 1] : null;
241
+ return { ok: true, version: cur.version, full: true, hashes: page, next };
242
+ }
243
+ serveNudge(hashes) {
244
+ if (!Array.isArray(hashes) || hashes.length > NUDGE_MAX || !hashes.every(isSha256Hex))
245
+ return { ok: false, reason: 'bad-request' };
246
+ let queued = 0;
247
+ for (const h of hashes) {
248
+ if (!this.referenced(h) || this.d.media.locateBlob(h))
249
+ continue;
250
+ this.rearm(h); // a peer naming itself as a holder re-arms a given-up file (§4.12)
251
+ this.hurry(h);
252
+ queued++;
253
+ }
254
+ if (queued > 0)
255
+ this.wantedDirty = true;
256
+ return { ok: true, queued };
257
+ }
258
+ // ── Pull ─────────────────────────────────────────────────────────────────────────
259
+ /** Referenced, unpurged hashes this machine does not hold. Cached for a minute (or until dirty). */
260
+ wantedHashes() {
261
+ if (!this.wantedDirty && this.now() - this.wantedAt < WANTED_REFRESH_MS)
262
+ return this.wanted;
263
+ const missing = [];
264
+ for (const r of this.d.store.mediaReferencedHashes())
265
+ if (!this.d.media.locateBlob(r.sha256))
266
+ missing.push(r.sha256);
267
+ missing.sort();
268
+ this.wanted = missing;
269
+ this.wantedAt = this.now();
270
+ this.wantedDirty = false;
271
+ const t = this.now();
272
+ const ins = this.db.prepare('INSERT OR IGNORE INTO pulls (sha256, firstWantedAt) VALUES (?, ?)');
273
+ for (const h of missing)
274
+ ins.run(h, t);
275
+ // Rows for hashes no longer wanted (landed, purged) and their partial files go.
276
+ const keep = new Set(missing);
277
+ for (const r of this.db.prepare('SELECT sha256 FROM pulls').all()) {
278
+ if (keep.has(r.sha256))
279
+ continue;
280
+ this.db.prepare('DELETE FROM pulls WHERE sha256 = ?').run(r.sha256);
281
+ this.removePart(r.sha256);
282
+ }
283
+ return missing;
284
+ }
285
+ partPath(sha) { return path.join(this.d.media.pullsDir, `${sha}.part`); }
286
+ removePart(sha) {
287
+ if (fs.existsSync(this.partPath(sha)))
288
+ SafeFsExecutor.safeRmSync(this.partPath(sha), { force: true, operation: `${OP}:part` });
289
+ }
290
+ pullRow(sha) {
291
+ return this.db.prepare('SELECT * FROM pulls WHERE sha256 = ?').get(sha) ?? null;
292
+ }
293
+ isUnrecoverable(sha) { return this.pullRow(sha)?.unrecoverable === 1; }
294
+ /** Ask for this blob now (download, app-media-path, owner dispatch). */
295
+ want(sha) {
296
+ if (!isSha256Hex(sha) || this.d.media.locateBlob(sha) || !this.referenced(sha))
297
+ return;
298
+ this.priority.add(sha);
299
+ this.db.prepare('INSERT OR IGNORE INTO pulls (sha256, firstWantedAt) VALUES (?, ?)').run(sha, this.now());
300
+ this.hurry(sha);
301
+ void this.tick();
302
+ }
303
+ /**
304
+ * Skip a file's backoff for one immediate attempt, at most once per backoff window: an app
305
+ * polling a download every 2 s, or repeated nudges, can never turn the backoff into a hot loop.
306
+ */
307
+ hurry(sha) {
308
+ const row = this.pullRow(sha);
309
+ if (!row || row.unrecoverable === 1 || row.nextAttemptAt <= this.now())
310
+ return;
311
+ const window = Math.min(BACKOFF_MIN_MS * 2 ** Math.min(Math.max(row.attempts - 1, 0), 20), BACKOFF_MAX_MS);
312
+ const last = this.lastHurryAt.get(sha);
313
+ if (last !== undefined && this.now() - last < window)
314
+ return;
315
+ this.lastHurryAt.set(sha, this.now());
316
+ this.db.prepare('UPDATE pulls SET nextAttemptAt = ? WHERE sha256 = ?').run(this.now(), sha);
317
+ }
318
+ /** Whether an on-demand copy can start now; otherwise the plain reason (the download route's 202 vs 404). */
319
+ canFetch(sha) {
320
+ if (!this.transport)
321
+ return { ok: false, reason: 'this machine is not connected to the agent\'s other machines' };
322
+ if (this.isUnrecoverable(sha))
323
+ return { ok: false, reason: 'no machine of this agent has been able to provide it for 7 days' };
324
+ if (this.d.media.belowDiskFloor())
325
+ return { ok: false, reason: 'this machine is nearly out of disk space, so nothing is copied here until space is freed' };
326
+ if (this.candidatePeers(sha).length === 0)
327
+ return { ok: false, reason: this.noPeerReason() };
328
+ return { ok: true };
329
+ }
330
+ /** Online peers with a closed breaker that could hold this file (a fresh inventory that lacks it rules a peer out). */
331
+ candidatePeers(sha) {
332
+ const t = this.now();
333
+ return (this.transport?.peers() ?? []).filter((p) => {
334
+ if (p.online === false || this.breakerOpen(p.machineId, t))
335
+ return false;
336
+ const inv = this.peerInventories.get(p.machineId);
337
+ return !inv || t - inv.at > INVENTORY_FRESH_MS || inv.set.has(sha);
338
+ }).map((p) => p.machineId);
339
+ }
340
+ noPeerReason() {
341
+ const peers = this.transport?.peers() ?? [];
342
+ if (peers.length === 0)
343
+ return 'no other machine of this agent is registered';
344
+ const offline = peers.filter((p) => p.online === false).map((p) => p.nickname || p.machineId);
345
+ if (offline.length === peers.length)
346
+ return `${offline.join(', ')} ${offline.length === 1 ? 'is' : 'are'} not reachable`;
347
+ return 'no reachable machine of this agent holds it';
348
+ }
349
+ /** On-demand asks waiting for a blob right now (observability and tests). */
350
+ waiterCount() { let n = 0; for (const ws of this.waiters.values())
351
+ n += ws.length; return n; }
352
+ /** Resolve true when the blob is held, or false after `ms` (the pull keeps going in the background). */
353
+ awaitBlob(sha, ms) {
354
+ if (this.d.media.locateBlob(sha))
355
+ return Promise.resolve(true);
356
+ if (!this.transport || !this.referenced(sha) || this.isUnrecoverable(sha))
357
+ return Promise.resolve(false);
358
+ return new Promise((resolve) => {
359
+ let done = false;
360
+ const finish = (v) => { if (!done) {
361
+ done = true;
362
+ clearTimeout(timer);
363
+ resolve(v);
364
+ } };
365
+ const timer = setTimeout(() => {
366
+ // Timed out: this waiter leaves the list (the pull itself keeps going).
367
+ const list = this.waiters.get(sha);
368
+ if (list) {
369
+ const rest = list.filter((w) => w !== finish);
370
+ if (rest.length > 0)
371
+ this.waiters.set(sha, rest);
372
+ else
373
+ this.waiters.delete(sha);
374
+ }
375
+ finish(false);
376
+ }, ms);
377
+ timer.unref?.();
378
+ const list = this.waiters.get(sha) ?? [];
379
+ list.push(finish);
380
+ this.waiters.set(sha, list);
381
+ this.want(sha);
382
+ });
383
+ }
384
+ /** Why a blob is not here yet, in plain words (app-media-path's message). */
385
+ whyNotHere(sha) {
386
+ const row = this.pullRow(sha);
387
+ const can = this.canFetch(sha);
388
+ if (!can.ok)
389
+ return can.reason;
390
+ const paused = this.loadPausedSince !== null && this.now() - this.loadPausedSince < LOAD_PAUSE_MAX_MS;
391
+ const load = paused ? '; background copying is paused while this machine is overloaded, this file is copied on request' : '';
392
+ return `${row?.lastError ? `still copying (${row.lastError})` : 'still copying'}${load}`;
393
+ }
394
+ settleWaiters(sha, landed) {
395
+ const ws = this.waiters.get(sha);
396
+ if (!ws)
397
+ return;
398
+ this.waiters.delete(sha);
399
+ for (const w of ws)
400
+ w(landed);
401
+ }
402
+ /** One pull tick. Single-flight; never throws. */
403
+ async tick() {
404
+ if (this.ticking || !this.transport)
405
+ return;
406
+ this.ticking = true;
407
+ try {
408
+ await this.runTick();
409
+ }
410
+ catch (err) {
411
+ this.report(`pull tick: ${err instanceof Error ? err.message : String(err)}`);
412
+ }
413
+ finally {
414
+ this.ticking = false;
415
+ } // @silent-fallback-ok — reported through deps.report; the next tick retries
416
+ if (this.now() - this.lastInventoryRound >= INVENTORY_EVERY_MS)
417
+ void this.inventoryRound();
418
+ }
419
+ /** How many slices this tick may move for background (non-priority) pulls. */
420
+ backgroundBudget() {
421
+ const v = this.transport?.loadVerdict?.() ?? 'ok';
422
+ if (v !== 'saturated' && v !== 'impaired') {
423
+ this.loadPausedSince = null;
424
+ return SLICES_PER_TICK;
425
+ }
426
+ if (this.loadPausedSince === null)
427
+ this.loadPausedSince = this.now();
428
+ return this.now() - this.loadPausedSince < LOAD_PAUSE_MAX_MS ? 0 : 1;
429
+ }
430
+ async runTick() {
431
+ if (this.d.media.belowDiskFloor())
432
+ return; // never write a blob below the floor (§4.7); missingBlobs counts it
433
+ const wanted = this.wantedHashes();
434
+ const background = this.backgroundBudget();
435
+ const t = this.now();
436
+ const due = (sha) => { const r = this.pullRow(sha); return !!r && r.unrecoverable === 0 && r.nextAttemptAt <= t; };
437
+ const queue = [...[...this.priority].filter(due), ...wanted.filter((h) => !this.priority.has(h) && due(h))];
438
+ let slices = 0;
439
+ let bgSlices = 0;
440
+ for (const sha of queue) {
441
+ if (slices >= SLICES_PER_TICK)
442
+ break;
443
+ const isPriority = this.priority.has(sha);
444
+ if (!isPriority && bgSlices >= background)
445
+ continue;
446
+ // Move this blob as far as the budget allows; stop on the first refusal.
447
+ for (;;) {
448
+ if (slices >= SLICES_PER_TICK || (!isPriority && bgSlices >= background))
449
+ break;
450
+ const step = await this.pullOneSlice(sha);
451
+ if (step === 'moved') {
452
+ slices++;
453
+ if (!isPriority)
454
+ bgSlices++;
455
+ continue;
456
+ }
457
+ if (step === 'landed') {
458
+ slices++;
459
+ if (!isPriority)
460
+ bgSlices++;
461
+ }
462
+ break;
463
+ }
464
+ }
465
+ }
466
+ peerChoice(sha, have) {
467
+ const t = this.now();
468
+ const peers = (this.transport?.peers() ?? []).filter((p) => p.online !== false && !this.breakerOpen(p.machineId, t));
469
+ if (have > 0) {
470
+ // A partial copy continues only from the peer that started it.
471
+ const pinned = this.partPeer.get(sha);
472
+ return pinned && peers.some((p) => p.machineId === pinned) ? pinned : null;
473
+ }
474
+ if (peers.length === 0)
475
+ return null;
476
+ const holders = peers.filter((p) => this.peerInventories.get(p.machineId)?.set.has(sha));
477
+ const pool = holders.length > 0 ? holders : peers;
478
+ const i = this.peerCursor.get(sha) ?? 0;
479
+ return pool[i % pool.length].machineId;
480
+ }
481
+ advancePeer(sha) { this.peerCursor.set(sha, (this.peerCursor.get(sha) ?? 0) + 1); }
482
+ async pullOneSlice(sha) {
483
+ const refs = this.referenced(sha);
484
+ if (!refs) {
485
+ this.priority.delete(sha);
486
+ return 'refused';
487
+ }
488
+ if (this.d.media.locateBlob(sha)) {
489
+ this.landed(sha);
490
+ return 'refused';
491
+ }
492
+ const part = this.partPath(sha);
493
+ let have = 0;
494
+ try {
495
+ have = fs.statSync(part).size;
496
+ }
497
+ catch {
498
+ have = 0;
499
+ } // @silent-fallback-ok — no partial file yet
500
+ if (have > refs.size) {
501
+ this.removePart(sha);
502
+ have = 0;
503
+ }
504
+ // A complete part left by an earlier tick (the disk floor, a crash): verify and keep it, no request.
505
+ if (have === refs.size)
506
+ return this.finishPart(sha, refs.size, this.partPeer.get(sha) ?? null);
507
+ let peer = this.peerChoice(sha, have);
508
+ if (!peer && have > 0) {
509
+ // The peer that started this partial copy is gone (offline, breaker, restart): start over from another.
510
+ this.removePart(sha);
511
+ this.partPeer.delete(sha);
512
+ have = 0;
513
+ peer = this.peerChoice(sha, 0);
514
+ }
515
+ if (!peer) {
516
+ this.blobFailure(sha, 'no peer is reachable');
517
+ return 'refused';
518
+ }
519
+ let res;
520
+ try {
521
+ res = await this.transport.send(peer, { type: 'app-media-pull', op: 'slice', sha256: sha, offset: have }, SLICE_TIMEOUT_MS);
522
+ }
523
+ catch (err) {
524
+ this.advancePeer(sha);
525
+ if (have > 0) {
526
+ this.removePart(sha);
527
+ this.partPeer.delete(sha);
528
+ }
529
+ this.blobFailure(sha, `${peer} did not answer (${err instanceof Error ? err.message : String(err)})`);
530
+ return 'refused';
531
+ }
532
+ const r = (res.ok ? res.result : null);
533
+ if (!r || r.ok !== true) {
534
+ const reason = r && r.ok === false ? r.reason : (res.reason ?? `status ${res.status ?? '?'}`);
535
+ this.advancePeer(sha);
536
+ // The pinned peer no longer serves this file: the partial copy restarts from another peer.
537
+ if (have > 0 && reason !== 'busy') {
538
+ this.removePart(sha);
539
+ this.partPeer.delete(sha);
540
+ }
541
+ if (reason === 'busy')
542
+ return 'refused'; // try again next tick, no backoff
543
+ this.blobFailure(sha, `${peer}: ${reason}`);
544
+ return 'refused';
545
+ }
546
+ const data = typeof r.dataB64 === 'string' ? Buffer.from(r.dataB64, 'base64') : null;
547
+ // A slice must be exactly what was asked for, from a file of the referenced size.
548
+ if (!data || r.offset !== have || r.size !== refs.size || data.length === 0 || data.length > APP_MEDIA_SLICE_BYTES || have + data.length > refs.size) {
549
+ this.removePart(sha);
550
+ this.partPeer.delete(sha);
551
+ this.verifyFailure(peer, sha, 'a slice did not match the request');
552
+ return 'refused';
553
+ }
554
+ if (have === 0)
555
+ this.partPeer.set(sha, peer);
556
+ const fd = fs.openSync(part, have === 0 ? 'w' : 'r+', 0o600); /* state-registry: app-media */
557
+ try {
558
+ fs.writeSync(fd, data, 0, data.length, have);
559
+ fs.fsyncSync(fd);
560
+ }
561
+ finally {
562
+ fs.closeSync(fd);
563
+ }
564
+ this.counters.slicesPulled++;
565
+ this.db.prepare('UPDATE pulls SET lastServedAt = ?, attempts = 0, nextAttemptAt = 0, lastError = NULL WHERE sha256 = ?').run(this.now(), sha);
566
+ if (have + data.length < refs.size)
567
+ return 'moved';
568
+ return this.finishPart(sha, refs.size, peer);
569
+ }
570
+ /** Hash the complete part in a worker; keep it only if length and SHA-256 equal the reference. */
571
+ async finishPart(sha, size, peer) {
572
+ const part = this.partPath(sha);
573
+ const h = await hashFile(part);
574
+ if (h.sha256 !== sha || h.size !== size) {
575
+ this.removePart(sha);
576
+ this.partPeer.delete(sha);
577
+ if (peer)
578
+ this.verifyFailure(peer, sha, 'the copied file did not match its hash');
579
+ else
580
+ this.blobFailure(sha, 'a partial copy did not match its hash');
581
+ return 'refused';
582
+ }
583
+ // A purge can land while the hash runs: purged bytes are never published (§4.7, §4.12).
584
+ if (!this.referenced(sha)) {
585
+ this.dropUnreferenced(sha);
586
+ return 'refused';
587
+ }
588
+ if (this.d.media.belowDiskFloor())
589
+ return 'refused'; // keep the verified part; publish when the floor clears
590
+ fs.chmodSync(part, 0o400);
591
+ if (!this.d.media.publishPulled(part, sha)) {
592
+ this.dropUnreferenced(sha);
593
+ return 'refused';
594
+ }
595
+ this.partPeer.delete(sha);
596
+ if (peer)
597
+ this.peerSuccess(peer);
598
+ this.counters.blobsLanded++;
599
+ this.landed(sha);
600
+ return 'landed';
601
+ }
602
+ /** The file stopped being wanted mid-copy (purged): its partial copy and pull state go, waiters hear no. */
603
+ dropUnreferenced(sha) {
604
+ this.removePart(sha);
605
+ this.partPeer.delete(sha);
606
+ this.priority.delete(sha);
607
+ this.db.prepare('DELETE FROM pulls WHERE sha256 = ?').run(sha);
608
+ this.wantedDirty = true;
609
+ this.settleWaiters(sha, false);
610
+ }
611
+ landed(sha) {
612
+ this.db.prepare('DELETE FROM pulls WHERE sha256 = ?').run(sha);
613
+ this.priority.delete(sha);
614
+ this.lastHurryAt.delete(sha);
615
+ this.wanted = this.wanted.filter((h) => h !== sha);
616
+ const latest = this.ownInventories[this.ownInventories.length - 1];
617
+ if (latest)
618
+ latest.at = 0; // the next inventory read recomputes (versions stay valid for diffs)
619
+ this.settleWaiters(sha, true);
620
+ try {
621
+ this.d.onBlobLanded?.(sha);
622
+ }
623
+ catch (err) {
624
+ this.report(`blob landed hook: ${err instanceof Error ? err.message : String(err)}`);
625
+ }
626
+ }
627
+ /** Backoff 30 s → 1 h; unrecoverable after 7 days with no peer serving it. */
628
+ blobFailure(sha, why) {
629
+ const row = this.pullRow(sha);
630
+ if (!row)
631
+ return;
632
+ const attempts = row.attempts + 1;
633
+ const wait = Math.min(BACKOFF_MIN_MS * 2 ** Math.min(attempts - 1, 20), BACKOFF_MAX_MS);
634
+ const since = row.lastServedAt ?? row.firstWantedAt;
635
+ const giveUp = this.now() - since >= GIVE_UP_MS;
636
+ this.db.prepare('UPDATE pulls SET attempts = ?, nextAttemptAt = ?, lastError = ?, unrecoverable = ? WHERE sha256 = ?')
637
+ .run(attempts, this.now() + wait, why.slice(0, 200), giveUp ? 1 : 0, sha);
638
+ if (giveUp) {
639
+ this.priority.delete(sha);
640
+ this.settleWaiters(sha, false);
641
+ this.evaluateUnrecoverable();
642
+ }
643
+ }
644
+ // ── Peer breaker ─────────────────────────────────────────────────────────────────
645
+ peerRow(id) {
646
+ return this.db.prepare('SELECT failures, openUntil, opensDay, opens FROM pull_peers WHERE machineId = ?').get(id)
647
+ ?? { failures: 0, openUntil: 0, opensDay: null, opens: 0 };
648
+ }
649
+ breakerOpen(id, t = this.now()) {
650
+ const r = this.peerRow(id);
651
+ if (r.opensDay === dayOf(t) && r.opens >= PEER_OPENS_TO_LATCH)
652
+ return true;
653
+ if (r.opens >= PEER_OPENS_TO_LATCH && r.opensDay !== dayOf(t))
654
+ this.clearNotice(`app-media:peer-breaker:${id}`);
655
+ return r.openUntil > t;
656
+ }
657
+ verifyFailure(peer, sha, why) {
658
+ this.counters.verifyFailures++;
659
+ this.blobFailure(sha, `${peer}: ${why}`);
660
+ const t = this.now();
661
+ const r = this.peerRow(peer);
662
+ let { failures, openUntil, opensDay, opens } = r;
663
+ failures++;
664
+ if (opensDay !== dayOf(t)) {
665
+ opensDay = dayOf(t);
666
+ opens = 0;
667
+ }
668
+ if (failures >= PEER_FAILURES_TO_OPEN) {
669
+ failures = 0;
670
+ openUntil = t + PEER_OPEN_MS;
671
+ opens++;
672
+ this.report(`copies from ${peer} failed verification ${PEER_FAILURES_TO_OPEN} times in a row; not asking it for 15 minutes`);
673
+ if (opens >= PEER_OPENS_TO_LATCH) {
674
+ this.raiseOnce(`app-media:peer-breaker:${peer}`, 'latched', {
675
+ title: `Files copied from ${peer} keep arriving damaged`,
676
+ body: `This machine stopped copying app files from ${peer} until tomorrow: its copies failed verification ${PEER_FAILURES_TO_OPEN * PEER_OPENS_TO_LATCH} times today. Files are still copied from the agent's other machines. A damaged copy is never kept.`,
677
+ });
678
+ }
679
+ }
680
+ this.db.prepare('INSERT OR REPLACE INTO pull_peers (machineId, failures, openUntil, opensDay, opens) VALUES (?, ?, ?, ?, ?)').run(peer, failures, openUntil, opensDay, opens);
681
+ }
682
+ peerSuccess(peer) {
683
+ this.db.prepare('UPDATE pull_peers SET failures = 0 WHERE machineId = ?').run(peer);
684
+ }
685
+ // ── Inventories and copy counts ──────────────────────────────────────────────────
686
+ /** Fetch every online peer's inventory (diff when possible), then evaluate the alarms. */
687
+ async inventoryRound() {
688
+ if (this.inventoryRunning || !this.transport)
689
+ return;
690
+ this.inventoryRunning = true;
691
+ this.lastInventoryRound = this.now();
692
+ try {
693
+ for (const p of this.transport.peers()) {
694
+ if (p.online === false)
695
+ continue;
696
+ try {
697
+ await this.fetchInventory(p.machineId);
698
+ }
699
+ catch (err) {
700
+ this.report(`inventory from ${p.machineId}: ${err instanceof Error ? err.message : String(err)}`);
701
+ }
702
+ }
703
+ this.evaluateAlarms();
704
+ this.evaluateUnrecoverable();
705
+ }
706
+ finally {
707
+ this.inventoryRunning = false;
708
+ }
709
+ }
710
+ async fetchInventory(peer) {
711
+ const prev = this.peerInventories.get(peer);
712
+ const first = await this.transport.send(peer, { type: 'app-media-pull', op: 'inventory', ...(prev ? { since: prev.version } : {}) }, INVENTORY_TIMEOUT_MS);
713
+ const a = (first.ok ? first.result : null);
714
+ if (!a || a.ok !== true)
715
+ return; // the old inventory ages out; singleCopyBlobs turns null
716
+ let set;
717
+ if ('unchanged' in a && prev)
718
+ set = prev.set;
719
+ else if ('diff' in a && prev) {
720
+ set = new Set(prev.set);
721
+ for (const h of a.diff.added)
722
+ if (isSha256Hex(h))
723
+ set.add(h);
724
+ for (const h of a.diff.removed)
725
+ set.delete(h);
726
+ }
727
+ else if ('full' in a) {
728
+ set = new Set(a.hashes.filter(isSha256Hex));
729
+ let next = a.next;
730
+ let pages = 1;
731
+ while (next && pages < INVENTORY_MAX_PAGES) {
732
+ const r = await this.transport.send(peer, { type: 'app-media-pull', op: 'inventory', after: next }, INVENTORY_TIMEOUT_MS);
733
+ const page = (r.ok ? r.result : null);
734
+ if (!page || page.ok !== true || !('full' in page) || page.version !== a.version)
735
+ return; // changed mid-read: keep the old one
736
+ for (const h of page.hashes)
737
+ if (isSha256Hex(h))
738
+ set.add(h);
739
+ next = page.next;
740
+ pages++;
741
+ }
742
+ if (next)
743
+ return; // too many pages for one round; the inventory is not complete
744
+ }
745
+ else
746
+ return;
747
+ this.peerInventories.set(peer, { version: a.version, set, at: this.now() });
748
+ // A peer that now holds a blob this machine gave up on re-arms it (§4.12).
749
+ for (const r of this.db.prepare('SELECT sha256 FROM pulls WHERE unrecoverable = 1').all()) {
750
+ if (set.has(r.sha256))
751
+ this.rearm(r.sha256);
752
+ }
753
+ }
754
+ /**
755
+ * Held, unpurged blobs no peer inventory younger than 2 hours lists. `count: null` (never 0)
756
+ * when that cannot be measured: no mesh transport, or a peer whose inventory is old or missing.
757
+ */
758
+ singleCopy() {
759
+ if (!this.transport)
760
+ return { count: null, hashes: [], reason: 'this machine is not connected to the agent\'s other machines' };
761
+ const peers = this.transport.peers();
762
+ const t = this.now();
763
+ const stale = peers.filter((p) => { const inv = this.peerInventories.get(p.machineId); return !inv || t - inv.at > INVENTORY_FRESH_MS; });
764
+ if (stale.length > 0)
765
+ return { count: null, hashes: [], reason: `no recent list of files from ${stale.map((p) => p.nickname || p.machineId).join(', ')}` };
766
+ const held = this.ownInventory().hashes;
767
+ const alone = held.filter((h) => !peers.some((p) => this.peerInventories.get(p.machineId).set.has(h)));
768
+ return { count: alone.length, hashes: alone, reason: null };
769
+ }
770
+ unrecoverableCount() {
771
+ let n = 0;
772
+ for (const r of this.db.prepare('SELECT sha256 FROM pulls WHERE unrecoverable = 1').all())
773
+ if (this.referenced(r.sha256))
774
+ n++;
775
+ return n;
776
+ }
777
+ conversationsOf(hashes) {
778
+ const keys = new Set();
779
+ for (const h of hashes.slice(0, 200))
780
+ for (const k of this.d.store.mediaConversationsFor(h))
781
+ keys.add(k);
782
+ const list = [...keys];
783
+ const titled = list.slice(0, 10).map((k) => {
784
+ const c = this.d.store.getConversation(k.startsWith('app:') ? k.slice(4) : k);
785
+ return c?.title ? `"${c.title}"` : k;
786
+ });
787
+ return `${titled.join(', ')}${list.length > 10 ? ` and ${list.length - 10} more` : ''}`;
788
+ }
789
+ /** The single-copy alarm ladder (§4.12). Called after each inventory round. */
790
+ evaluateAlarms() {
791
+ if (!this.transport)
792
+ return;
793
+ const peers = this.transport.peers();
794
+ if (peers.length === 0)
795
+ return; // one machine: nothing to copy to, nothing to alarm about
796
+ const self = this.d.selfId();
797
+ const key = `app-media:single-copy:${self}`;
798
+ const t = this.now();
799
+ const sc = this.singleCopy();
800
+ if (sc.count === null) {
801
+ this.singleSince = null;
802
+ this.nudgedAt = null;
803
+ if (this.unmeasurableSince === null)
804
+ this.unmeasurableSince = t;
805
+ if (t - this.unmeasurableSince > UNMEASURABLE_ALARM_MS) {
806
+ this.raiseOnce(key, 'unmeasurable', {
807
+ title: 'Cannot tell whether app files have a second copy',
808
+ body: `For over 2 hours this machine could not measure how many app files exist only here: ${sc.reason}.`,
809
+ });
810
+ }
811
+ return;
812
+ }
813
+ this.unmeasurableSince = null;
814
+ for (const h of [...this.singleFirstSeen.keys()])
815
+ if (!sc.hashes.includes(h))
816
+ this.singleFirstSeen.delete(h);
817
+ for (const h of sc.hashes)
818
+ if (!this.singleFirstSeen.has(h))
819
+ this.singleFirstSeen.set(h, t);
820
+ if (sc.count === 0) {
821
+ this.singleSince = null;
822
+ this.nudgedAt = null;
823
+ this.clearNotice(key);
824
+ return;
825
+ }
826
+ if (this.singleSince === null)
827
+ this.singleSince = t;
828
+ const offline = peers.filter((p) => p.online === false);
829
+ if (offline.length > 0) {
830
+ const old = sc.hashes.filter((h) => t - (this.singleFirstSeen.get(h) ?? t) > SINGLE_COPY_OFFLINE_MS);
831
+ if (old.length > 0) {
832
+ this.raiseOnce(key, 'offline', {
833
+ title: 'Some app files exist on one machine only',
834
+ body: `${old.length} file(s) from ${this.conversationsOf(old)} have been on this machine only for over a day, because ${offline.map((p) => p.nickname || p.machineId).join(', ')} ${offline.length === 1 ? 'is' : 'are'} offline. They will be copied when it is back.`,
835
+ });
836
+ }
837
+ return;
838
+ }
839
+ if (t - this.singleSince <= SINGLE_COPY_GRACE_MS)
840
+ return;
841
+ if (this.nudgedAt === null) {
842
+ // First self-heal: ask every peer to pull what only this machine holds.
843
+ this.nudgedAt = t;
844
+ for (const p of peers) {
845
+ this.counters.nudgesSent++;
846
+ void this.transport.send(p.machineId, { type: 'app-media-pull', op: 'nudge', hashes: sc.hashes.slice(0, NUDGE_MAX) }, INVENTORY_TIMEOUT_MS)
847
+ .catch((err) => this.report(`nudge to ${p.machineId}: ${err instanceof Error ? err.message : String(err)}`));
848
+ }
849
+ return;
850
+ }
851
+ if (t - this.nudgedAt > SINGLE_COPY_GRACE_MS) {
852
+ this.raiseOnce(key, 'stuck', {
853
+ title: 'Some app files exist on one machine only',
854
+ body: `${sc.count} file(s) from ${this.conversationsOf(sc.hashes)} are still held only by this machine, although every machine is online and was asked to copy them an hour ago.`,
855
+ });
856
+ }
857
+ }
858
+ /** One notice per machine while blobs are unrecoverable (§4.12). */
859
+ evaluateUnrecoverable() {
860
+ const key = `app-media:unrecoverable:${this.d.selfId()}`;
861
+ const rows = this.db.prepare('SELECT sha256 FROM pulls WHERE unrecoverable = 1').all().map((r) => r.sha256).filter((h) => this.referenced(h));
862
+ if (rows.length === 0) {
863
+ this.clearNotice(key);
864
+ return;
865
+ }
866
+ this.raiseOnce(key, 'unrecoverable', {
867
+ title: 'Some app files could not be copied to this machine',
868
+ body: `${rows.length} file(s) from ${this.conversationsOf(rows)} could not be copied here: no machine of this agent has been able to provide them for 7 days. They are copied as soon as a machine that holds them is back.`,
869
+ });
870
+ }
871
+ /** Raise once per condition and reason; a new reason refreshes the same item. */
872
+ raiseOnce(key, reason, item) {
873
+ if (this.raised.get(key) === reason)
874
+ return;
875
+ this.raised.set(key, reason);
876
+ try {
877
+ this.d.raiseAttention?.({ conditionKey: key, ...item });
878
+ }
879
+ catch (err) {
880
+ this.report(`notice: ${err instanceof Error ? err.message : String(err)}`);
881
+ }
882
+ }
883
+ clearNotice(key) {
884
+ if (!this.raised.delete(key))
885
+ return;
886
+ try {
887
+ this.d.clearAttention?.(key);
888
+ }
889
+ catch (err) {
890
+ this.report(`notice clear: ${err instanceof Error ? err.message : String(err)}`);
891
+ }
892
+ }
893
+ /** Observability for tests and /health. */
894
+ status() {
895
+ return {
896
+ transport: this.transport !== null, wanted: this.wanted.length, priority: this.priority.size, serving: this.serving,
897
+ loadPausedSince: this.loadPausedSince ? new Date(this.loadPausedSince).toISOString() : null,
898
+ peerInventories: [...this.peerInventories.entries()].map(([id, inv]) => ({ machineId: id, blobs: inv.set.size, at: new Date(inv.at).toISOString() })),
899
+ counters: { ...this.counters },
900
+ };
901
+ }
902
+ }
903
+ //# sourceMappingURL=AppMediaReplicator.js.map