@remnic/core 9.15.3 → 9.16.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 (311) hide show
  1. package/dist/access-admin-ops-surface.d.ts +8 -8
  2. package/dist/access-admin-ops-surface.js +7 -7
  3. package/dist/access-boundary.d.ts +8 -8
  4. package/dist/access-boundary.js +7 -7
  5. package/dist/access-cli.js +31 -30
  6. package/dist/access-cli.js.map +1 -1
  7. package/dist/access-http.d.ts +8 -8
  8. package/dist/access-http.js +11 -11
  9. package/dist/access-identity-continuity-surface.d.ts +6 -6
  10. package/dist/access-identity-continuity-surface.js +7 -7
  11. package/dist/access-lcm-surface.d.ts +8 -8
  12. package/dist/access-lcm-surface.js +7 -7
  13. package/dist/access-mcp.d.ts +8 -8
  14. package/dist/access-mcp.js +10 -10
  15. package/dist/access-memory-search-fanout.d.ts +2 -2
  16. package/dist/{access-namespace-preflight-B9e5GnyZ.d.ts → access-namespace-preflight-DwhEFYy3.d.ts} +1 -1
  17. package/dist/access-namespace-preflight.d.ts +3 -3
  18. package/dist/access-namespace-preflight.js +7 -7
  19. package/dist/access-observe-write-surface.d.ts +8 -8
  20. package/dist/access-observe-write-surface.js +7 -7
  21. package/dist/access-operations-batch.js +8 -8
  22. package/dist/access-operations.d.ts +8 -8
  23. package/dist/access-operations.js +9 -9
  24. package/dist/access-recall-concurrency.d.ts +8 -8
  25. package/dist/access-recall-concurrency.js +7 -7
  26. package/dist/access-recall-response.d.ts +8 -8
  27. package/dist/access-recall-response.js +7 -7
  28. package/dist/access-recall-surface.d.ts +8 -8
  29. package/dist/access-recall-surface.js +7 -7
  30. package/dist/{access-service-DgmJd_A-.d.ts → access-service-CSq1Wp2Z.d.ts} +6 -6
  31. package/dist/access-service.d.ts +8 -8
  32. package/dist/access-service.js +7 -7
  33. package/dist/access-surface-catalog.d.ts +8 -8
  34. package/dist/action-confidence.d.ts +1 -1
  35. package/dist/active-memory-bridge.d.ts +1 -1
  36. package/dist/active-recall.d.ts +1 -1
  37. package/dist/active-recall.js +2 -1
  38. package/dist/active-recall.js.map +1 -1
  39. package/dist/behavior-learner.d.ts +1 -1
  40. package/dist/behavior-signals.d.ts +1 -1
  41. package/dist/bootstrap.d.ts +6 -6
  42. package/dist/briefing.d.ts +2 -2
  43. package/dist/briefing.js +3 -3
  44. package/dist/buffer-surprise-report.d.ts +1 -1
  45. package/dist/buffer.d.ts +2 -2
  46. package/dist/bulk-import/index.d.ts +3 -3
  47. package/dist/calibration.d.ts +1 -1
  48. package/dist/capabilities.d.ts +1 -1
  49. package/dist/{catalog-g_dfT6oL.d.ts → catalog-B9uga4sG.d.ts} +1 -1
  50. package/dist/causal-behavior.d.ts +1 -1
  51. package/dist/causal-consolidation.d.ts +1 -1
  52. package/dist/causal-consolidation.js +4 -4
  53. package/dist/causal-trajectory-graph.d.ts +1 -1
  54. package/dist/{chunk-IRX5UCGN.js → chunk-2OH3ZRYH.js} +3 -3
  55. package/dist/{chunk-YXTSUIXC.js → chunk-5EKOBRCA.js} +6 -1
  56. package/dist/chunk-5EKOBRCA.js.map +1 -0
  57. package/dist/{chunk-42DJZZUB.js → chunk-7LXSPIVG.js} +2 -2
  58. package/dist/{chunk-47WFEKT7.js → chunk-7PK4W4RN.js} +2 -2
  59. package/dist/{chunk-O7ACJAHR.js → chunk-7YJLCNPF.js} +2 -2
  60. package/dist/{chunk-6EJL2O3G.js → chunk-AFH7PIFU.js} +2 -2
  61. package/dist/{chunk-JN4N2TMQ.js → chunk-ANGUOE2N.js} +7 -7
  62. package/dist/{chunk-GQOKO77V.js → chunk-AWT7SCWQ.js} +2 -2
  63. package/dist/{chunk-PXJEL55X.js → chunk-BIRPR5N7.js} +2 -2
  64. package/dist/{chunk-NAY25BGS.js → chunk-BTK2PIFL.js} +6 -6
  65. package/dist/{chunk-SYCVU556.js → chunk-C2RMQRPN.js} +3 -3
  66. package/dist/{chunk-T4EAQPQQ.js → chunk-G2WCVFDC.js} +2 -2
  67. package/dist/{chunk-UJIBXOJA.js → chunk-GEDBTSYU.js} +5 -5
  68. package/dist/{chunk-CXFVZ3PK.js → chunk-GRJ5UOFO.js} +84 -658
  69. package/dist/chunk-GRJ5UOFO.js.map +1 -0
  70. package/dist/chunk-GSQASASV.js +429 -0
  71. package/dist/chunk-GSQASASV.js.map +1 -0
  72. package/dist/{chunk-WIKOHCQZ.js → chunk-I6ODVWQB.js} +2 -2
  73. package/dist/{chunk-H4MDUPER.js → chunk-IZHXSLO7.js} +2 -2
  74. package/dist/{chunk-RRS5CDB6.js → chunk-JOCXI2O5.js} +2 -2
  75. package/dist/{chunk-EEUZQQRJ.js → chunk-KGKYTCDO.js} +2 -2
  76. package/dist/{chunk-JTW4TGPV.js → chunk-L3HAUO7K.js} +2 -2
  77. package/dist/{chunk-P53HGVJS.js → chunk-LMVAIZR5.js} +2 -2
  78. package/dist/{chunk-KLXG4BBO.js → chunk-M6TZQDTX.js} +2 -2
  79. package/dist/{chunk-FJ5IQ3SI.js → chunk-N7DK67GZ.js} +1 -1
  80. package/dist/chunk-N7DK67GZ.js.map +1 -0
  81. package/dist/{chunk-U6QWUFSM.js → chunk-OXT5TQDS.js} +2 -2
  82. package/dist/{chunk-T4OTDMX4.js → chunk-QDTEH6BS.js} +2 -2
  83. package/dist/{chunk-5MDE5MIC.js → chunk-SESF64QR.js} +2 -2
  84. package/dist/{chunk-4E5H4FPC.js → chunk-TUNI4GB3.js} +3 -3
  85. package/dist/{chunk-XJT6LNLY.js → chunk-V6LYAXGW.js} +834 -25
  86. package/dist/chunk-V6LYAXGW.js.map +1 -0
  87. package/dist/{chunk-5HFIHWOF.js → chunk-VLIY55Q5.js} +17 -17
  88. package/dist/{chunk-BNH2VVWP.js → chunk-WIS5JTJR.js} +2 -2
  89. package/dist/{chunk-OOGGUXAG.js → chunk-WQ2I6ODB.js} +2 -2
  90. package/dist/{cli-BXCd2cgK.d.ts → cli-DYLGXxuu.d.ts} +5 -5
  91. package/dist/cli.d.ts +10 -10
  92. package/dist/cli.js +25 -24
  93. package/dist/compounding/engine.d.ts +2 -2
  94. package/dist/compounding/engine.js +3 -3
  95. package/dist/compounding/preference-consolidator.d.ts +1 -1
  96. package/dist/compression-optimizer.d.ts +1 -1
  97. package/dist/config.d.ts +1 -1
  98. package/dist/config.js +2 -1
  99. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  100. package/dist/connectors/codex-materialize-runner.js +3 -3
  101. package/dist/connectors/codex-materialize.d.ts +1 -1
  102. package/dist/connectors/index.d.ts +1 -1
  103. package/dist/connectors/index.js +3 -3
  104. package/dist/consolidation-provenance-check.d.ts +2 -2
  105. package/dist/consolidation-undo.d.ts +2 -2
  106. package/dist/contradiction/index.d.ts +2 -2
  107. package/dist/conversation-index/backend.d.ts +1 -1
  108. package/dist/conversation-index/chunker.d.ts +1 -1
  109. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  110. package/dist/conversation-index/indexer.d.ts +1 -1
  111. package/dist/conversation-index/search.d.ts +1 -1
  112. package/dist/day-summary.d.ts +1 -1
  113. package/dist/delinearize.d.ts +1 -1
  114. package/dist/direct-answer-wiring.d.ts +1 -1
  115. package/dist/direct-answer.d.ts +1 -1
  116. package/dist/embedding-fallback.d.ts +1 -1
  117. package/dist/enrichment/index.d.ts +1 -1
  118. package/dist/entity-retrieval.d.ts +2 -2
  119. package/dist/entity-retrieval.js +3 -3
  120. package/dist/entity-schema.d.ts +1 -1
  121. package/dist/explicit-capture.d.ts +6 -6
  122. package/dist/extraction-faithfulness.d.ts +1 -1
  123. package/dist/extraction-judge-telemetry.d.ts +1 -1
  124. package/dist/extraction-judge-training.d.ts +1 -1
  125. package/dist/extraction-judge.d.ts +1 -1
  126. package/dist/extraction.d.ts +1 -1
  127. package/dist/fallback-llm.d.ts +1 -1
  128. package/dist/graph-dashboard-diff.d.ts +1 -1
  129. package/dist/graph-dashboard-key.d.ts +1 -1
  130. package/dist/graph-dashboard-parser.d.ts +1 -1
  131. package/dist/graph-edge-reinforcement.d.ts +1 -1
  132. package/dist/graph-snapshot.d.ts +1 -1
  133. package/dist/graph.d.ts +1 -1
  134. package/dist/importance.d.ts +1 -1
  135. package/dist/importers/index.d.ts +1 -1
  136. package/dist/in-flight-reads.d.ts +1 -1
  137. package/dist/index.d.ts +289 -99
  138. package/dist/index.js +70 -45
  139. package/dist/intent.d.ts +1 -1
  140. package/dist/lcm/engine.d.ts +1 -1
  141. package/dist/lcm/index.d.ts +1 -1
  142. package/dist/lcm/tools.d.ts +1 -1
  143. package/dist/lifecycle.d.ts +1 -1
  144. package/dist/live-connectors-runner.d.ts +1 -1
  145. package/dist/local-llm.d.ts +1 -1
  146. package/dist/local-model-endpoint.d.ts +1 -1
  147. package/dist/maintenance/memory-governance.d.ts +1 -1
  148. package/dist/maintenance/memory-governance.js +3 -3
  149. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +2 -2
  150. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  151. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  152. package/dist/mcp-memory-inspector-app.d.ts +8 -8
  153. package/dist/memory-action-policy.d.ts +1 -1
  154. package/dist/memory-cache.d.ts +1 -1
  155. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  156. package/dist/memory-projection-store.d.ts +1 -1
  157. package/dist/memory-provenance.d.ts +1 -1
  158. package/dist/memory-worth-outcomes.d.ts +2 -2
  159. package/dist/models-json.d.ts +1 -1
  160. package/dist/namespaces/migrate.d.ts +3 -3
  161. package/dist/namespaces/migrate.js +4 -4
  162. package/dist/namespaces/principal.d.ts +1 -1
  163. package/dist/namespaces/search.d.ts +1 -1
  164. package/dist/namespaces/storage.d.ts +3 -3
  165. package/dist/namespaces/storage.js +3 -3
  166. package/dist/native-knowledge.d.ts +1 -1
  167. package/dist/offline-sync-impression-drain.d.ts +1 -1
  168. package/dist/operator-toolkit.d.ts +2 -2
  169. package/dist/operator-toolkit.js +9 -8
  170. package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
  171. package/dist/orchestration/maintenance.d.ts +10 -7
  172. package/dist/orchestration/maintenance.js +7 -6
  173. package/dist/{orchestrator-BHfWtvEJ.d.ts → orchestrator-BHx-DWCa.d.ts} +7 -5
  174. package/dist/orchestrator.d.ts +6 -6
  175. package/dist/orchestrator.js +31 -30
  176. package/dist/patterns-cli.d.ts +1 -1
  177. package/dist/{pipeline-CBYcnmDC.d.ts → pipeline-DDHSE876.d.ts} +1 -1
  178. package/dist/policy-runtime.d.ts +1 -1
  179. package/dist/proactive-contention.d.ts +1 -1
  180. package/dist/provenance.d.ts +1 -1
  181. package/dist/{qmd-BIq-jrV4.d.ts → qmd-C4WVh6BM.d.ts} +1 -1
  182. package/dist/qmd-preflight.d.ts +2 -2
  183. package/dist/qmd-recall-cache.d.ts +1 -1
  184. package/dist/qmd.d.ts +2 -2
  185. package/dist/recall-concurrency-config.d.ts +1 -1
  186. package/dist/recall-disclosure-escalation.d.ts +1 -1
  187. package/dist/recall-explain-renderer.d.ts +1 -1
  188. package/dist/recall-explain-renderer.js +3 -3
  189. package/dist/recall-memory-map.d.ts +1 -1
  190. package/dist/recall-planner-llm.d.ts +1 -1
  191. package/dist/recall-state.d.ts +1 -1
  192. package/dist/recall-tag-filter.d.ts +1 -1
  193. package/dist/recall-timings.d.ts +1 -1
  194. package/dist/recall-xray-cli.d.ts +1 -1
  195. package/dist/recall-xray-cli.js +4 -4
  196. package/dist/recall-xray-renderer.d.ts +1 -1
  197. package/dist/recall-xray-renderer.js +3 -3
  198. package/dist/recall-xray.d.ts +1 -1
  199. package/dist/recall-xray.js +2 -2
  200. package/dist/replay/normalizers/chatgpt.d.ts +1 -1
  201. package/dist/replay/normalizers/claude.d.ts +1 -1
  202. package/dist/replay/normalizers/openclaw.d.ts +1 -1
  203. package/dist/replay/normalizers/shared.d.ts +1 -1
  204. package/dist/replay/runner.d.ts +1 -1
  205. package/dist/replay/types.d.ts +1 -1
  206. package/dist/resolve-auth-token.d.ts +1 -1
  207. package/dist/resume-bundles.js +3 -2
  208. package/dist/retrieval-agents.d.ts +2 -2
  209. package/dist/retrieval-tiers.d.ts +1 -1
  210. package/dist/routing/engine.d.ts +1 -1
  211. package/dist/routing/store.d.ts +1 -1
  212. package/dist/salvage-envelope.d.ts +1 -1
  213. package/dist/{scope-profiles-DDno-uvy.d.ts → scope-profiles-EqMXwzCz.d.ts} +1 -1
  214. package/dist/search/embed-helper.d.ts +1 -1
  215. package/dist/search/factory.d.ts +1 -1
  216. package/dist/search/index.d.ts +1 -1
  217. package/dist/search/lancedb-backend.d.ts +1 -1
  218. package/dist/search/meilisearch-backend.d.ts +1 -1
  219. package/dist/search/noop-backend.d.ts +1 -1
  220. package/dist/search/orama-backend.d.ts +1 -1
  221. package/dist/search/port.d.ts +1 -1
  222. package/dist/search/remote-backend.d.ts +1 -1
  223. package/dist/{semantic-consolidation-7RIJFDwY.d.ts → semantic-consolidation-CYpvwmQc.d.ts} +1 -1
  224. package/dist/semantic-consolidation.d.ts +2 -2
  225. package/dist/semantic-consolidation.js +4 -4
  226. package/dist/semantic-rule-promotion.js +3 -3
  227. package/dist/semantic-rule-verifier.d.ts +1 -1
  228. package/dist/semantic-rule-verifier.js +3 -3
  229. package/dist/session-observer-bands.d.ts +1 -1
  230. package/dist/session-observer-state.d.ts +1 -1
  231. package/dist/shared-context/manager.d.ts +1 -1
  232. package/dist/signal.d.ts +1 -1
  233. package/dist/{storage-DkphZiOJ.d.ts → storage-Dxd7zVZH.d.ts} +1 -1
  234. package/dist/storage.d.ts +2 -2
  235. package/dist/storage.js +2 -2
  236. package/dist/summarizer.d.ts +1 -1
  237. package/dist/summary-snapshot.d.ts +1 -1
  238. package/dist/temporal-supersession.d.ts +2 -2
  239. package/dist/temporal-timeline-recall.d.ts +1 -1
  240. package/dist/temporal-validity.d.ts +1 -1
  241. package/dist/threading.d.ts +1 -1
  242. package/dist/tier-migration.d.ts +2 -2
  243. package/dist/tier-routing.d.ts +1 -1
  244. package/dist/topics.d.ts +1 -1
  245. package/dist/transcript.d.ts +1 -1
  246. package/dist/trust-score-stage.d.ts +1 -1
  247. package/dist/trust-score.d.ts +1 -1
  248. package/dist/{types-2q_MRFcM.d.ts → types-OUCJALBK.d.ts} +98 -1
  249. package/dist/types.d.ts +1 -1
  250. package/dist/types.js +1 -1
  251. package/dist/utility-runtime.d.ts +1 -1
  252. package/dist/verified-recall.js +3 -3
  253. package/dist/write-envelope.d.ts +1 -1
  254. package/package.json +2 -2
  255. package/src/activity/config.test.ts +113 -0
  256. package/src/activity/config.ts +111 -0
  257. package/src/activity/digest.ts +2 -2
  258. package/src/activity/index.ts +6 -0
  259. package/src/activity/pipeline.test.ts +379 -0
  260. package/src/activity/pipeline.ts +223 -0
  261. package/src/activity/reindex.test.ts +97 -0
  262. package/src/activity/reindex.ts +40 -0
  263. package/src/activity/runner.test.ts +452 -0
  264. package/src/activity/runner.ts +237 -0
  265. package/src/activity/scheduler.test.ts +303 -0
  266. package/src/activity/scheduler.ts +147 -0
  267. package/src/activity/source-client.test.ts +208 -0
  268. package/src/activity/source-client.ts +159 -0
  269. package/src/activity/sync-registration.test.ts +134 -0
  270. package/src/activity/sync-registration.ts +139 -0
  271. package/src/activity/types.ts +17 -0
  272. package/src/config.test.ts +19 -0
  273. package/src/config.ts +3 -0
  274. package/src/orchestration/maintenance.test.ts +42 -42
  275. package/src/orchestration/maintenance.ts +37 -5
  276. package/src/orchestration/orchestrator-helpers.ts +36 -1
  277. package/src/orchestration/recall-internal.ts +6 -6
  278. package/src/orchestration/recall-search-pipeline.ts +15 -7
  279. package/src/orchestrator-background-writes.test.ts +2 -2
  280. package/src/orchestrator.ts +3 -3
  281. package/src/types.ts +4 -1
  282. package/dist/chunk-CXFVZ3PK.js.map +0 -1
  283. package/dist/chunk-FJ5IQ3SI.js.map +0 -1
  284. package/dist/chunk-XJT6LNLY.js.map +0 -1
  285. package/dist/chunk-YXTSUIXC.js.map +0 -1
  286. /package/dist/{chunk-IRX5UCGN.js.map → chunk-2OH3ZRYH.js.map} +0 -0
  287. /package/dist/{chunk-42DJZZUB.js.map → chunk-7LXSPIVG.js.map} +0 -0
  288. /package/dist/{chunk-47WFEKT7.js.map → chunk-7PK4W4RN.js.map} +0 -0
  289. /package/dist/{chunk-O7ACJAHR.js.map → chunk-7YJLCNPF.js.map} +0 -0
  290. /package/dist/{chunk-6EJL2O3G.js.map → chunk-AFH7PIFU.js.map} +0 -0
  291. /package/dist/{chunk-JN4N2TMQ.js.map → chunk-ANGUOE2N.js.map} +0 -0
  292. /package/dist/{chunk-GQOKO77V.js.map → chunk-AWT7SCWQ.js.map} +0 -0
  293. /package/dist/{chunk-PXJEL55X.js.map → chunk-BIRPR5N7.js.map} +0 -0
  294. /package/dist/{chunk-NAY25BGS.js.map → chunk-BTK2PIFL.js.map} +0 -0
  295. /package/dist/{chunk-SYCVU556.js.map → chunk-C2RMQRPN.js.map} +0 -0
  296. /package/dist/{chunk-T4EAQPQQ.js.map → chunk-G2WCVFDC.js.map} +0 -0
  297. /package/dist/{chunk-UJIBXOJA.js.map → chunk-GEDBTSYU.js.map} +0 -0
  298. /package/dist/{chunk-WIKOHCQZ.js.map → chunk-I6ODVWQB.js.map} +0 -0
  299. /package/dist/{chunk-H4MDUPER.js.map → chunk-IZHXSLO7.js.map} +0 -0
  300. /package/dist/{chunk-RRS5CDB6.js.map → chunk-JOCXI2O5.js.map} +0 -0
  301. /package/dist/{chunk-EEUZQQRJ.js.map → chunk-KGKYTCDO.js.map} +0 -0
  302. /package/dist/{chunk-JTW4TGPV.js.map → chunk-L3HAUO7K.js.map} +0 -0
  303. /package/dist/{chunk-P53HGVJS.js.map → chunk-LMVAIZR5.js.map} +0 -0
  304. /package/dist/{chunk-KLXG4BBO.js.map → chunk-M6TZQDTX.js.map} +0 -0
  305. /package/dist/{chunk-U6QWUFSM.js.map → chunk-OXT5TQDS.js.map} +0 -0
  306. /package/dist/{chunk-T4OTDMX4.js.map → chunk-QDTEH6BS.js.map} +0 -0
  307. /package/dist/{chunk-5MDE5MIC.js.map → chunk-SESF64QR.js.map} +0 -0
  308. /package/dist/{chunk-4E5H4FPC.js.map → chunk-TUNI4GB3.js.map} +0 -0
  309. /package/dist/{chunk-5HFIHWOF.js.map → chunk-VLIY55Q5.js.map} +0 -0
  310. /package/dist/{chunk-BNH2VVWP.js.map → chunk-WIS5JTJR.js.map} +0 -0
  311. /package/dist/{chunk-OOGGUXAG.js.map → chunk-WQ2I6ODB.js.map} +0 -0
