@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.
- package/dist/access-admin-ops-surface.d.ts +8 -8
- package/dist/access-admin-ops-surface.js +7 -7
- package/dist/access-boundary.d.ts +8 -8
- package/dist/access-boundary.js +7 -7
- package/dist/access-cli.js +31 -30
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +8 -8
- package/dist/access-http.js +11 -11
- package/dist/access-identity-continuity-surface.d.ts +6 -6
- package/dist/access-identity-continuity-surface.js +7 -7
- package/dist/access-lcm-surface.d.ts +8 -8
- package/dist/access-lcm-surface.js +7 -7
- package/dist/access-mcp.d.ts +8 -8
- package/dist/access-mcp.js +10 -10
- package/dist/access-memory-search-fanout.d.ts +2 -2
- package/dist/{access-namespace-preflight-B9e5GnyZ.d.ts → access-namespace-preflight-DwhEFYy3.d.ts} +1 -1
- package/dist/access-namespace-preflight.d.ts +3 -3
- package/dist/access-namespace-preflight.js +7 -7
- package/dist/access-observe-write-surface.d.ts +8 -8
- package/dist/access-observe-write-surface.js +7 -7
- package/dist/access-operations-batch.js +8 -8
- package/dist/access-operations.d.ts +8 -8
- package/dist/access-operations.js +9 -9
- package/dist/access-recall-concurrency.d.ts +8 -8
- package/dist/access-recall-concurrency.js +7 -7
- package/dist/access-recall-response.d.ts +8 -8
- package/dist/access-recall-response.js +7 -7
- package/dist/access-recall-surface.d.ts +8 -8
- package/dist/access-recall-surface.js +7 -7
- package/dist/{access-service-DgmJd_A-.d.ts → access-service-CSq1Wp2Z.d.ts} +6 -6
- package/dist/access-service.d.ts +8 -8
- package/dist/access-service.js +7 -7
- package/dist/access-surface-catalog.d.ts +8 -8
- package/dist/action-confidence.d.ts +1 -1
- package/dist/active-memory-bridge.d.ts +1 -1
- package/dist/active-recall.d.ts +1 -1
- package/dist/active-recall.js +2 -1
- package/dist/active-recall.js.map +1 -1
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +6 -6
- package/dist/briefing.d.ts +2 -2
- package/dist/briefing.js +3 -3
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +2 -2
- package/dist/bulk-import/index.d.ts +3 -3
- package/dist/calibration.d.ts +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/{catalog-g_dfT6oL.d.ts → catalog-B9uga4sG.d.ts} +1 -1
- package/dist/causal-behavior.d.ts +1 -1
- package/dist/causal-consolidation.d.ts +1 -1
- package/dist/causal-consolidation.js +4 -4
- package/dist/causal-trajectory-graph.d.ts +1 -1
- package/dist/{chunk-IRX5UCGN.js → chunk-2OH3ZRYH.js} +3 -3
- package/dist/{chunk-YXTSUIXC.js → chunk-5EKOBRCA.js} +6 -1
- package/dist/chunk-5EKOBRCA.js.map +1 -0
- package/dist/{chunk-42DJZZUB.js → chunk-7LXSPIVG.js} +2 -2
- package/dist/{chunk-47WFEKT7.js → chunk-7PK4W4RN.js} +2 -2
- package/dist/{chunk-O7ACJAHR.js → chunk-7YJLCNPF.js} +2 -2
- package/dist/{chunk-6EJL2O3G.js → chunk-AFH7PIFU.js} +2 -2
- package/dist/{chunk-JN4N2TMQ.js → chunk-ANGUOE2N.js} +7 -7
- package/dist/{chunk-GQOKO77V.js → chunk-AWT7SCWQ.js} +2 -2
- package/dist/{chunk-PXJEL55X.js → chunk-BIRPR5N7.js} +2 -2
- package/dist/{chunk-NAY25BGS.js → chunk-BTK2PIFL.js} +6 -6
- package/dist/{chunk-SYCVU556.js → chunk-C2RMQRPN.js} +3 -3
- package/dist/{chunk-T4EAQPQQ.js → chunk-G2WCVFDC.js} +2 -2
- package/dist/{chunk-UJIBXOJA.js → chunk-GEDBTSYU.js} +5 -5
- package/dist/{chunk-CXFVZ3PK.js → chunk-GRJ5UOFO.js} +84 -658
- package/dist/chunk-GRJ5UOFO.js.map +1 -0
- package/dist/chunk-GSQASASV.js +429 -0
- package/dist/chunk-GSQASASV.js.map +1 -0
- package/dist/{chunk-WIKOHCQZ.js → chunk-I6ODVWQB.js} +2 -2
- package/dist/{chunk-H4MDUPER.js → chunk-IZHXSLO7.js} +2 -2
- package/dist/{chunk-RRS5CDB6.js → chunk-JOCXI2O5.js} +2 -2
- package/dist/{chunk-EEUZQQRJ.js → chunk-KGKYTCDO.js} +2 -2
- package/dist/{chunk-JTW4TGPV.js → chunk-L3HAUO7K.js} +2 -2
- package/dist/{chunk-P53HGVJS.js → chunk-LMVAIZR5.js} +2 -2
- package/dist/{chunk-KLXG4BBO.js → chunk-M6TZQDTX.js} +2 -2
- package/dist/{chunk-FJ5IQ3SI.js → chunk-N7DK67GZ.js} +1 -1
- package/dist/chunk-N7DK67GZ.js.map +1 -0
- package/dist/{chunk-U6QWUFSM.js → chunk-OXT5TQDS.js} +2 -2
- package/dist/{chunk-T4OTDMX4.js → chunk-QDTEH6BS.js} +2 -2
- package/dist/{chunk-5MDE5MIC.js → chunk-SESF64QR.js} +2 -2
- package/dist/{chunk-4E5H4FPC.js → chunk-TUNI4GB3.js} +3 -3
- package/dist/{chunk-XJT6LNLY.js → chunk-V6LYAXGW.js} +834 -25
- package/dist/chunk-V6LYAXGW.js.map +1 -0
- package/dist/{chunk-5HFIHWOF.js → chunk-VLIY55Q5.js} +17 -17
- package/dist/{chunk-BNH2VVWP.js → chunk-WIS5JTJR.js} +2 -2
- package/dist/{chunk-OOGGUXAG.js → chunk-WQ2I6ODB.js} +2 -2
- package/dist/{cli-BXCd2cgK.d.ts → cli-DYLGXxuu.d.ts} +5 -5
- package/dist/cli.d.ts +10 -10
- package/dist/cli.js +25 -24
- package/dist/compounding/engine.d.ts +2 -2
- package/dist/compounding/engine.js +3 -3
- package/dist/compounding/preference-consolidator.d.ts +1 -1
- package/dist/compression-optimizer.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +2 -1
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +3 -3
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +3 -3
- package/dist/consolidation-provenance-check.d.ts +2 -2
- package/dist/consolidation-undo.d.ts +2 -2
- package/dist/contradiction/index.d.ts +2 -2
- package/dist/conversation-index/backend.d.ts +1 -1
- package/dist/conversation-index/chunker.d.ts +1 -1
- package/dist/conversation-index/faiss-adapter.d.ts +1 -1
- package/dist/conversation-index/indexer.d.ts +1 -1
- package/dist/conversation-index/search.d.ts +1 -1
- package/dist/day-summary.d.ts +1 -1
- package/dist/delinearize.d.ts +1 -1
- package/dist/direct-answer-wiring.d.ts +1 -1
- package/dist/direct-answer.d.ts +1 -1
- package/dist/embedding-fallback.d.ts +1 -1
- package/dist/enrichment/index.d.ts +1 -1
- package/dist/entity-retrieval.d.ts +2 -2
- package/dist/entity-retrieval.js +3 -3
- package/dist/entity-schema.d.ts +1 -1
- package/dist/explicit-capture.d.ts +6 -6
- package/dist/extraction-faithfulness.d.ts +1 -1
- package/dist/extraction-judge-telemetry.d.ts +1 -1
- package/dist/extraction-judge-training.d.ts +1 -1
- package/dist/extraction-judge.d.ts +1 -1
- package/dist/extraction.d.ts +1 -1
- package/dist/fallback-llm.d.ts +1 -1
- package/dist/graph-dashboard-diff.d.ts +1 -1
- package/dist/graph-dashboard-key.d.ts +1 -1
- package/dist/graph-dashboard-parser.d.ts +1 -1
- package/dist/graph-edge-reinforcement.d.ts +1 -1
- package/dist/graph-snapshot.d.ts +1 -1
- package/dist/graph.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/importers/index.d.ts +1 -1
- package/dist/in-flight-reads.d.ts +1 -1
- package/dist/index.d.ts +289 -99
- package/dist/index.js +70 -45
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/tools.d.ts +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/live-connectors-runner.d.ts +1 -1
- package/dist/local-llm.d.ts +1 -1
- package/dist/local-model-endpoint.d.ts +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +3 -3
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +2 -2
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
- package/dist/maintenance/rebuild-memory-projection.js +4 -4
- package/dist/mcp-memory-inspector-app.d.ts +8 -8
- package/dist/memory-action-policy.d.ts +1 -1
- package/dist/memory-cache.d.ts +1 -1
- package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
- package/dist/memory-projection-store.d.ts +1 -1
- package/dist/memory-provenance.d.ts +1 -1
- package/dist/memory-worth-outcomes.d.ts +2 -2
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +3 -3
- package/dist/namespaces/migrate.js +4 -4
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/storage.d.ts +3 -3
- package/dist/namespaces/storage.js +3 -3
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/offline-sync-impression-drain.d.ts +1 -1
- package/dist/operator-toolkit.d.ts +2 -2
- package/dist/operator-toolkit.js +9 -8
- package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
- package/dist/orchestration/maintenance.d.ts +10 -7
- package/dist/orchestration/maintenance.js +7 -6
- package/dist/{orchestrator-BHfWtvEJ.d.ts → orchestrator-BHx-DWCa.d.ts} +7 -5
- package/dist/orchestrator.d.ts +6 -6
- package/dist/orchestrator.js +31 -30
- package/dist/patterns-cli.d.ts +1 -1
- package/dist/{pipeline-CBYcnmDC.d.ts → pipeline-DDHSE876.d.ts} +1 -1
- package/dist/policy-runtime.d.ts +1 -1
- package/dist/proactive-contention.d.ts +1 -1
- package/dist/provenance.d.ts +1 -1
- package/dist/{qmd-BIq-jrV4.d.ts → qmd-C4WVh6BM.d.ts} +1 -1
- package/dist/qmd-preflight.d.ts +2 -2
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +2 -2
- package/dist/recall-concurrency-config.d.ts +1 -1
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- package/dist/recall-explain-renderer.js +3 -3
- package/dist/recall-memory-map.d.ts +1 -1
- package/dist/recall-planner-llm.d.ts +1 -1
- package/dist/recall-state.d.ts +1 -1
- package/dist/recall-tag-filter.d.ts +1 -1
- package/dist/recall-timings.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-cli.js +4 -4
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray-renderer.js +3 -3
- package/dist/recall-xray.d.ts +1 -1
- package/dist/recall-xray.js +2 -2
- package/dist/replay/normalizers/chatgpt.d.ts +1 -1
- package/dist/replay/normalizers/claude.d.ts +1 -1
- package/dist/replay/normalizers/openclaw.d.ts +1 -1
- package/dist/replay/normalizers/shared.d.ts +1 -1
- package/dist/replay/runner.d.ts +1 -1
- package/dist/replay/types.d.ts +1 -1
- package/dist/resolve-auth-token.d.ts +1 -1
- package/dist/resume-bundles.js +3 -2
- package/dist/retrieval-agents.d.ts +2 -2
- package/dist/retrieval-tiers.d.ts +1 -1
- package/dist/routing/engine.d.ts +1 -1
- package/dist/routing/store.d.ts +1 -1
- package/dist/salvage-envelope.d.ts +1 -1
- package/dist/{scope-profiles-DDno-uvy.d.ts → scope-profiles-EqMXwzCz.d.ts} +1 -1
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/index.d.ts +1 -1
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/port.d.ts +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/{semantic-consolidation-7RIJFDwY.d.ts → semantic-consolidation-CYpvwmQc.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +4 -4
- package/dist/semantic-rule-promotion.js +3 -3
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +3 -3
- package/dist/session-observer-bands.d.ts +1 -1
- package/dist/session-observer-state.d.ts +1 -1
- package/dist/shared-context/manager.d.ts +1 -1
- package/dist/signal.d.ts +1 -1
- package/dist/{storage-DkphZiOJ.d.ts → storage-Dxd7zVZH.d.ts} +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.js +2 -2
- package/dist/summarizer.d.ts +1 -1
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/temporal-supersession.d.ts +2 -2
- package/dist/temporal-timeline-recall.d.ts +1 -1
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +2 -2
- package/dist/tier-routing.d.ts +1 -1
- package/dist/topics.d.ts +1 -1
- package/dist/transcript.d.ts +1 -1
- package/dist/trust-score-stage.d.ts +1 -1
- package/dist/trust-score.d.ts +1 -1
- package/dist/{types-2q_MRFcM.d.ts → types-OUCJALBK.d.ts} +98 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/verified-recall.js +3 -3
- package/dist/write-envelope.d.ts +1 -1
- package/package.json +2 -2
- package/src/activity/config.test.ts +113 -0
- package/src/activity/config.ts +111 -0
- package/src/activity/digest.ts +2 -2
- package/src/activity/index.ts +6 -0
- package/src/activity/pipeline.test.ts +379 -0
- package/src/activity/pipeline.ts +223 -0
- package/src/activity/reindex.test.ts +97 -0
- package/src/activity/reindex.ts +40 -0
- package/src/activity/runner.test.ts +452 -0
- package/src/activity/runner.ts +237 -0
- package/src/activity/scheduler.test.ts +303 -0
- package/src/activity/scheduler.ts +147 -0
- package/src/activity/source-client.test.ts +208 -0
- package/src/activity/source-client.ts +159 -0
- package/src/activity/sync-registration.test.ts +134 -0
- package/src/activity/sync-registration.ts +139 -0
- package/src/activity/types.ts +17 -0
- package/src/config.test.ts +19 -0
- package/src/config.ts +3 -0
- package/src/orchestration/maintenance.test.ts +42 -42
- package/src/orchestration/maintenance.ts +37 -5
- package/src/orchestration/orchestrator-helpers.ts +36 -1
- package/src/orchestration/recall-internal.ts +6 -6
- package/src/orchestration/recall-search-pipeline.ts +15 -7
- package/src/orchestrator-background-writes.test.ts +2 -2
- package/src/orchestrator.ts +3 -3
- package/src/types.ts +4 -1
- package/dist/chunk-CXFVZ3PK.js.map +0 -1
- package/dist/chunk-FJ5IQ3SI.js.map +0 -1
- package/dist/chunk-XJT6LNLY.js.map +0 -1
- package/dist/chunk-YXTSUIXC.js.map +0 -1
- /package/dist/{chunk-IRX5UCGN.js.map → chunk-2OH3ZRYH.js.map} +0 -0
- /package/dist/{chunk-42DJZZUB.js.map → chunk-7LXSPIVG.js.map} +0 -0
- /package/dist/{chunk-47WFEKT7.js.map → chunk-7PK4W4RN.js.map} +0 -0
- /package/dist/{chunk-O7ACJAHR.js.map → chunk-7YJLCNPF.js.map} +0 -0
- /package/dist/{chunk-6EJL2O3G.js.map → chunk-AFH7PIFU.js.map} +0 -0
- /package/dist/{chunk-JN4N2TMQ.js.map → chunk-ANGUOE2N.js.map} +0 -0
- /package/dist/{chunk-GQOKO77V.js.map → chunk-AWT7SCWQ.js.map} +0 -0
- /package/dist/{chunk-PXJEL55X.js.map → chunk-BIRPR5N7.js.map} +0 -0
- /package/dist/{chunk-NAY25BGS.js.map → chunk-BTK2PIFL.js.map} +0 -0
- /package/dist/{chunk-SYCVU556.js.map → chunk-C2RMQRPN.js.map} +0 -0
- /package/dist/{chunk-T4EAQPQQ.js.map → chunk-G2WCVFDC.js.map} +0 -0
- /package/dist/{chunk-UJIBXOJA.js.map → chunk-GEDBTSYU.js.map} +0 -0
- /package/dist/{chunk-WIKOHCQZ.js.map → chunk-I6ODVWQB.js.map} +0 -0
- /package/dist/{chunk-H4MDUPER.js.map → chunk-IZHXSLO7.js.map} +0 -0
- /package/dist/{chunk-RRS5CDB6.js.map → chunk-JOCXI2O5.js.map} +0 -0
- /package/dist/{chunk-EEUZQQRJ.js.map → chunk-KGKYTCDO.js.map} +0 -0
- /package/dist/{chunk-JTW4TGPV.js.map → chunk-L3HAUO7K.js.map} +0 -0
- /package/dist/{chunk-P53HGVJS.js.map → chunk-LMVAIZR5.js.map} +0 -0
- /package/dist/{chunk-KLXG4BBO.js.map → chunk-M6TZQDTX.js.map} +0 -0
- /package/dist/{chunk-U6QWUFSM.js.map → chunk-OXT5TQDS.js.map} +0 -0
- /package/dist/{chunk-T4OTDMX4.js.map → chunk-QDTEH6BS.js.map} +0 -0
- /package/dist/{chunk-5MDE5MIC.js.map → chunk-SESF64QR.js.map} +0 -0
- /package/dist/{chunk-4E5H4FPC.js.map → chunk-TUNI4GB3.js.map} +0 -0
- /package/dist/{chunk-5HFIHWOF.js.map → chunk-VLIY55Q5.js.map} +0 -0
- /package/dist/{chunk-BNH2VVWP.js.map → chunk-WIS5JTJR.js.map} +0 -0
- /package/dist/{chunk-OOGGUXAG.js.map → chunk-WQ2I6ODB.js.map} +0 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import { activityDigestPath } from "./digest.js";
|
|
8
|
+
import { activityCursorKey, syncActivitySource } from "./pipeline.js";
|
|
9
|
+
import { ActivityStore } from "./store.js";
|
|
10
|
+
import type { ActivitySnapshot, ActivitySourceClient } from "./types.js";
|
|
11
|
+
|
|
12
|
+
function snapshot(overrides: Partial<ActivitySnapshot> = {}): ActivitySnapshot {
|
|
13
|
+
return {
|
|
14
|
+
machine: "untrusted-source-value",
|
|
15
|
+
capturedAtUtc: "2026-07-22T14:00:00.000Z",
|
|
16
|
+
app: "Browser",
|
|
17
|
+
windowTitle: "Example",
|
|
18
|
+
text: "A synthetic activity snapshot",
|
|
19
|
+
textSource: "ax",
|
|
20
|
+
contentHash: "hash-1",
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function source(
|
|
26
|
+
machineLabel: string,
|
|
27
|
+
pages: Map<string | null, ActivitySnapshot[]>,
|
|
28
|
+
cursors: Map<string | null, string | null>,
|
|
29
|
+
): ActivitySourceClient {
|
|
30
|
+
return {
|
|
31
|
+
machineLabel,
|
|
32
|
+
async verify() {
|
|
33
|
+
return { ok: true };
|
|
34
|
+
},
|
|
35
|
+
async fetchSnapshots({ cursor }) {
|
|
36
|
+
return { snapshots: pages.get(cursor ?? null) ?? [], nextCursor: cursors.get(cursor ?? null) ?? null };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("syncActivitySource persists every page, renders the digest, then advances the cursor", async () => {
|
|
42
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
43
|
+
const store = ActivityStore.open(memoryDir);
|
|
44
|
+
try {
|
|
45
|
+
const client = source(
|
|
46
|
+
"workstation-a",
|
|
47
|
+
new Map([
|
|
48
|
+
[null, [snapshot()]],
|
|
49
|
+
["cursor-1", [snapshot({ capturedAtUtc: "2026-07-22T14:01:00.000Z", contentHash: "hash-2" })]],
|
|
50
|
+
]),
|
|
51
|
+
new Map([
|
|
52
|
+
[null, "cursor-1"],
|
|
53
|
+
["cursor-1", "cursor-2"],
|
|
54
|
+
]),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const result = await syncActivitySource(client, { date: "2026-07-22", timezone: "UTC", memoryDir, store });
|
|
58
|
+
|
|
59
|
+
assert.deepEqual(result, {
|
|
60
|
+
machine: "workstation-a",
|
|
61
|
+
fetched: 2,
|
|
62
|
+
inserted: 2,
|
|
63
|
+
duplicates: 0,
|
|
64
|
+
cursor: "cursor-2",
|
|
65
|
+
digestWritten: true,
|
|
66
|
+
});
|
|
67
|
+
assert.equal(store.getCursor(activityCursorKey("workstation-a", "2026-07-22")), "cursor-2");
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
store.listSnapshotsForDay(null, "2026-07-22T00:00:00.000Z", "2026-07-23T00:00:00.000Z").map((item) => item.machine),
|
|
70
|
+
["workstation-a", "workstation-a"],
|
|
71
|
+
"the source identity, not a remote payload label, owns the persisted machine field",
|
|
72
|
+
);
|
|
73
|
+
assert.match(await readFile(activityDigestPath(memoryDir, "2026-07-22"), "utf8"), /snapshotCount: 2/);
|
|
74
|
+
} finally {
|
|
75
|
+
store.close();
|
|
76
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("syncActivitySource leaves the cursor unchanged when a later page cannot persist", async () => {
|
|
81
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
82
|
+
const store = ActivityStore.open(memoryDir);
|
|
83
|
+
try {
|
|
84
|
+
const client = source(
|
|
85
|
+
"workstation-a",
|
|
86
|
+
new Map([
|
|
87
|
+
[null, [snapshot()]],
|
|
88
|
+
["cursor-1", [snapshot({ capturedAtUtc: "not-a-timestamp", contentHash: "bad-hash" })]],
|
|
89
|
+
]),
|
|
90
|
+
new Map([
|
|
91
|
+
[null, "cursor-1"],
|
|
92
|
+
["cursor-1", "cursor-2"],
|
|
93
|
+
]),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await assert.rejects(
|
|
97
|
+
syncActivitySource(client, { date: "2026-07-22", timezone: "UTC", memoryDir, store }),
|
|
98
|
+
/capture timestamp/,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
assert.equal(store.getCursor(activityCursorKey("workstation-a", "2026-07-22")), null);
|
|
102
|
+
assert.equal(store.listSnapshotsForDay(null, "2026-07-22T00:00:00.000Z", "2026-07-23T00:00:00.000Z").length, 1);
|
|
103
|
+
} finally {
|
|
104
|
+
store.close();
|
|
105
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("syncActivitySource completes normally when the final page lands on the page cap", async () => {
|
|
110
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
111
|
+
const store = ActivityStore.open(memoryDir);
|
|
112
|
+
try {
|
|
113
|
+
const client = source(
|
|
114
|
+
"workstation-a",
|
|
115
|
+
new Map([
|
|
116
|
+
[null, [snapshot()]],
|
|
117
|
+
["cursor-1", [snapshot({ capturedAtUtc: "2026-07-22T14:01:00.000Z", contentHash: "hash-2" })]],
|
|
118
|
+
]),
|
|
119
|
+
new Map([
|
|
120
|
+
[null, "cursor-1"],
|
|
121
|
+
["cursor-1", null],
|
|
122
|
+
]),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Two pages with a page budget of exactly two: the terminal null cursor
|
|
126
|
+
// arrives on the last allowed page, which must not be treated as a runaway.
|
|
127
|
+
const result = await syncActivitySource(client, { date: "2026-07-22", timezone: "UTC", memoryDir, store, maxPages: 2 });
|
|
128
|
+
assert.equal(result.inserted, 2);
|
|
129
|
+
assert.equal(result.cursor, "cursor-1");
|
|
130
|
+
assert.equal(store.getCursor(activityCursorKey("workstation-a", "2026-07-22")), "cursor-1");
|
|
131
|
+
} finally {
|
|
132
|
+
store.close();
|
|
133
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("syncActivitySource rejects runaway pagination and leaves the cursor unadvanced", async () => {
|
|
138
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
139
|
+
const store = ActivityStore.open(memoryDir);
|
|
140
|
+
try {
|
|
141
|
+
let issued = 0;
|
|
142
|
+
const runaway: ActivitySourceClient = {
|
|
143
|
+
machineLabel: "workstation-a",
|
|
144
|
+
async verify() {
|
|
145
|
+
return { ok: true };
|
|
146
|
+
},
|
|
147
|
+
async fetchSnapshots() {
|
|
148
|
+
issued += 1;
|
|
149
|
+
return { snapshots: [], nextCursor: `cursor-${issued}` };
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
await assert.rejects(
|
|
154
|
+
syncActivitySource(runaway, { date: "2026-07-22", timezone: "UTC", memoryDir, store, maxPages: 3 }),
|
|
155
|
+
/exceeded 3 pages/,
|
|
156
|
+
);
|
|
157
|
+
assert.equal(store.getCursor(activityCursorKey("workstation-a", "2026-07-22")), null);
|
|
158
|
+
} finally {
|
|
159
|
+
store.close();
|
|
160
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("concurrent same-day syncs from two sources persist without corrupting the digest", async () => {
|
|
165
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
166
|
+
const store = ActivityStore.open(memoryDir);
|
|
167
|
+
try {
|
|
168
|
+
const clientA = source("workstation-a", new Map([[null, [snapshot({ contentHash: "a-1" })]]]), new Map([[null, null]]));
|
|
169
|
+
const clientB = source("workstation-b", new Map([[null, [snapshot({ contentHash: "b-1" })]]]), new Map([[null, null]]));
|
|
170
|
+
|
|
171
|
+
await Promise.all([
|
|
172
|
+
syncActivitySource(clientA, { date: "2026-07-22", timezone: "UTC", memoryDir, store }),
|
|
173
|
+
syncActivitySource(clientB, { date: "2026-07-22", timezone: "UTC", memoryDir, store }),
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
const digest = await readFile(activityDigestPath(memoryDir, "2026-07-22"), "utf8");
|
|
177
|
+
assert.match(digest, /kind: activity-digest/);
|
|
178
|
+
assert.match(digest, /snapshotCount: 2/);
|
|
179
|
+
assert.equal(store.getCursor(activityCursorKey("workstation-a", "2026-07-22")), null);
|
|
180
|
+
assert.equal(store.getCursor(activityCursorKey("workstation-b", "2026-07-22")), null);
|
|
181
|
+
} finally {
|
|
182
|
+
store.close();
|
|
183
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("syncActivitySource reindexes after a digest write, with the digest already durable", async () => {
|
|
188
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
189
|
+
const store = ActivityStore.open(memoryDir);
|
|
190
|
+
try {
|
|
191
|
+
const client = source("workstation-a", new Map([[null, [snapshot()]]]), new Map([[null, null]]));
|
|
192
|
+
let reindexed = 0;
|
|
193
|
+
let digestAtReindex = "";
|
|
194
|
+
const result = await syncActivitySource(client, {
|
|
195
|
+
date: "2026-07-22",
|
|
196
|
+
timezone: "UTC",
|
|
197
|
+
memoryDir,
|
|
198
|
+
store,
|
|
199
|
+
afterWrites: async () => {
|
|
200
|
+
reindexed += 1;
|
|
201
|
+
// The reindex must see the durable digest already on disk — that is
|
|
202
|
+
// what makes the new day discoverable (rule 31).
|
|
203
|
+
digestAtReindex = await readFile(activityDigestPath(memoryDir, "2026-07-22"), "utf8");
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
assert.equal(result.digestWritten, true);
|
|
208
|
+
assert.equal(reindexed, 1, "reindex fires exactly once after the digest write");
|
|
209
|
+
assert.match(digestAtReindex, /snapshotCount: 1/, "the written digest is visible to the reindex");
|
|
210
|
+
assert.equal(result.reindexError, undefined);
|
|
211
|
+
} finally {
|
|
212
|
+
store.close();
|
|
213
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("syncActivitySource skips the reindex when the digest is unchanged", async () => {
|
|
218
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
219
|
+
const store = ActivityStore.open(memoryDir);
|
|
220
|
+
try {
|
|
221
|
+
let reindexed = 0;
|
|
222
|
+
const opts = {
|
|
223
|
+
date: "2026-07-22",
|
|
224
|
+
timezone: "UTC",
|
|
225
|
+
memoryDir,
|
|
226
|
+
store,
|
|
227
|
+
afterWrites: async () => {
|
|
228
|
+
reindexed += 1;
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const first = source("workstation-a", new Map([[null, [snapshot()]]]), new Map([[null, null]]));
|
|
232
|
+
await syncActivitySource(first, opts);
|
|
233
|
+
const second = source("workstation-a", new Map([[null, [snapshot()]]]), new Map([[null, null]]));
|
|
234
|
+
const result = await syncActivitySource(second, opts);
|
|
235
|
+
|
|
236
|
+
assert.equal(result.digestWritten, false, "an unchanged day rewrites nothing");
|
|
237
|
+
assert.equal(reindexed, 1, "no reindex when the digest did not change");
|
|
238
|
+
} finally {
|
|
239
|
+
store.close();
|
|
240
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("syncActivitySource isolates a reindex failure: rows, digest, and cursor still commit", async () => {
|
|
245
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
246
|
+
const store = ActivityStore.open(memoryDir);
|
|
247
|
+
try {
|
|
248
|
+
const client = source("workstation-a", new Map([[null, [snapshot()]]]), new Map([[null, null]]));
|
|
249
|
+
const result = await syncActivitySource(client, {
|
|
250
|
+
date: "2026-07-22",
|
|
251
|
+
timezone: "UTC",
|
|
252
|
+
memoryDir,
|
|
253
|
+
store,
|
|
254
|
+
afterWrites: async () => {
|
|
255
|
+
// A backend error whose message embeds an absolute path must be
|
|
256
|
+
// sanitized to a name+code in the exported reindexError, not echoed.
|
|
257
|
+
throw Object.assign(new Error("ENOENT: /abs/qmd/index.sqlite missing"), { code: "ENOENT" });
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
assert.equal(result.digestWritten, true, "the digest is durable despite the reindex failure");
|
|
262
|
+
assert.match(result.reindexError ?? "", /ENOENT/);
|
|
263
|
+
assert.ok(!result.reindexError?.includes("/abs/qmd"), "reindexError does not leak the absolute path");
|
|
264
|
+
assert.equal(result.inserted, 1);
|
|
265
|
+
await access(activityDigestPath(memoryDir, "2026-07-22"));
|
|
266
|
+
assert.equal(
|
|
267
|
+
store.getCursor(activityCursorKey("workstation-a", "2026-07-22")),
|
|
268
|
+
null,
|
|
269
|
+
"cursor still advanced; reindex is best-effort",
|
|
270
|
+
);
|
|
271
|
+
} finally {
|
|
272
|
+
store.close();
|
|
273
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("syncActivitySource honors an abort before committing the digest and cursor", async () => {
|
|
278
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
279
|
+
const store = ActivityStore.open(memoryDir);
|
|
280
|
+
try {
|
|
281
|
+
const controller = new AbortController();
|
|
282
|
+
const client: ActivitySourceClient = {
|
|
283
|
+
machineLabel: "workstation-a",
|
|
284
|
+
async verify() {
|
|
285
|
+
return { ok: true };
|
|
286
|
+
},
|
|
287
|
+
async fetchSnapshots() {
|
|
288
|
+
// Abort fires after the last page returns but before the digest write.
|
|
289
|
+
controller.abort();
|
|
290
|
+
return { snapshots: [snapshot()], nextCursor: null };
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
await assert.rejects(
|
|
295
|
+
syncActivitySource(client, {
|
|
296
|
+
date: "2026-07-22",
|
|
297
|
+
timezone: "UTC",
|
|
298
|
+
memoryDir,
|
|
299
|
+
store,
|
|
300
|
+
signal: controller.signal,
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
assert.equal(
|
|
305
|
+
store.getCursor(activityCursorKey("workstation-a", "2026-07-22")),
|
|
306
|
+
null,
|
|
307
|
+
"an aborted tick never advances the cursor",
|
|
308
|
+
);
|
|
309
|
+
await assert.rejects(
|
|
310
|
+
readFile(activityDigestPath(memoryDir, "2026-07-22"), "utf8"),
|
|
311
|
+
"an aborted tick never writes the digest",
|
|
312
|
+
);
|
|
313
|
+
} finally {
|
|
314
|
+
store.close();
|
|
315
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("syncActivitySource persists out-of-window snapshots under their own day, never the requested day's digest", async () => {
|
|
320
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
321
|
+
const store = ActivityStore.open(memoryDir);
|
|
322
|
+
try {
|
|
323
|
+
const client = source(
|
|
324
|
+
"workstation-a",
|
|
325
|
+
new Map([
|
|
326
|
+
[
|
|
327
|
+
null,
|
|
328
|
+
[
|
|
329
|
+
snapshot({ capturedAtUtc: "2026-07-22T14:00:00.000Z", contentHash: "in-window" }),
|
|
330
|
+
// A snapshot the daemon misplaced onto a different day (replay /
|
|
331
|
+
// timezone bug). It must be retained under its own day, not lost.
|
|
332
|
+
snapshot({ capturedAtUtc: "2026-07-20T10:00:00.000Z", contentHash: "out-of-window" }),
|
|
333
|
+
],
|
|
334
|
+
],
|
|
335
|
+
]),
|
|
336
|
+
new Map([[null, null]]),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const result = await syncActivitySource(client, { date: "2026-07-22", timezone: "UTC", memoryDir, store });
|
|
340
|
+
|
|
341
|
+
assert.equal(result.fetched, 2, "both snapshots were fetched");
|
|
342
|
+
assert.equal(result.inserted, 2, "both snapshots are durably persisted (no data loss)");
|
|
343
|
+
// The requested day's digest is a window query: only its in-window row.
|
|
344
|
+
const day = store.listSnapshotsForDay(null, "2026-07-22T00:00:00.000Z", "2026-07-23T00:00:00.000Z");
|
|
345
|
+
assert.deepEqual(day.map((s) => s.contentHash), ["in-window"], "the requested day contains only its in-window row");
|
|
346
|
+
assert.match(await readFile(activityDigestPath(memoryDir, "2026-07-22"), "utf8"), /snapshotCount: 1/);
|
|
347
|
+
// The misplaced row is durable under ITS OWN day, surfacing when that day syncs.
|
|
348
|
+
const ownDay = store.listSnapshotsForDay(null, "2026-07-20T00:00:00.000Z", "2026-07-21T00:00:00.000Z");
|
|
349
|
+
assert.deepEqual(ownDay.map((s) => s.contentHash), ["out-of-window"], "the misplaced row is retained under its own day, not dropped");
|
|
350
|
+
// Nothing was skipped, so the cursor advancing past the page is correct.
|
|
351
|
+
assert.equal(store.getCursor(activityCursorKey("workstation-a", "2026-07-22")), null, "cursor advances after a fully-persisted page");
|
|
352
|
+
} finally {
|
|
353
|
+
store.close();
|
|
354
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("syncActivitySource rejects a calendar-overflow timestamp instead of skipping it", async () => {
|
|
359
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-pipeline-"));
|
|
360
|
+
const store = ActivityStore.open(memoryDir);
|
|
361
|
+
try {
|
|
362
|
+
const client = source(
|
|
363
|
+
"workstation-a",
|
|
364
|
+
// 2026-02-30 is impossible; Date.parse rolls it to Mar 2, which would fall
|
|
365
|
+
// outside a Feb 28 sync window. It must fail loudly, not be silently skipped.
|
|
366
|
+
new Map([[null, [snapshot({ capturedAtUtc: "2026-02-30T12:00:00.000Z", contentHash: "overflow" })]]]),
|
|
367
|
+
new Map([[null, null]]),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
await assert.rejects(
|
|
371
|
+
syncActivitySource(client, { date: "2026-02-28", timezone: "UTC", memoryDir, store }),
|
|
372
|
+
/capture timestamp/,
|
|
373
|
+
);
|
|
374
|
+
assert.equal(store.getCursor(activityCursorKey("workstation-a", "2026-02-28")), null, "cursor never advances on a rejected page");
|
|
375
|
+
} finally {
|
|
376
|
+
store.close();
|
|
377
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
378
|
+
}
|
|
379
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { displayErrorDetail } from "../runtime/better-sqlite.js";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
activityDayWindow,
|
|
9
|
+
activityDigestPath,
|
|
10
|
+
composeActivityDigestBody,
|
|
11
|
+
composeActivityDigestMeta,
|
|
12
|
+
isValidActivityDate,
|
|
13
|
+
serializeActivityDigest,
|
|
14
|
+
} from "./digest.js";
|
|
15
|
+
import type { ActivityStore } from "./store.js";
|
|
16
|
+
import type { ActivitySnapshot, ActivitySourceClient } from "./types.js";
|
|
17
|
+
|
|
18
|
+
const MAX_SYNC_PAGES = 10_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sync cursors are scoped per (machine, local day): each date resumes its own
|
|
22
|
+
* pagination independently, so a multi-day window never inherits an earlier
|
|
23
|
+
* day's cursor (which could skip backfill). Encoded into the store's single
|
|
24
|
+
* key column with a NUL separator that cannot occur in a machine label.
|
|
25
|
+
*/
|
|
26
|
+
export function activityCursorKey(machine: string, date: string): string {
|
|
27
|
+
return `${machine}\u0000${date}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Serializes the list -> compose -> write digest section per (memoryDir, date)
|
|
31
|
+
// so two sources syncing the same day cannot lose-update each other's digest:
|
|
32
|
+
// the last writer in the chain lists every row present and wins.
|
|
33
|
+
const digestLocks = new Map<string, Promise<unknown>>();
|
|
34
|
+
|
|
35
|
+
function withDigestLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
36
|
+
const prior = digestLocks.get(key) ?? Promise.resolve();
|
|
37
|
+
const next = prior.then(fn, fn);
|
|
38
|
+
const tail = next.then(
|
|
39
|
+
() => undefined,
|
|
40
|
+
() => undefined,
|
|
41
|
+
);
|
|
42
|
+
digestLocks.set(key, tail);
|
|
43
|
+
void tail.then(() => {
|
|
44
|
+
if (digestLocks.get(key) === tail) digestLocks.delete(key);
|
|
45
|
+
});
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ActivitySyncOptions {
|
|
50
|
+
date: string;
|
|
51
|
+
timezone: string;
|
|
52
|
+
memoryDir: string;
|
|
53
|
+
store: ActivityStore;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
/** Runaway-pagination guard; defaults to MAX_SYNC_PAGES. */
|
|
56
|
+
maxPages?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Search-index refresh, run after a digest is (re)written so the durable
|
|
59
|
+
* markdown becomes discoverable (AGENTS.md rule 31; the digest lives in the
|
|
60
|
+
* QMD collection root but bypasses the extraction->persist->index pipeline).
|
|
61
|
+
* Host-agnostic: the caller injects the core index seam (SearchBackend.update).
|
|
62
|
+
* Best-effort — a failure never fails the sync (rows/digest are durable and
|
|
63
|
+
* index on the next update); it surfaces as `reindexError`.
|
|
64
|
+
*/
|
|
65
|
+
afterWrites?: (signal?: AbortSignal) => Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ActivitySyncResult {
|
|
69
|
+
machine: string;
|
|
70
|
+
fetched: number;
|
|
71
|
+
inserted: number;
|
|
72
|
+
duplicates: number;
|
|
73
|
+
cursor: string | null;
|
|
74
|
+
digestWritten: boolean;
|
|
75
|
+
/** Set when the post-write reindex threw; the sync still succeeded. */
|
|
76
|
+
reindexError?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function snapshotForMachine(snapshot: ActivitySnapshot, machine: string): ActivitySnapshot {
|
|
80
|
+
return {
|
|
81
|
+
machine,
|
|
82
|
+
capturedAtUtc: snapshot.capturedAtUtc,
|
|
83
|
+
app: snapshot.app,
|
|
84
|
+
windowTitle: snapshot.windowTitle,
|
|
85
|
+
...(snapshot.browserUrl === undefined ? {} : { browserUrl: snapshot.browserUrl }),
|
|
86
|
+
text: snapshot.text,
|
|
87
|
+
textSource: snapshot.textSource,
|
|
88
|
+
contentHash: snapshot.contentHash,
|
|
89
|
+
...(snapshot.simhash === undefined ? {} : { simhash: snapshot.simhash }),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function writeDigestIfChanged(memoryDir: string, date: string, serialized: string): Promise<boolean> {
|
|
94
|
+
const target = activityDigestPath(memoryDir, date);
|
|
95
|
+
try {
|
|
96
|
+
if ((await readFile(target, "utf8")) === serialized) return false;
|
|
97
|
+
} catch (error: unknown) {
|
|
98
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
102
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
103
|
+
await writeFile(temporary, serialized, { encoding: "utf8", mode: 0o600 });
|
|
104
|
+
await rename(temporary, target);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Pull one source's local-day snapshots and persist its cursor only after every
|
|
110
|
+
* page and the derived digest are durable. Replays after a partial failure are
|
|
111
|
+
* safe because ActivityStore deduplicates an exact captured snapshot.
|
|
112
|
+
*/
|
|
113
|
+
export async function syncActivitySource(
|
|
114
|
+
source: ActivitySourceClient,
|
|
115
|
+
options: ActivitySyncOptions,
|
|
116
|
+
): Promise<ActivitySyncResult> {
|
|
117
|
+
if (!isValidActivityDate(options.date)) {
|
|
118
|
+
throw new RangeError(`Invalid activity date "${options.date}"; expected a real YYYY-MM-DD day.`);
|
|
119
|
+
}
|
|
120
|
+
if (source.machineLabel.trim().length === 0) {
|
|
121
|
+
throw new RangeError("activity source machine label must not be empty");
|
|
122
|
+
}
|
|
123
|
+
const maxPages = options.maxPages ?? MAX_SYNC_PAGES;
|
|
124
|
+
if (!Number.isInteger(maxPages) || maxPages < 1) {
|
|
125
|
+
throw new RangeError("activity sync maxPages must be a positive integer");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cursorKey = activityCursorKey(source.machineLabel, options.date);
|
|
129
|
+
let cursor = options.store.getCursor(cursorKey);
|
|
130
|
+
let pages = 0;
|
|
131
|
+
let fetched = 0;
|
|
132
|
+
let inserted = 0;
|
|
133
|
+
let duplicates = 0;
|
|
134
|
+
const seenCursors = new Set<string>();
|
|
135
|
+
let completed = false;
|
|
136
|
+
|
|
137
|
+
while (pages < maxPages) {
|
|
138
|
+
options.signal?.throwIfAborted();
|
|
139
|
+
const page = await source.fetchSnapshots({
|
|
140
|
+
date: options.date,
|
|
141
|
+
timezone: options.timezone,
|
|
142
|
+
cursor,
|
|
143
|
+
signal: options.signal,
|
|
144
|
+
});
|
|
145
|
+
pages += 1;
|
|
146
|
+
fetched += page.snapshots.length;
|
|
147
|
+
|
|
148
|
+
for (const snapshot of page.snapshots) {
|
|
149
|
+
// Persist every row keyed by its own timestamp: an out-of-window snapshot
|
|
150
|
+
// (replay file, timezone-boundary bug) is durable under its own day and
|
|
151
|
+
// never lost, while the day digest below is a window query so it never
|
|
152
|
+
// pollutes options.date. A malformed instant is rejected loudly here.
|
|
153
|
+
const result = options.store.insertSnapshot(snapshotForMachine(snapshot, source.machineLabel));
|
|
154
|
+
if (result.inserted) inserted += 1;
|
|
155
|
+
else duplicates += 1;
|
|
156
|
+
}
|
|
157
|
+
if (page.nextCursor === null) {
|
|
158
|
+
completed = true;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (typeof page.nextCursor !== "string" || page.nextCursor.length === 0) {
|
|
162
|
+
throw new TypeError("activity source returned an invalid cursor");
|
|
163
|
+
}
|
|
164
|
+
if (seenCursors.has(page.nextCursor)) {
|
|
165
|
+
throw new Error("activity source returned a repeated cursor");
|
|
166
|
+
}
|
|
167
|
+
seenCursors.add(page.nextCursor);
|
|
168
|
+
cursor = page.nextCursor;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Only a loop that ran out of its page budget without seeing a terminal
|
|
172
|
+
// null cursor is a runaway; completing on the final allowed page is normal.
|
|
173
|
+
if (!completed) {
|
|
174
|
+
throw new Error(`activity source exceeded ${maxPages} pages`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Re-check abort before the durable write section: stop() may have fired after
|
|
178
|
+
// the last page returned, or while this date waited behind digestLocks. An
|
|
179
|
+
// aborted tick must not compose/write a digest or advance the cursor.
|
|
180
|
+
options.signal?.throwIfAborted();
|
|
181
|
+
const digestWritten = await withDigestLock(`${options.memoryDir}\u0000${options.date}`, async () => {
|
|
182
|
+
options.signal?.throwIfAborted();
|
|
183
|
+
const { startUtc, endUtc } = activityDayWindow(options.date, options.timezone);
|
|
184
|
+
const snapshots = options.store.listSnapshotsForDay(null, startUtc, endUtc);
|
|
185
|
+
const body = composeActivityDigestBody(options.date, options.timezone, snapshots);
|
|
186
|
+
const machines = [...new Set(snapshots.map((snapshot) => snapshot.machine))].sort((a, b) =>
|
|
187
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
188
|
+
);
|
|
189
|
+
const serialized = serializeActivityDigest(composeActivityDigestMeta(options.date, machines, snapshots, body), body);
|
|
190
|
+
return writeDigestIfChanged(options.memoryDir, options.date, serialized);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
options.signal?.throwIfAborted();
|
|
194
|
+
options.store.setCursor(cursorKey, cursor);
|
|
195
|
+
|
|
196
|
+
// Rows and digest are durable and the cursor has advanced; refresh the search
|
|
197
|
+
// index so the new digest is discoverable (rule 31). Best-effort: a failure
|
|
198
|
+
// is reported, not thrown — the data indexes on the next update.
|
|
199
|
+
let reindexError: string | undefined;
|
|
200
|
+
if (digestWritten && options.afterWrites !== undefined) {
|
|
201
|
+
try {
|
|
202
|
+
await options.afterWrites(options.signal);
|
|
203
|
+
} catch (error: unknown) {
|
|
204
|
+
// reindexError is part of the exported ActivitySyncResult. Keep our
|
|
205
|
+
// controlled QMD strict messages, but reduce opaque backend errors (which
|
|
206
|
+
// can embed absolute paths / loader stacks) to a sanitized name+code.
|
|
207
|
+
reindexError =
|
|
208
|
+
error instanceof Error && error.message.startsWith("QMD ")
|
|
209
|
+
? error.message
|
|
210
|
+
: displayErrorDetail(error) || "reindex failed";
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
machine: source.machineLabel,
|
|
216
|
+
fetched,
|
|
217
|
+
inserted,
|
|
218
|
+
duplicates,
|
|
219
|
+
cursor,
|
|
220
|
+
digestWritten,
|
|
221
|
+
...(reindexError === undefined ? {} : { reindexError }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { refreshActivityIndex, type ActivityIndexRefresher } from "./reindex.js";
|
|
5
|
+
|
|
6
|
+
function refresher(overrides: Partial<ActivityIndexRefresher>, calls: string[]): ActivityIndexRefresher {
|
|
7
|
+
return {
|
|
8
|
+
async update() {
|
|
9
|
+
calls.push("update");
|
|
10
|
+
},
|
|
11
|
+
...overrides,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("refreshActivityIndex prefers the forced+strict collection refresh", async () => {
|
|
16
|
+
const calls: string[] = [];
|
|
17
|
+
const qmd = refresher(
|
|
18
|
+
{
|
|
19
|
+
async updateStrict() {
|
|
20
|
+
calls.push("updateStrict");
|
|
21
|
+
},
|
|
22
|
+
async updateCollectionStrict(collection: string) {
|
|
23
|
+
calls.push(`updateCollectionStrict:${collection}`);
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
calls,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
await refreshActivityIndex(qmd, "openclaw-engram");
|
|
30
|
+
assert.deepEqual(calls, ["updateCollectionStrict:openclaw-engram"], "forces past the fail-open min-interval gate");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("refreshActivityIndex falls back to updateStrict, then update", async () => {
|
|
34
|
+
const strictCalls: string[] = [];
|
|
35
|
+
await refreshActivityIndex(
|
|
36
|
+
refresher(
|
|
37
|
+
{
|
|
38
|
+
async updateStrict() {
|
|
39
|
+
strictCalls.push("updateStrict");
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
strictCalls,
|
|
43
|
+
),
|
|
44
|
+
"openclaw-engram",
|
|
45
|
+
);
|
|
46
|
+
assert.deepEqual(strictCalls, ["updateStrict"], "uses strict global refresh when no collection-strict path exists");
|
|
47
|
+
|
|
48
|
+
const plainCalls: string[] = [];
|
|
49
|
+
await refreshActivityIndex(refresher({}, plainCalls), "openclaw-engram");
|
|
50
|
+
assert.deepEqual(plainCalls, ["update"], "falls back to plain update when no strict path exists");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("refreshActivityIndex skips collection-strict for an empty collection name", async () => {
|
|
54
|
+
const calls: string[] = [];
|
|
55
|
+
await refreshActivityIndex(
|
|
56
|
+
refresher(
|
|
57
|
+
{
|
|
58
|
+
async updateStrict() {
|
|
59
|
+
calls.push("updateStrict");
|
|
60
|
+
},
|
|
61
|
+
async updateCollectionStrict(collection: string) {
|
|
62
|
+
calls.push(`updateCollectionStrict:${collection}`);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
calls,
|
|
66
|
+
),
|
|
67
|
+
" ",
|
|
68
|
+
);
|
|
69
|
+
assert.deepEqual(calls, ["updateStrict"], "no collection -> strict global refresh, never a blank-collection call");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("refreshActivityIndex propagates a strict-refresh failure (no fake success)", async () => {
|
|
73
|
+
const calls: string[] = [];
|
|
74
|
+
const qmd = refresher(
|
|
75
|
+
{
|
|
76
|
+
async updateCollectionStrict() {
|
|
77
|
+
calls.push("updateCollectionStrict");
|
|
78
|
+
throw new Error("QMD update skipped by per-collection failure backoff");
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
calls,
|
|
82
|
+
);
|
|
83
|
+
await assert.rejects(refreshActivityIndex(qmd, "openclaw-engram"), /failure backoff/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("refreshActivityIndex forwards the abort signal to the backend refresh", async () => {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
let seenSignal: AbortSignal | undefined;
|
|
89
|
+
const qmd: ActivityIndexRefresher = {
|
|
90
|
+
async update() {},
|
|
91
|
+
async updateCollectionStrict(_collection, execution) {
|
|
92
|
+
seenSignal = execution?.signal;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
await refreshActivityIndex(qmd, "openclaw-engram", controller.signal);
|
|
96
|
+
assert.equal(seenSignal, controller.signal, "the tick's abort signal reaches the QMD refresh");
|
|
97
|
+
});
|