@anteros/core 0.0.41 → 0.0.44

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 (133) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +724 -58
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1567 -0
  11. package/lib/agentMemory.ts +689 -0
  12. package/lib/agents.ts +180 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/mcpClient.ts +128 -0
  25. package/lib/messages.ts +299 -0
  26. package/lib/providers.ts +970 -0
  27. package/lib/routes.ts +14 -1
  28. package/lib/scriptWorker.ts +4 -1
  29. package/lib/scripts.ts +6 -1
  30. package/lib/skills.ts +481 -0
  31. package/lib/sockets.ts +3 -1
  32. package/lib/workflow.ts +209 -37
  33. package/lib/zip.ts +109 -0
  34. package/package.json +1 -1
  35. package/server/access.ts +96 -0
  36. package/server/agents.ts +472 -0
  37. package/server/api.ts +80 -65
  38. package/server/boot.ts +11 -0
  39. package/server/hono.ts +8 -3
  40. package/server/mcp.ts +17 -63
  41. package/server/routes.ts +19 -24
  42. package/tests/agent-mcp.test.ts +116 -0
  43. package/tests/agent-memory.test.ts +617 -0
  44. package/tests/agent-provider.test.ts +288 -0
  45. package/tests/agent-runtime.test.ts +369 -0
  46. package/tests/agent-skills.test.ts +177 -0
  47. package/tests/agent-summarize.test.ts +124 -0
  48. package/tests/agent-working-memory.test.ts +404 -0
  49. package/tests/agent.test.ts +1241 -0
  50. package/tests/agents-api.test.ts +593 -0
  51. package/tests/api-private-fields.test.ts +196 -0
  52. package/tests/api.test.ts +51 -0
  53. package/tests/attachment.test.ts +241 -0
  54. package/tests/audit-operations.test.ts +1 -1
  55. package/tests/audit-redact.test.ts +29 -3
  56. package/tests/audit-run-action.test.ts +1 -1
  57. package/tests/endpoints.test.ts +80 -0
  58. package/tests/file-collection-replication.test.ts +2 -0
  59. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  60. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  61. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  62. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  63. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  64. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  65. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  66. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  67. package/tests/fixtures/attachment/data.csv +2 -0
  68. package/tests/fixtures/fake-provider.ts +128 -0
  69. package/tests/fixtures/repl-only/app.ts +3 -0
  70. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  71. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  72. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  73. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  74. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  75. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  76. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  77. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  78. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  79. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  80. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  81. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  82. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  83. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  84. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  85. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  86. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  87. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  88. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  89. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  90. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  91. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  92. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  93. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  94. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  95. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  96. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  97. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  98. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  99. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  100. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  101. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  102. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  103. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  104. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  105. package/tests/hono.test.ts +6 -3
  106. package/tests/locks.test.ts +120 -0
  107. package/tests/mcp.test.ts +11 -11
  108. package/tests/messages.test.ts +258 -0
  109. package/tests/replication-change-stream.test.ts +240 -0
  110. package/tests/replication-control.test.ts +4 -2
  111. package/tests/replication-initial-sync.test.ts +2 -0
  112. package/tests/replication-meta.test.ts +246 -0
  113. package/tests/replication.test.ts +2 -0
  114. package/tests/upload.test.ts +44 -12
  115. package/tests/vars-api.test.ts +1 -1
  116. package/tests/vars-replication.test.ts +5 -2
  117. package/tests/workflow-engine.test.ts +340 -0
  118. package/tests/workflow-retention.test.ts +103 -0
  119. package/tests/workflow.test.ts +186 -0
  120. package/types/agent.d.ts +1067 -0
  121. package/types/config.d.ts +16 -0
  122. package/types/file.d.ts +7 -7
  123. package/types/hook.d.ts +6 -0
  124. package/types/lifecycle.d.ts +6 -0
  125. package/types/mcp.d.ts +11 -1
  126. package/types/replication.d.ts +70 -4
  127. package/types/route.d.ts +7 -1
  128. package/types/scripts.d.ts +6 -0
  129. package/types/service.d.ts +7 -1
  130. package/types/tenant.d.ts +13 -0
  131. package/types/vars.d.ts +2 -2
  132. package/types/websocket.d.ts +6 -0
  133. package/types/workflow.d.ts +50 -3
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Change-stream replication — the **default** mode (`replication.mode` unset).
3
+ *
4
+ * What separates it from the scan engine, and what these cases pin down:
5
+ * - nothing has to be triggered: a write reaches the destination on its own;
6
+ * - deletes come from the oplog, so a collection **without hooks** propagates them
7
+ * too (the scan engine can only see applicative tombstones, which is why an
8
+ * `_audit_` entry deleted out of band used to survive on the destination);
9
+ * - the position is a resume token, not a date cursor;
10
+ * - the stream owns a collection, so a one-shot run leaves it alone;
11
+ * - `reset()` re-arms the stream, which cold-starts again.
12
+ */
13
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
14
+ import { MongoClient } from "mongodb";
15
+ import { useRest } from "../database/rest";
16
+ import { formatConfig, cfg } from "../server/config";
17
+ import { syncTenants } from "../database/tenant";
18
+ import { syncCollections } from "../database/collection";
19
+ import { syncVars } from "../database/vars";
20
+ import {
21
+ startReplication,
22
+ stopReplication,
23
+ replicateTenant,
24
+ getReplicationState,
25
+ resetReplication,
26
+ } from "../database/replication";
27
+ import { AUDIT_COLLECTION } from "../database/audit";
28
+
29
+ const ORDERS_T = "cs-orders";
30
+ const ORDERS_DIR = "packages/core/tests/fixtures/replication-meta";
31
+ const ORDERS_SRC = "mongodb://localhost:27017/_CS_SRC";
32
+ const ORDERS_DST = "mongodb://localhost:27017/_CS_DST";
33
+ const DEST_ID = "backup";
34
+
35
+ const VARS_T = "cs-vars";
36
+ const VARS_DIR = "packages/core/tests/fixtures/vars-tenant";
37
+ const VARS_SRC = "mongodb://localhost:27017/_CS_VARS_SRC";
38
+ const VARS_DST = "mongodb://localhost:27017/_CS_VARS_DST";
39
+
40
+ let rest: InstanceType<typeof useRest>;
41
+ let restVars: InstanceType<typeof useRest>;
42
+ let src: MongoClient;
43
+ let dest: MongoClient;
44
+ let varsDest: MongoClient;
45
+
46
+ /** Poll `check` until it holds — a change stream is asynchronous by nature. */
47
+ async function waitFor(check: () => Promise<boolean>, timeout = 8_000, step = 50): Promise<boolean> {
48
+ const deadline = Date.now() + timeout;
49
+ while (Date.now() < deadline) {
50
+ if (await check()) return true;
51
+ await Bun.sleep(step);
52
+ }
53
+ return false;
54
+ }
55
+
56
+ const ordersDest = () => dest.db().collection("orders");
57
+ const auditDest = () => dest.db().collection(AUDIT_COLLECTION);
58
+ const varsOnDest = (ns: string, key: string) => varsDest.db().collection("_vars_").findOne({ ns, key });
59
+
60
+ beforeAll(async () => {
61
+ formatConfig({
62
+ server: { port: 4000 },
63
+ tenants: [
64
+ {
65
+ id: ORDERS_T,
66
+ dir: ORDERS_DIR,
67
+ database: { uri: ORDERS_SRC },
68
+ // No `mode`: change streams are the default. No `key`, no `batchSize`,
69
+ // no `schedule` either — the stream is the whole engine.
70
+ replication: { runOnBoot: false, destinations: [{ id: DEST_ID, uri: ORDERS_DST }] },
71
+ },
72
+ {
73
+ id: VARS_T,
74
+ dir: VARS_DIR,
75
+ database: { uri: VARS_SRC },
76
+ replication: { runOnBoot: false, destinations: [{ id: "backup", uri: VARS_DST }] },
77
+ },
78
+ ],
79
+ });
80
+
81
+ await syncTenants();
82
+ await syncCollections();
83
+ await syncVars();
84
+ await startReplication();
85
+
86
+ rest = new useRest({ tenant_id: ORDERS_T });
87
+ restVars = new useRest({ tenant_id: VARS_T });
88
+ src = new MongoClient(ORDERS_SRC, { serverSelectionTimeoutMS: 3000 });
89
+ dest = new MongoClient(ORDERS_DST, { serverSelectionTimeoutMS: 3000 });
90
+ varsDest = new MongoClient(VARS_DST, { serverSelectionTimeoutMS: 3000 });
91
+ await Promise.all([src.connect(), dest.connect(), varsDest.connect()]);
92
+ });
93
+
94
+ afterAll(async () => {
95
+ await stopReplication();
96
+ for (const uri of [ORDERS_SRC, ORDERS_DST, VARS_SRC, VARS_DST]) {
97
+ try {
98
+ const client = new MongoClient(uri);
99
+ await client.connect();
100
+ await client.db().dropDatabase();
101
+ await client.close();
102
+ } catch (_) {}
103
+ }
104
+ cfg.agents = [];
105
+ cfg.agentMemories = [];
106
+ try { await src.close(); } catch (_) {}
107
+ try { await dest.close(); } catch (_) {}
108
+ try { await varsDest.close(); } catch (_) {}
109
+ });
110
+
111
+ describe("replication — changeStream mode", () => {
112
+ it("replicates a write on its own, with no run to trigger", async () => {
113
+ const marker = `cs-insert-${crypto.randomUUID()}`;
114
+ await rest.insertOne("orders", { title: marker });
115
+
116
+ // No `replicateTenant()` call anywhere: the stream is the engine
117
+ expect(await waitFor(async () => Boolean(await ordersDest().findOne({ title: marker })))).toBe(true);
118
+ });
119
+
120
+ it("replicates an update in place", async () => {
121
+ const marker = `cs-update-${crypto.randomUUID()}`;
122
+ const doc: any = await rest.insertOne("orders", { title: marker });
123
+ expect(await waitFor(async () => Boolean(await ordersDest().findOne({ title: marker })))).toBe(true);
124
+
125
+ await rest.updateOne("orders", doc._id, { $set: { title: `${marker}-paid` } });
126
+ expect(await waitFor(async () => (await ordersDest().countDocuments({ title: `${marker}-paid` })) === 1)).toBe(true);
127
+ });
128
+
129
+ it("propagates a delete without writing a tombstone", async () => {
130
+ const marker = `cs-delete-${crypto.randomUUID()}`;
131
+ const doc: any = await rest.insertOne("orders", { title: marker });
132
+ expect(await waitFor(async () => Boolean(await ordersDest().findOne({ title: marker })))).toBe(true);
133
+
134
+ await rest.deleteOne("orders", doc._id);
135
+ expect(await waitFor(async () => (await ordersDest().countDocuments({ title: marker })) === 0)).toBe(true);
136
+
137
+ // The oplog carried it: the hook + tombstone path is not even installed here
138
+ const tombstones = await src.db().collection("_replication_").countDocuments({ type: "delete", collection: "orders" });
139
+ expect(tombstones).toBe(0);
140
+ });
141
+
142
+ it("propagates a delete on a framework collection no hook can see", async () => {
143
+ const marker = `cs-audit-${crypto.randomUUID()}`;
144
+ await rest.audit.addActivities([{
145
+ internal: true,
146
+ trace: { id: crypto.randomUUID() },
147
+ meta: {},
148
+ operation: {
149
+ tenant: ORDERS_T, action: marker, collection: "orders",
150
+ status: "success", input: null, result: null, error: null, duration: 0, transaction: false,
151
+ },
152
+ ts: new Date(),
153
+ } as any]);
154
+ expect(await waitFor(async () => Boolean(await auditDest().findOne({ "operation.action": marker })))).toBe(true);
155
+
156
+ // Out of band: no applicative hook runs, no tombstone is written — the scan
157
+ // engine would leave this entry on the destination forever
158
+ await src.db().collection(AUDIT_COLLECTION).deleteOne({ "operation.action": marker } as any);
159
+
160
+ expect(await waitFor(async () => (await auditDest().countDocuments({ "operation.action": marker })) === 0)).toBe(true);
161
+ }, 20_000);
162
+
163
+ it("stores a resume token in `_replication_`, tagged with the mode", async () => {
164
+ const [state] = await getReplicationState(ORDERS_T, DEST_ID, "orders");
165
+ expect(state?.mode).toBe("changeStream");
166
+ expect(state?.resumeToken).toBeDefined();
167
+ expect(state?.status).toBe("watching");
168
+ });
169
+
170
+ it("leaves a streamed collection alone when a run is triggered", async () => {
171
+ const results = await replicateTenant(ORDERS_T);
172
+
173
+ // The stream owns it: a scan on top would let a stale read overwrite a fresh
174
+ // event, so the run reports nothing for that collection
175
+ expect(results.find((result) => result.collection === "orders")).toBeUndefined();
176
+ });
177
+
178
+ it("cold-starts again after `reset()` — the position is really cleared", async () => {
179
+ const marker = `cs-reset-${crypto.randomUUID()}`;
180
+ await rest.insertOne("orders", { title: marker });
181
+ expect(await waitFor(async () => Boolean(await ordersDest().findOne({ title: marker })))).toBe(true);
182
+
183
+ // Lose the copy, then forget the position: only a backfill can bring it back
184
+ await ordersDest().deleteMany({});
185
+ const reset = await resetReplication(ORDERS_T, { destination: DEST_ID, collection: "orders" });
186
+ expect(reset.state).toBeGreaterThanOrEqual(1);
187
+
188
+ expect(await waitFor(async () => (await ordersDest().countDocuments({ title: marker })) === 1, 20_000)).toBe(true);
189
+ }, 30_000);
190
+
191
+ it("applies the collection's document filter to the stream (`_vars_` namespaces)", async () => {
192
+ const marker = `cs-vars-${crypto.randomUUID()}`;
193
+ await restVars.vars.set("config", marker, "REPLICATED");
194
+ await restVars.vars.set("private", "secret", marker);
195
+
196
+ expect(await waitFor(async () => Boolean(await varsOnDest("config", marker)))).toBe(true);
197
+ // `private.var.ts` opts out: the filter is on the post-image, so the stream
198
+ // never even writes it
199
+ expect(await varsOnDest("private", "secret")).toBeNull();
200
+ });
201
+ });
202
+
203
+ describe("replication — changeStream shutdown", () => {
204
+ it("stops writing once replication is stopped", async () => {
205
+ await stopReplication();
206
+
207
+ const marker = `cs-after-stop-${crypto.randomUUID()}`;
208
+ await rest.insertOne("orders", { title: marker });
209
+ await Bun.sleep(1_500); // long enough for a stream that is still alive
210
+
211
+ expect(await ordersDest().countDocuments({ title: marker })).toBe(0);
212
+ });
213
+
214
+ it("does not wait forever on a collection another writer owns", async () => {
215
+ // A stream elsewhere already owns `orders`; this boot must not block on it
216
+ await rest.lock(`replication:${DEST_ID}:orders`, 60_000);
217
+
218
+ const startedAt = Date.now();
219
+ await startReplication();
220
+ const elapsed = Date.now() - startedAt;
221
+
222
+ expect(elapsed).toBeLessThan(25_000);
223
+
224
+ // …and the collections it *could* take are still watched: an entry written
225
+ // with no run reaches the destination while `orders` is skipped
226
+ const marker = `cs-partial-${crypto.randomUUID()}`;
227
+ await rest.audit.addActivities([{
228
+ internal: true,
229
+ trace: { id: crypto.randomUUID() },
230
+ meta: {},
231
+ operation: {
232
+ tenant: ORDERS_T, action: marker, collection: "orders",
233
+ status: "success", input: null, result: null, error: null, duration: 0, transaction: false,
234
+ },
235
+ ts: new Date(),
236
+ } as any]);
237
+
238
+ expect(await waitFor(async () => (await auditDest().countDocuments({ "operation.action": marker })) === 1)).toBe(true);
239
+ }, 60_000);
240
+ });
@@ -28,6 +28,8 @@ beforeAll(async () => {
28
28
  dir: "src",
29
29
  database: { uri: SRC_DB },
30
30
  replication: {
31
+ // These cases exercise the scan engine (cursor, tombstones, seeding)
32
+ mode: "scan",
31
33
  runOnBoot: false,
32
34
  schedule: { interval: "1h" },
33
35
  destinations: [{ id: DEST_ID, uri: DEST_DB }],
@@ -102,7 +104,7 @@ describe("replication seed", () => {
102
104
  const preMarker = `pre-${crypto.randomUUID()}`;
103
105
  await rest.insertOne("orders", { title: preMarker });
104
106
 
105
- const res = await seedReplication(TEST_TENANT, "latest");
107
+ const res = await seedReplication(TEST_TENANT, "latest", { collection: "orders" });
106
108
  expect(res.seeded).toBe(1);
107
109
 
108
110
  await replicateTenant(TEST_TENANT);
@@ -118,7 +120,7 @@ describe("replication seed", () => {
118
120
 
119
121
  it("seeds from an explicit date", async () => {
120
122
  await resetReplication(TEST_TENANT);
121
- const res = await seedReplication(TEST_TENANT, "2020-01-01T00:00:00Z");
123
+ const res = await seedReplication(TEST_TENANT, "2020-01-01T00:00:00Z", { collection: "orders" });
122
124
  expect(res.seeded).toBe(1);
123
125
 
124
126
  const state = await getReplicationState(TEST_TENANT, DEST_ID, "orders");
@@ -21,6 +21,8 @@ beforeAll(async () => {
21
21
  dir: "src",
22
22
  database: { uri: SRC_DB },
23
23
  replication: {
24
+ // These cases exercise the scan engine's `initialSync` seeding
25
+ mode: "scan",
24
26
  runOnBoot: false,
25
27
  schedule: { interval: "1h" },
26
28
  destinations: [{ id: DEST_ID, uri: DEST_DB }],
@@ -0,0 +1,246 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import os from "node:os";
3
+ import { MongoClient } from "mongodb";
4
+ import { useRest } from "../database/rest";
5
+ import { formatConfig, cfg } from "../server/config";
6
+ import { syncTenants } from "../database/tenant";
7
+ import { syncCollections } from "../database/collection";
8
+ import { syncWorkflows } from "../lib/workflow";
9
+ import { startReplication, stopReplication, replicateTenant } from "../database/replication";
10
+ import { syncAgents } from "../lib/agents";
11
+ import { MongoAgentMemory } from "../lib/agentMemory";
12
+ import { AGENT_THREADS } from "./fixtures/replication-meta/agents/chat.agent";
13
+ import type { ReplicationMetaName } from "../types/replication";
14
+
15
+ const DIR = "packages/core/tests/fixtures/replication-meta";
16
+
17
+ /** Everything replicated: declared collection + the six framework collections */
18
+ const ALL_T = "rm-default";
19
+ const ALL_T_DB = "mongodb://localhost:27017/_RM_DEFAULT_SRC";
20
+ const ALL_T_DEST = "mongodb://localhost:27017/_RM_DEFAULT_DEST";
21
+
22
+ /** Nothing but the declared collection */
23
+ const OFF_T = "rm-alloff";
24
+ const OFF_T_DB = "mongodb://localhost:27017/_RM_ALLOFF_SRC";
25
+ const OFF_T_DEST = "mongodb://localhost:27017/_RM_ALLOFF_DEST";
26
+
27
+ let rest: InstanceType<typeof useRest>;
28
+ let dest: MongoClient;
29
+ let destOff: MongoClient;
30
+
31
+ const target = (slug: string) => dest.db().collection(slug);
32
+ const targetOff = (slug: string) => destOff.db().collection(slug);
33
+
34
+ beforeAll(async () => {
35
+ formatConfig({
36
+ server: { port: 4000 },
37
+ tenants: [
38
+ {
39
+ id: ALL_T,
40
+ dir: DIR,
41
+ database: { uri: ALL_T_DB },
42
+ // Opt-in retention, so the destination's copy is pruned the same way
43
+ audit: { retention: "30d" },
44
+ replication: {
45
+ // These cases exercise the scan engine (meta collections, exclude)
46
+ mode: "scan",
47
+ runOnBoot: false,
48
+ schedule: { interval: "1h" },
49
+ destinations: [{ id: "backup", uri: ALL_T_DEST }],
50
+ },
51
+ },
52
+ {
53
+ id: OFF_T,
54
+ dir: DIR,
55
+ database: { uri: OFF_T_DB },
56
+ replication: {
57
+ mode: "scan",
58
+ runOnBoot: false,
59
+ schedule: { interval: "1h" },
60
+ exclude: ["audit", "workflows", "locks", "replication", "vars", "memory"],
61
+ destinations: [{ id: "backup", uri: OFF_T_DEST }],
62
+ },
63
+ },
64
+ ],
65
+ });
66
+
67
+ await syncTenants();
68
+ await syncCollections();
69
+ await syncWorkflows();
70
+ await syncAgents(); // publishes the agent memory collections (`cfg.agentMemories`)
71
+ await startReplication();
72
+
73
+ rest = new useRest({ tenant_id: ALL_T });
74
+ dest = new MongoClient(ALL_T_DEST, { serverSelectionTimeoutMS: 3000 });
75
+ destOff = new MongoClient(OFF_T_DEST, { serverSelectionTimeoutMS: 3000 });
76
+ await dest.connect();
77
+ await destOff.connect();
78
+ });
79
+
80
+ afterAll(async () => {
81
+ await stopReplication();
82
+ for (const uri of [ALL_T_DB, ALL_T_DEST, OFF_T_DB, OFF_T_DEST]) {
83
+ try {
84
+ const client = new MongoClient(uri);
85
+ await client.connect();
86
+ await client.db().dropDatabase();
87
+ await client.close();
88
+ } catch (_) {}
89
+ }
90
+ cfg.agents = [];
91
+ cfg.agentMemories = [];
92
+ try { await dest.close(); } catch (_) {}
93
+ try { await destOff.close(); } catch (_) {}
94
+ });
95
+
96
+ describe("replication — framework collections", () => {
97
+ it("replicates them by default (audit, workflows, locks, replication, vars, memory)", async () => {
98
+ const marker = `meta-${crypto.randomUUID()}`;
99
+
100
+ await rest.audit.addActivities([{
101
+ internal: true,
102
+ trace: { id: crypto.randomUUID() },
103
+ meta: {},
104
+ operation: {
105
+ tenant: ALL_T, action: marker, collection: "orders",
106
+ status: "success", input: null, result: null, error: null, duration: 0, transaction: false,
107
+ },
108
+ ts: new Date(),
109
+ } as any]);
110
+ await rest.workflow.run("run", { marker });
111
+ await rest.vars.set("config", marker, "META-1");
112
+ await rest.lock(`meta-${marker}`);
113
+ await rest.insertOne("orders", { title: marker });
114
+ // An agent conversation — the collection this tenant named for its memory
115
+ await new MongoAgentMemory({ collection: AGENT_THREADS }).save(
116
+ "chat-1",
117
+ [{ role: "user", content: marker }] as any,
118
+ { rest, tenant: ALL_T, resourceId: "u1" },
119
+ );
120
+
121
+ // A first run picks the state document it writes, hence the second one
122
+ await replicateTenant(ALL_T);
123
+ await replicateTenant(ALL_T);
124
+
125
+ expect(await target("_audit_").findOne({ "operation.action": marker })).toBeDefined();
126
+ expect(await target("_workflows_").findOne({ workflowId: "run" })).toBeDefined();
127
+ expect(await target("_vars_").findOne({ ns: "config", key: marker })).toBeDefined();
128
+ expect(await target("_locks_").findOne({ name: `meta-${marker}` })).toBeDefined();
129
+ expect(await target("_replication_").findOne({ type: "state", tenant: ALL_T })).toBeDefined();
130
+ expect(await target(AGENT_THREADS).findOne({ _id: "u1:chat-1" as any })).toBeDefined();
131
+ expect(await target("orders").findOne({ title: marker })).toBeDefined();
132
+
133
+ await rest.unlock(`meta-${marker}`);
134
+ }, 30_000);
135
+
136
+ it("propagates a cleared thread to the destination (tombstone)", async () => {
137
+ // The thread replicated by the previous test is deleted at the source: no
138
+ // date cursor can see a deletion, so the store leaves a tombstone.
139
+ await new MongoAgentMemory({ collection: AGENT_THREADS }).clear("chat-1", { rest, tenant: ALL_T, resourceId: "u1" });
140
+
141
+ await replicateTenant(ALL_T);
142
+
143
+ expect(await target(AGENT_THREADS).findOne({ _id: "u1:chat-1" as any })).toBeNull();
144
+ // The tombstone is consumed and purged
145
+ expect(await rest.db.collection("_replication_").countDocuments({ type: "delete", collection: AGENT_THREADS })).toBe(0);
146
+ }, 30_000);
147
+
148
+ it("uses each collection's own date key (audit is read on `ts`)", async () => {
149
+ const state: any = await rest.db.collection("_replication_")
150
+ .findOne({ type: "state", tenant: ALL_T, collection: "_audit_" });
151
+ expect(state).toBeDefined();
152
+ expect(state.key).toBe("ts");
153
+ // The state says which process is doing the work (`reusePort` / forked workers)
154
+ expect(state.pid).toBe(process.pid);
155
+ expect(state.hostname).toBe(os.hostname());
156
+ });
157
+
158
+ it("mirrors the audit retention on the destination", async () => {
159
+ const ttl: any = (await target("_audit_").listIndexes().toArray())
160
+ .find((i: any) => i.name === "_audit_ttl_");
161
+ expect(ttl).toBeDefined();
162
+ expect(ttl.expireAfterSeconds).toBe(30 * 86400);
163
+ }, 30_000);
164
+
165
+ it("leaves out only what `exclude` names", async () => {
166
+ const tenant: any = cfg.tenants?.find((t) => t.id === ALL_T);
167
+ const marker = `noaudit-${crypto.randomUUID()}`;
168
+ tenant.replication.exclude = ["audit", "memory"];
169
+
170
+ await rest.audit.addActivities([{
171
+ internal: true, trace: { id: crypto.randomUUID() }, meta: {},
172
+ operation: {
173
+ tenant: ALL_T, action: marker, collection: "orders",
174
+ status: "success", input: null, result: null, error: null, duration: 0, transaction: false,
175
+ },
176
+ ts: new Date(),
177
+ } as any]);
178
+ await rest.vars.set("config", marker, "still-replicated");
179
+ await new MongoAgentMemory({ collection: AGENT_THREADS }).save(
180
+ "chat-2",
181
+ [{ role: "user", content: marker }] as any,
182
+ { rest, tenant: ALL_T, resourceId: "u1" },
183
+ );
184
+
185
+ await replicateTenant(ALL_T);
186
+
187
+ expect(await target("_audit_").findOne({ "operation.action": marker })).toBeNull(); // excluded
188
+ expect(await target(AGENT_THREADS).findOne({ _id: "u1:chat-2" as any })).toBeNull(); // excluded
189
+ expect(await target("_vars_").findOne({ ns: "config", key: marker })).toBeDefined(); // still there
190
+
191
+ delete tenant.replication.exclude;
192
+ }, 30_000);
193
+
194
+ it("replicates nothing from the framework when every name is excluded", async () => {
195
+ const offRest = new useRest({ tenant_id: OFF_T });
196
+ const marker = `alloff-${crypto.randomUUID()}`;
197
+
198
+ await offRest.audit.addActivities([{
199
+ internal: true, trace: { id: crypto.randomUUID() }, meta: {},
200
+ operation: {
201
+ tenant: OFF_T, action: marker, collection: "orders",
202
+ status: "success", input: null, result: null, error: null, duration: 0, transaction: false,
203
+ },
204
+ ts: new Date(),
205
+ } as any]);
206
+ await offRest.vars.set("config", marker, "not-replicated");
207
+ await offRest.insertOne("orders", { title: marker });
208
+ await new MongoAgentMemory({ collection: AGENT_THREADS }).save(
209
+ "chat-3",
210
+ [{ role: "user", content: marker }] as any,
211
+ { rest: offRest, tenant: OFF_T, resourceId: "u1" },
212
+ );
213
+
214
+ await replicateTenant(OFF_T);
215
+ await replicateTenant(OFF_T);
216
+
217
+ // The declared collection still replicates…
218
+ expect(await targetOff("orders").findOne({ title: marker })).toBeDefined();
219
+ // …but the framework collections do not exist on that destination
220
+ expect(await targetOff("_audit_").countDocuments({})).toBe(0);
221
+ expect(await targetOff("_vars_").countDocuments({})).toBe(0);
222
+ expect(await targetOff("_workflows_").countDocuments({})).toBe(0);
223
+ expect(await targetOff(AGENT_THREADS).countDocuments({})).toBe(0);
224
+ }, 30_000);
225
+
226
+ it("propagates a delete on a collection whose key is a number", async () => {
227
+ const doc: any = await rest.insertOne("counters", { seq: 100, title: "numeric-key" });
228
+ await replicateTenant(ALL_T);
229
+ expect(await target("counters").findOne({ title: "numeric-key" })).toBeDefined();
230
+
231
+ await rest.deleteOne("counters", doc._id);
232
+ await replicateTenant(ALL_T);
233
+ // The tombstone guard compares the key to a date by default: a numeric key
234
+ // used to silently skip the delete
235
+ expect(await target("counters").findOne({ title: "numeric-key" })).toBeNull();
236
+ }, 30_000);
237
+
238
+ it("keeps the `exclude` list typed", () => {
239
+ const names: ReplicationMetaName[] = ["audit", "workflows", "locks", "replication", "vars", "memory"];
240
+ expect(names.length).toBe(6);
241
+
242
+ // @ts-expect-error — a declared collection slug is not a framework collection
243
+ const wrong: ReplicationMetaName[] = ["orders"];
244
+ expect(wrong.length).toBe(1);
245
+ });
246
+ });
@@ -26,6 +26,8 @@ beforeAll(async () => {
26
26
  dir: "src",
27
27
  database: { uri: SRC_DB },
28
28
  replication: {
29
+ // These cases exercise the scan engine (cursor, tombstones, initial sync)
30
+ mode: "scan",
29
31
  runOnBoot: false,
30
32
  schedule: { interval: "1h" },
31
33
  destinations: [{ id: DEST_ID, uri: DEST_DB }],