@@ -0,0 +1,208 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer, type RequestListener } from "node:http";
3
+ import test from "node:test";
4
+
5
+ import { ActivityHttpSourceClient } from "./source-client.js";
6
+
7
+ async function withServer(
8
+ handler: RequestListener,
9
+ run: (baseUrl: string) => Promise<void>,
10
+ ): Promise<void> {
11
+ const server = createServer(handler);
12
+ const listening = Promise.withResolvers<void>();
13
+ server.listen(0, "127.0.0.1", listening.resolve);
14
+ await listening.promise;
15
+ try {
16
+ const address = server.address();
17
+ assert.ok(address && typeof address !== "string");
18
+ await run(`http://127.0.0.1:${address.port}`);
19
+ } finally {
20
+ const closed = Promise.withResolvers<void>();
21
+ server.close((error) => (error ? closed.reject(error) : closed.resolve()));
22
+ await closed.promise;
23
+ }
24
+ }
25
+
26
+ test("ActivityHttpSourceClient authenticates and maps a snapshot page", async () => {
27
+ await withServer((request, response) => {
28
+ assert.equal(request.headers.authorization, "Bearer fixture-token");
29
+ assert.equal(request.url, "/v1/snapshots?date=2026-07-22&timezone=America%2FChicago&cursor=next%20cursor");
30
+ response.setHeader("content-type", "application/json");
31
+ response.end(
32
+ JSON.stringify({
33
+ snapshots: [
34
+ {
35
+ capturedAtUtc: "2026-07-22T14:00:00.000Z",
36
+ app: "Browser",
37
+ windowTitle: "Fixture",
38
+ text: "synthetic text",
39
+ textSource: "ax",
40
+ contentHash: "abc123",
41
+ },
42
+ ],
43
+ nextCursor: "cursor-2",
44
+ }),
45
+ );
46
+ }, async (baseUrl) => {
47
+ const client = new ActivityHttpSourceClient({ machineLabel: "fixture-machine", baseUrl, token: "fixture-token" });
48
+ const page = await client.fetchSnapshots({ date: "2026-07-22", timezone: "America/Chicago", cursor: "next cursor" });
49
+
50
+ assert.deepEqual(page, {
51
+ snapshots: [
52
+ {
53
+ machine: "fixture-machine",
54
+ capturedAtUtc: "2026-07-22T14:00:00.000Z",
55
+ app: "Browser",
56
+ windowTitle: "Fixture",
57
+ text: "synthetic text",
58
+ textSource: "ax",
59
+ contentHash: "abc123",
60
+ },
61
+ ],
62
+ nextCursor: "cursor-2",
63
+ });
64
+ });
65
+ });
66
+
67
+ test("ActivityHttpSourceClient treats an omitted nextCursor as end-of-pagination", async () => {
68
+ await withServer((_request, response) => {
69
+ response.setHeader("content-type", "application/json");
70
+ // Daemon omits nextCursor entirely (not JSON null) on the final page.
71
+ response.end(
72
+ JSON.stringify({
73
+ snapshots: [
74
+ {
75
+ capturedAtUtc: "2026-07-22T14:00:00.000Z",
76
+ app: "Browser",
77
+ windowTitle: "Fixture",
78
+ text: "synthetic text",
79
+ textSource: "ax",
80
+ contentHash: "abc123",
81
+ },
82
+ ],
83
+ }),
84
+ );
85
+ }, async (baseUrl) => {
86
+ const client = new ActivityHttpSourceClient({ machineLabel: "fixture-machine", baseUrl });
87
+ const page = await client.fetchSnapshots({ date: "2026-07-22", timezone: "UTC" });
88
+ assert.equal(page.nextCursor, null, "a missing nextCursor is normal completion, not an error");
89
+ assert.equal(page.snapshots.length, 1);
90
+ });
91
+ });
92
+
93
+ test("ActivityHttpSourceClient reports an unreachable health probe without treating it as an empty source", async () => {
94
+ await withServer((_request, response) => {
95
+ response.statusCode = 503;
96
+ response.end("temporarily unavailable");
97
+ }, async (baseUrl) => {
98
+ const client = new ActivityHttpSourceClient({ machineLabel: "fixture-machine", baseUrl });
99
+ assert.deepEqual(await client.verify(), { ok: false, detail: "HTTP 503" });
100
+ await assert.rejects(
101
+ client.fetchSnapshots({ date: "2026-07-22", timezone: "UTC" }),
102
+ /HTTP 503/,
103
+ );
104
+ });
105
+ });
106
+
107
+ test("ActivityHttpSourceClient aborts a stalled request via the default timeout", async () => {
108
+ await withServer((request) => {
109
+ // Never respond: hold the request open so only the client-side timeout can
110
+ // end it. The socket is destroyed when fetch aborts, so teardown proceeds.
111
+ request.resume();
112
+ }, async (baseUrl) => {
113
+ const client = new ActivityHttpSourceClient({ machineLabel: "fixture-machine", baseUrl, timeoutMs: 50 });
114
+ assert.deepEqual((await client.verify()).ok, false);
115
+ await assert.rejects(client.fetchSnapshots({ date: "2026-07-22", timezone: "UTC" }));
116
+ });
117
+ });
118
+
119
+ test("ActivityHttpSourceClient honors a caller-supplied abort signal", async () => {
120
+ await withServer((request) => {
121
+ request.resume();
122
+ }, async (baseUrl) => {
123
+ const client = new ActivityHttpSourceClient({ machineLabel: "fixture-machine", baseUrl });
124
+ await assert.rejects(client.fetchSnapshots({ date: "2026-07-22", timezone: "UTC", signal: AbortSignal.abort() }));
125
+ });
126
+ });
127
+
128
+ test("ActivityHttpSourceClient rejects a non-positive timeout", () => {
129
+ assert.throws(
130
+ () => new ActivityHttpSourceClient({ machineLabel: "fixture-machine", baseUrl: "http://127.0.0.1:4319", timeoutMs: 0 }),
131
+ /timeoutMs must be a positive number/,
132
+ );
133
+ });
134
+
135
+ async function closedLoopbackBaseUrl(): Promise<string> {
136
+ // Reserve an ephemeral loopback port, then close it so the connection is
137
+ // deterministically refused (no reliance on a fixed low port being free).
138
+ const server = createServer();
139
+ const listening = Promise.withResolvers<void>();
140
+ server.listen(0, "127.0.0.1", listening.resolve);
141
+ await listening.promise;
142
+ const address = server.address();
143
+ assert.ok(address && typeof address !== "string");
144
+ const port = address.port;
145
+ const closed = Promise.withResolvers<void>();
146
+ server.close((error) => (error ? closed.reject(error) : closed.resolve()));
147
+ await closed.promise;
148
+ return `http://127.0.0.1:${port}`;
149
+ }
150
+
151
+ test("ActivityHttpSourceClient.verify returns a sanitized detail for network errors", async () => {
152
+ const baseUrl = await closedLoopbackBaseUrl();
153
+ const port = new URL(baseUrl).port;
154
+ const client = new ActivityHttpSourceClient({ machineLabel: "fixture-machine", baseUrl });
155
+ const check = await client.verify();
156
+ assert.equal(check.ok, false);
157
+ assert.ok(check.detail && check.detail.length > 0, "a failure carries some detail");
158
+ assert.ok(!check.detail?.includes("127.0.0.1"), "detail does not echo the host");
159
+ assert.ok(!check.detail?.includes(port), "detail does not echo the port");
160
+ });
161
+
162
+ test("ActivityHttpSourceClient accepts an empty window title and text (untitled/blank window)", async () => {
163
+ await withServer((_request, response) => {
164
+ response.setHeader("content-type", "application/json");
165
+ response.end(
166
+ JSON.stringify({
167
+ snapshots: [
168
+ {
169
+ capturedAtUtc: "2026-07-22T14:00:00.000Z",
170
+ app: "",
171
+ windowTitle: "",
172
+ text: "",
173
+ textSource: "ocr",
174
+ contentHash: "abc123",
175
+ },
176
+ ],
177
+ nextCursor: null,
178
+ }),
179
+ );
180
+ }, async (baseUrl) => {
181
+ const client = new ActivityHttpSourceClient({ machineLabel: "fixture-machine", baseUrl });
182
+ const page = await client.fetchSnapshots({ date: "2026-07-22", timezone: "UTC" });
183
+ // A legitimate no-text/untitled foreground window must parse, not throw —
184
+ // else the runner errors the day and the cursor never advances past it.
185
+ assert.equal(page.snapshots.length, 1);
186
+ assert.equal(page.snapshots[0]?.windowTitle, "");
187
+ assert.equal(page.snapshots[0]?.text, "");
188
+ assert.equal(page.snapshots[0]?.app, "");
189
+ });
190
+ });
191
+
192
+ test("ActivityHttpSourceClient still rejects a missing contentHash identifier", async () => {
193
+ await withServer((_request, response) => {
194
+ response.setHeader("content-type", "application/json");
195
+ response.end(
196
+ JSON.stringify({
197
+ snapshots: [
198
+ { capturedAtUtc: "2026-07-22T14:00:00.000Z", app: "Browser", windowTitle: "T", text: "x", textSource: "ax" },
199
+ ],
200
+ nextCursor: null,
201
+ }),
202
+ );
203
+ }, async (baseUrl) => {
204
+ const client = new ActivityHttpSourceClient({ machineLabel: "fixture-machine", baseUrl });
205
+ // Identity/dedup fields are still required and non-empty.
206
+ await assert.rejects(client.fetchSnapshots({ date: "2026-07-22", timezone: "UTC" }), /contentHash/);
207
+ });
208
+ });
@@ -0,0 +1,159 @@
1
+ import { validateActivityBaseUrl } from "./config.js";
2
+ import { displayErrorDetail } from "../runtime/better-sqlite.js";
3
+ import type { ActivitySnapshot, ActivitySnapshotPage, ActivitySourceCheck, ActivitySourceClient } from "./types.js";
4
+
5
+ /** Abort a stalled capture-daemon request rather than hang the whole sync. */
6
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
7
+
8
+ export interface ActivityHttpSourceClientOptions {
9
+ machineLabel: string;
10
+ baseUrl: string;
11
+ token?: string;
12
+ /** Per-request timeout in ms; defaults to DEFAULT_REQUEST_TIMEOUT_MS. */
13
+ timeoutMs?: number;
14
+ }
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === "object" && value !== null;
18
+ }
19
+
20
+ function stringField(value: Record<string, unknown>, field: string): string {
21
+ const result = value[field];
22
+ if (typeof result !== "string" || result.length === 0) {
23
+ throw new TypeError(`activity source response has no valid ${field}`);
24
+ }
25
+ return result;
26
+ }
27
+
28
+ /**
29
+ * Content fields (app/windowTitle/text) may be legitimately empty: a foreground
30
+ * window can be untitled or have no extractable text. Require the string TYPE
31
+ * (a missing/non-string field still fails loudly) but tolerate "", matching the
32
+ * durable store's contract — else a blank window poisons the day cursor by
33
+ * failing every replay of the same page.
34
+ */
35
+ function contentStringField(value: Record<string, unknown>, field: string): string {
36
+ const result = value[field];
37
+ if (typeof result !== "string") {
38
+ throw new TypeError(`activity source response has invalid ${field}`);
39
+ }
40
+ return result;
41
+ }
42
+
43
+ function optionalStringField(value: Record<string, unknown>, field: string): string | undefined {
44
+ const result = value[field];
45
+ if (result === undefined || result === null) return undefined;
46
+ if (typeof result !== "string") throw new TypeError(`activity source response has invalid ${field}`);
47
+ return result;
48
+ }
49
+
50
+ function snapshotFromWire(value: unknown, machine: string): ActivitySnapshot {
51
+ if (!isRecord(value)) throw new TypeError("activity source returned a non-object snapshot");
52
+ const textSource = stringField(value, "textSource");
53
+ if (textSource !== "ax" && textSource !== "ocr") {
54
+ throw new TypeError("activity source response has invalid textSource");
55
+ }
56
+ const browserUrl = optionalStringField(value, "browserUrl");
57
+ const simhash = optionalStringField(value, "simhash");
58
+ return {
59
+ machine,
60
+ capturedAtUtc: stringField(value, "capturedAtUtc"),
61
+ app: contentStringField(value, "app"),
62
+ windowTitle: contentStringField(value, "windowTitle"),
63
+ ...(browserUrl === undefined ? {} : { browserUrl }),
64
+ text: contentStringField(value, "text"),
65
+ textSource,
66
+ contentHash: stringField(value, "contentHash"),
67
+ ...(simhash === undefined ? {} : { simhash }),
68
+ };
69
+ }
70
+
71
+ function responsePage(value: unknown, machine: string): ActivitySnapshotPage {
72
+ if (!isRecord(value) || !Array.isArray(value.snapshots)) {
73
+ throw new TypeError("activity source response has no snapshots array");
74
+ }
75
+ // A missing/undefined nextCursor (field omitted, not JSON null) is normal
76
+ // end-of-pagination — only a present non-string value is invalid.
77
+ const nextCursor = value.nextCursor === undefined ? null : value.nextCursor;
78
+ if (nextCursor !== null && typeof nextCursor !== "string") {
79
+ throw new TypeError("activity source response has invalid nextCursor");
80
+ }
81
+ return {
82
+ snapshots: value.snapshots.map((snapshot) => snapshotFromWire(snapshot, machine)),
83
+ nextCursor,
84
+ };
85
+ }
86
+
87
+ function requestUrl(baseUrl: string, path: string, query: Record<string, string | undefined>): string {
88
+ const url = new URL(path, baseUrl);
89
+ const parts = Object.entries(query)
90
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
91
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
92
+ url.search = parts.join("&");
93
+ return url.toString();
94
+ }
95
+
96
+ export class ActivityHttpSourceClient implements ActivitySourceClient {
97
+ readonly machineLabel: string;
98
+ private readonly baseUrl: string;
99
+ private readonly token: string | undefined;
100
+ private readonly timeoutMs: number;
101
+
102
+ constructor(options: ActivityHttpSourceClientOptions) {
103
+ if (options.machineLabel.trim().length === 0) throw new RangeError("activity source machine label must not be empty");
104
+ if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
105
+ throw new RangeError("activity source timeoutMs must be a positive number");
106
+ }
107
+ this.machineLabel = options.machineLabel;
108
+ this.baseUrl = validateActivityBaseUrl(options.baseUrl).toString();
109
+ this.token = options.token;
110
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
111
+ }
112
+
113
+ private async request(url: string, signal?: AbortSignal): Promise<Response> {
114
+ // Bound every request so a stalled daemon aborts instead of hanging the
115
+ // sync forever; honor the caller's signal too when one is supplied.
116
+ const timeout = AbortSignal.timeout(this.timeoutMs);
117
+ const composed = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
118
+ const response = await fetch(url, {
119
+ headers: this.token === undefined ? undefined : { authorization: `Bearer ${this.token}` },
120
+ signal: composed,
121
+ });
122
+ if (!response.ok) throw new Error(`activity source HTTP ${response.status}`);
123
+ return response;
124
+ }
125
+
126
+ async verify(signal?: AbortSignal): Promise<ActivitySourceCheck> {
127
+ try {
128
+ await this.request(requestUrl(this.baseUrl, "/v1/health", {}), signal);
129
+ return { ok: true };
130
+ } catch (error: unknown) {
131
+ // The controlled `HTTP <status>` message is safe to surface verbatim;
132
+ // anything else (network/runtime fetch errors that can embed hostnames or
133
+ // absolute paths) is reduced to a sanitized name+code via
134
+ // displayErrorDetail, matching the wearables operator-facing path.
135
+ const detail =
136
+ error instanceof Error && error.message.startsWith("activity source HTTP ")
137
+ ? error.message.slice("activity source ".length)
138
+ : displayErrorDetail(error) || "request failed";
139
+ return { ok: false, detail };
140
+ }
141
+ }
142
+
143
+ async fetchSnapshots(opts: {
144
+ date: string;
145
+ timezone: string;
146
+ cursor?: string | null;
147
+ signal?: AbortSignal;
148
+ }): Promise<ActivitySnapshotPage> {
149
+ const response = await this.request(
150
+ requestUrl(this.baseUrl, "/v1/snapshots", {
151
+ date: opts.date,
152
+ timezone: opts.timezone,
153
+ cursor: opts.cursor ?? undefined,
154
+ }),
155
+ opts.signal,
156
+ );
157
+ return responsePage(await response.json(), this.machineLabel);
158
+ }
159
+ }
@@ -0,0 +1,134 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import type { ActivitySyncRunSummary } from "./runner.js";
5
+ import type { ActivitySyncSchedulerOptions } from "./scheduler.js";
6
+ import {
7
+ ActivitySyncRegistrar,
8
+ type ActivitySyncRegistrarDeps,
9
+ type ActivitySyncSchedulerLike,
10
+ } from "./sync-registration.js";
11
+
12
+ class FakeScheduler implements ActivitySyncSchedulerLike {
13
+ starts = 0;
14
+ stops = 0;
15
+ readonly options: ActivitySyncSchedulerOptions;
16
+ constructor(options: ActivitySyncSchedulerOptions) {
17
+ this.options = options;
18
+ }
19
+ start(): unknown {
20
+ this.starts += 1;
21
+ return { registered: true, intervalMs: this.options.intervalMs ?? 0 };
22
+ }
23
+ async stop(): Promise<void> {
24
+ this.stops += 1;
25
+ }
26
+ }
27
+
28
+ function makeDeps(overrides: Partial<ActivitySyncRegistrarDeps> = {}): {
29
+ deps: ActivitySyncRegistrarDeps;
30
+ created: FakeScheduler[];
31
+ state: { retries: number };
32
+ } {
33
+ const created: FakeScheduler[] = [];
34
+ const state = { retries: 0 };
35
+ const deps: ActivitySyncRegistrarDeps = {
36
+ config: { enabled: true, timezone: "UTC", syncDays: 1, autoSyncIntervalMinutes: 15, sources: [] },
37
+ memoryDir: "/tmp/remnic-activity-registrar",
38
+ qmdCollection: "openclaw-engram",
39
+ secureStoreEnabled: false,
40
+ getQmd: () => ({ update: async () => undefined }),
41
+ requestReindexRetry: () => {
42
+ state.retries += 1;
43
+ },
44
+ createScheduler: (options) => {
45
+ const s = new FakeScheduler(options);
46
+ created.push(s);
47
+ return s;
48
+ },
49
+ ...overrides,
50
+ };
51
+ return { deps, created, state };
52
+ }
53
+
54
+ test("register arms a scheduler on a live signal", async () => {
55
+ const { deps, created } = makeDeps();
56
+ const registrar = new ActivitySyncRegistrar(deps);
57
+ await registrar.register(new AbortController().signal);
58
+ assert.equal(created.length, 1, "exactly one scheduler is constructed");
59
+ assert.equal(created[0]?.starts, 1, "the scheduler is started");
60
+ assert.ok(registrar.armed, "the registrar reports armed");
61
+ });
62
+
63
+ test("register on an aborted signal never arms and stops any prior scheduler", async () => {
64
+ const { deps, created } = makeDeps();
65
+ const registrar = new ActivitySyncRegistrar(deps);
66
+ await registrar.register(new AbortController().signal);
67
+ const prior = created[0];
68
+ const aborted = AbortSignal.abort();
69
+ await registrar.register(aborted);
70
+ assert.equal(created.length, 1, "no replacement scheduler is constructed while aborted");
71
+ assert.equal(prior?.stops, 1, "the prior scheduler is stopped before bailing");
72
+ assert.equal(registrar.armed, false, "the registrar is disarmed after an aborted re-init");
73
+ });
74
+
75
+ test("register re-arms by stopping the prior scheduler first", async () => {
76
+ const { deps, created } = makeDeps();
77
+ const registrar = new ActivitySyncRegistrar(deps);
78
+ await registrar.register(new AbortController().signal);
79
+ await registrar.register(new AbortController().signal);
80
+ assert.equal(created.length, 2, "a fresh scheduler is armed on re-register");
81
+ assert.equal(created[0]?.stops, 1, "the prior scheduler is drained");
82
+ assert.equal(created[1]?.starts, 1, "the replacement scheduler is started");
83
+ assert.ok(registrar.armed);
84
+ });
85
+
86
+ test("register refuses to arm under a secure store", async () => {
87
+ const { deps, created } = makeDeps({ secureStoreEnabled: true });
88
+ const registrar = new ActivitySyncRegistrar(deps);
89
+ await registrar.register(new AbortController().signal);
90
+ assert.equal(created.length, 0, "no scheduler is armed when at-rest encryption is on");
91
+ assert.equal(registrar.armed, false);
92
+ });
93
+
94
+ test("dispose latches teardown and drains the scheduler", async () => {
95
+ const { deps, created } = makeDeps();
96
+ const registrar = new ActivitySyncRegistrar(deps);
97
+ await registrar.register(new AbortController().signal);
98
+ await registrar.dispose();
99
+ assert.equal(created[0]?.stops, 1, "dispose drains the armed scheduler");
100
+ assert.equal(registrar.armed, false, "the registrar is disarmed after dispose");
101
+ });
102
+
103
+ test("a reindex failure queues a retry, but never after teardown", async () => {
104
+ const box = makeDeps();
105
+ const registrar = new ActivitySyncRegistrar(box.deps);
106
+ await registrar.register(new AbortController().signal);
107
+ const onRun = box.created[0]?.options.onRun as (s: ActivitySyncRunSummary) => void;
108
+ const failed = { machine: "m", ran: true, fetched: 0, inserted: 0, duplicates: 0, reindexErrorCount: 2 } as unknown as ActivitySyncRunSummary;
109
+ onRun(failed);
110
+ assert.equal(box.state.retries, 1, "a failed reindex queues one maintenance retry");
111
+ await registrar.dispose();
112
+ onRun(failed);
113
+ assert.equal(box.state.retries, 1, "a draining tick's onRun does not re-arm maintenance after teardown");
114
+ });
115
+
116
+ test("abort landing during construction stops the freshly-started scheduler", async () => {
117
+ const controller = new AbortController();
118
+ const created: FakeScheduler[] = [];
119
+ const { deps } = makeDeps({
120
+ createScheduler: (options) => {
121
+ // Simulate the abort arriving in the window between the guard and start().
122
+ controller.abort();
123
+ const s = new FakeScheduler(options);
124
+ created.push(s);
125
+ return s;
126
+ },
127
+ });
128
+ const registrar = new ActivitySyncRegistrar(deps);
129
+ await registrar.register(controller.signal);
130
+ assert.equal(created.length, 1, "the scheduler is constructed before the abort is observed");
131
+ assert.equal(created[0]?.starts, 1, "it is started");
132
+ assert.equal(created[0]?.stops, 1, "then immediately stopped once the abort is seen");
133
+ assert.equal(registrar.armed, false, "the registrar does not retain an aborted scheduler");
134
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Activity (screen-capture) sync lifecycle owner (issue #1900).
3
+ *
4
+ * The MaintenanceScheduler used to inline the arm / re-arm / teardown wiring
5
+ * for the periodic activity sync, which made the lifecycle invariants easy to
6
+ * break one edit at a time. This module centralizes them in one testable place:
7
+ *
8
+ * - Re-arm and teardown BOTH stop any prior scheduler first, so a repeated
9
+ * registration never orphans a running interval.
10
+ * - An aborted deferred-init signal means teardown began. `destroy()` aborts
11
+ * the init signal *before* it disposes, so an aborted signal here also
12
+ * implies `dispose()` may already have latched teardown. `register()` never
13
+ * arms nor clears the latch while aborted — even when the abort lands
14
+ * mid-drain — so a late re-init cannot resurrect a torn-down instance.
15
+ * - The teardown latch gates the reindex-failure retry, so a draining tick's
16
+ * `onRun` cannot queue QMD maintenance after teardown.
17
+ * - Activity digests + SQLite are written in the clear, so the registrar
18
+ * refuses to arm under a secure store rather than silently bypass opted-in
19
+ * at-rest encryption.
20
+ *
21
+ * Host-agnostic: the scheduler factory is injectable, so tests drive the whole
22
+ * lifecycle against a fake scheduler without real timers.
23
+ */
24
+
25
+ import { refreshActivityIndex, type ActivityIndexRefresher } from "./reindex.js";
26
+ import type { ActivitySyncRunSummary } from "./runner.js";
27
+ import { ActivitySyncScheduler, type ActivitySyncSchedulerOptions } from "./scheduler.js";
28
+ import type { ActivityConfig } from "./types.js";
29
+ import { log } from "../logger.js";
30
+
31
+ /** The scheduler surface the registrar drives (injectable for tests). */
32
+ export interface ActivitySyncSchedulerLike {
33
+ start(): unknown;
34
+ stop(): Promise<void>;
35
+ }
36
+
37
+ export interface ActivitySyncRegistrarDeps {
38
+ /** Activity sync settings; the master default-off flag lives here. */
39
+ readonly config: ActivityConfig;
40
+ /** Memory root the digests + SQLite store live under. */
41
+ readonly memoryDir: string;
42
+ /** QMD collection the day digest is (strictly) reindexed into. */
43
+ readonly qmdCollection: string;
44
+ /**
45
+ * True when at-rest encryption is configured. Activity writes are not yet
46
+ * encrypted, so the registrar refuses to arm rather than leak plaintext.
47
+ */
48
+ readonly secureStoreEnabled: boolean;
49
+ /** Live QMD accessor — the host may swap backends after construction. */
50
+ readonly getQmd: () => ActivityIndexRefresher;
51
+ /** Debounced/singleflighted QMD retry used when a tick's reindex fails. */
52
+ readonly requestReindexRetry: () => void;
53
+ /** Scheduler factory (injectable for tests); defaults to the real scheduler. */
54
+ readonly createScheduler?: (options: ActivitySyncSchedulerOptions) => ActivitySyncSchedulerLike;
55
+ }
56
+
57
+ export class ActivitySyncRegistrar {
58
+ private scheduler: ActivitySyncSchedulerLike | null = null;
59
+ /** Latched by dispose() so a draining tick's onRun cannot re-arm maintenance. */
60
+ private disposed = false;
61
+ private readonly createScheduler: (options: ActivitySyncSchedulerOptions) => ActivitySyncSchedulerLike;
62
+
63
+ constructor(private readonly deps: ActivitySyncRegistrarDeps) {
64
+ this.createScheduler = deps.createScheduler ?? ((options) => new ActivitySyncScheduler(options));
65
+ }
66
+
67
+ /** True while a scheduler is armed (test/telemetry observability). */
68
+ get armed(): boolean {
69
+ return this.scheduler !== null;
70
+ }
71
+
72
+ /**
73
+ * Arm (or re-arm) the periodic sync for the current lifecycle. Idempotent and
74
+ * safe to call repeatedly; always stops a prior scheduler first.
75
+ */
76
+ async register(signal: AbortSignal): Promise<void> {
77
+ // Re-arm or teardown: stop any prior scheduler before anything else, so a
78
+ // repeated registration never orphans a running interval.
79
+ const prior = this.scheduler;
80
+ this.scheduler = null;
81
+ await prior?.stop();
82
+ // Teardown began (possibly during the drain above). Never arm nor clear the
83
+ // latch: dispose() may already have set it, and it must stay set.
84
+ if (signal.aborted) return;
85
+ // Opted-in at-rest encryption must not be silently bypassed: activity
86
+ // digests + SQLite are written in the clear, so refuse to run under it.
87
+ if (this.deps.secureStoreEnabled && this.deps.config.enabled) {
88
+ log.warn(
89
+ "activity sync disabled: secure store is enabled but activity digests/SQLite are not encrypted at rest",
90
+ );
91
+ return;
92
+ }
93
+ // Committed to a fresh lifecycle: clear the latch so this scheduler's
94
+ // reindex retries fire again.
95
+ this.disposed = false;
96
+ try {
97
+ const scheduler = this.createScheduler({
98
+ config: this.deps.config,
99
+ memoryDir: this.deps.memoryDir,
100
+ intervalMs: this.deps.config.autoSyncIntervalMinutes * 60_000,
101
+ // Strict refresh after each digest write (rule 31): bypasses the
102
+ // min-interval gate and throws on a real failure.
103
+ reindexSearch: (sig) => refreshActivityIndex(this.deps.getQmd(), this.deps.qmdCollection, sig),
104
+ // A failed reindex (QMD down) leaves the next unchanged-digest tick
105
+ // skipping afterWrites, so route the retry through the durable,
106
+ // throttled QMD path — but never after teardown.
107
+ onRun: (summary: ActivitySyncRunSummary) => {
108
+ if (summary.reindexErrorCount > 0 && !this.disposed) {
109
+ log.warn(
110
+ `activity sync: ${summary.reindexErrorCount} source(s) had a failed reindex; queuing a maintenance retry`,
111
+ );
112
+ this.deps.requestReindexRetry();
113
+ }
114
+ },
115
+ });
116
+ this.scheduler = scheduler;
117
+ scheduler.start();
118
+ // Close the race where abort fires between the guard above and start().
119
+ if (signal.aborted) {
120
+ await scheduler.stop();
121
+ this.scheduler = null;
122
+ }
123
+ } catch (err) {
124
+ log.debug(`activity sync scheduler start failed (non-fatal): ${err}`);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Latch teardown and drain the scheduler. Latching first ensures a draining
130
+ * tick's onRun cannot queue a retry; the drain is awaited so no in-flight
131
+ * tick keeps writing after teardown.
132
+ */
133
+ async dispose(): Promise<void> {
134
+ this.disposed = true;
135
+ const drain = this.scheduler?.stop();
136
+ this.scheduler = null;
137
+ await drain;
138
+ }
139
+ }
@@ -84,3 +84,20 @@ export interface ActivitySourceClient {
84
84
  signal?: AbortSignal;
85
85
  }): Promise<ActivitySnapshotPage>;
86
86
  }
87
+
88
+ /** One trusted capture daemon that feeds this memory directory. */
89
+ export interface ActivitySourceConfig {
90
+ machineLabel: string;
91
+ baseUrl: string;
92
+ token?: string;
93
+ }
94
+
95
+ /** Opt-in activity synchronization settings. */
96
+ export interface ActivityConfig {
97
+ enabled: boolean;
98
+ timezone: string;
99
+ syncDays: number;
100
+ /** Periodic auto-sync cadence in minutes (default 15). */
101
+ autoSyncIntervalMinutes: number;
102
+ sources: ActivitySourceConfig[];
103
+ }
@@ -2348,3 +2348,22 @@ test("parseConfig bounded-state knobs coerce valid strings, preserve 0, and reje
2348
2348
  assert.throws(() => parseConfig({ recallImpressionsRotateKeep: 1001 }), /between 1 and 1000/);
2349
2349
  assert.throws(() => parseConfig({ recallImpressionsRotateKeep: "1000000" }), /between 1 and 1000/);
2350
2350
  }));
2351
+
2352
+ test("parseConfig forwards activity source settings", () => {
2353
+ const config = parseConfig({
2354
+ activity: {
2355
+ enabled: "true",
2356
+ timezone: "America/Chicago",
2357
+ syncDays: "2",
2358
+ sources: [{ machineLabel: "fixture-machine", baseUrl: "http://127.0.0.1:4319" }],
2359
+ },
2360
+ });
2361
+
2362
+ assert.deepEqual(config.activity, {
2363
+ enabled: true,
2364
+ timezone: "America/Chicago",
2365
+ syncDays: 2,
2366
+ autoSyncIntervalMinutes: 15,
2367
+ sources: [{ machineLabel: "fixture-machine", baseUrl: "http://127.0.0.1:4319" }],
2368
+ });
2369
+ });