@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.
- package/database/audit.ts +9 -59
- package/database/collection.ts +3 -1
- package/database/deleteLog.ts +67 -3
- package/database/file.ts +4 -3
- package/database/mongodbadapter.ts +103 -42
- package/database/replication.ts +724 -58
- package/database/ttl.ts +68 -0
- package/database/workflow.ts +466 -240
- package/index.ts +6 -0
- package/lib/agent.ts +1567 -0
- package/lib/agentMemory.ts +689 -0
- package/lib/agents.ts +180 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/auditResult.ts +13 -0
- package/lib/define.ts +43 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +2 -1
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +7 -1
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/mcpClient.ts +128 -0
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +970 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/skills.ts +481 -0
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +472 -0
- package/server/api.ts +80 -65
- package/server/boot.ts +11 -0
- package/server/hono.ts +8 -3
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-provider.test.ts +288 -0
- package/tests/agent-runtime.test.ts +369 -0
- package/tests/agent-skills.test.ts +177 -0
- package/tests/agent-summarize.test.ts +124 -0
- package/tests/agent-working-memory.test.ts +404 -0
- package/tests/agent.test.ts +1241 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api-private-fields.test.ts +196 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-operations.test.ts +1 -1
- package/tests/audit-redact.test.ts +29 -3
- package/tests/audit-run-action.test.ts +1 -1
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +2 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- package/tests/fixtures/repl-only/app.ts +3 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
- package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
- package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
- package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
- package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
- package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
- package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
- package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
- package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
- package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
- package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
- package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
- package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
- package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +4 -2
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +246 -0
- package/tests/replication.test.ts +2 -0
- package/tests/upload.test.ts +44 -12
- package/tests/vars-api.test.ts +1 -1
- package/tests/vars-replication.test.ts +5 -2
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +1067 -0
- package/types/config.d.ts +16 -0
- package/types/file.d.ts +7 -7
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +6 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +70 -4
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +13 -0
- package/types/vars.d.ts +2 -2
- package/types/websocket.d.ts +6 -0
- 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 }],
|