@anteros/core 0.0.40 → 0.0.42
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 +143 -0
- package/database/collection.ts +6 -4
- package/database/deleteLog.ts +98 -0
- package/database/file.ts +7 -6
- package/database/mongodbadapter.ts +420 -185
- package/database/replication.ts +858 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/ttl.ts +68 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +466 -240
- package/index.ts +16 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +133 -0
- package/lib/define.ts +62 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +66 -12
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +147 -0
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/redact.ts +117 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- 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 +451 -0
- package/server/api.ts +121 -95
- package/server/boot.ts +196 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +9 -4
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +259 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/db-cache.test.ts +7 -7
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +98 -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/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -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/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
- 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/lifecycle.test.ts +96 -0
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-meta.test.ts +243 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +141 -8
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +96 -0
- package/tests/vars.test.ts +216 -0
- 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 +837 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +80 -0
- package/types/file.d.ts +11 -8
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +77 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +228 -0
- 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 +36 -0
- package/types/vars.d.ts +147 -0
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
- package/utils/func.ts +67 -52
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Test fixture — `b` always fails, `a` must be compensated (once, never twice).
|
|
2
|
+
// Also proves the step ctx `rest` is a real tenant client.
|
|
3
|
+
const state = ((globalThis as any).__wfEngine ??= { log: [], attempts: {} });
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
_isWorkflow_: true,
|
|
7
|
+
id: "failing",
|
|
8
|
+
name: "Failing",
|
|
9
|
+
version: 1,
|
|
10
|
+
steps: [
|
|
11
|
+
{
|
|
12
|
+
id: "a",
|
|
13
|
+
exec: async ({ rest }: any) => {
|
|
14
|
+
state.log.push(`failing:a:rest=${typeof rest?.find === "function" ? "client" : "engine"}`);
|
|
15
|
+
// A sub-workflow, through the client handed to the step
|
|
16
|
+
await rest.workflow.run("simple", { echo: "sub" });
|
|
17
|
+
state.log.push("failing:a:sub-workflow-done");
|
|
18
|
+
return { a: 1 };
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{ id: "b", exec: async () => { state.log.push("failing:b"); throw new Error("boom-b"); } },
|
|
22
|
+
{ id: "c", exec: async () => { state.log.push("failing:c"); return { c: 1 }; } },
|
|
23
|
+
],
|
|
24
|
+
compensations: [
|
|
25
|
+
{
|
|
26
|
+
id: "undo-a",
|
|
27
|
+
depend: ["a"],
|
|
28
|
+
exec: async () => {
|
|
29
|
+
state.attempts["undo-a"] = (state.attempts["undo-a"] ?? 0) + 1;
|
|
30
|
+
state.log.push(`failing:undo-a:${state.attempts["undo-a"]}`);
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Test fixture — a step that fails until `data.failUntil` attempts are reached,
|
|
2
|
+
// with retries. Used to prove a transient failure does not roll the saga back.
|
|
3
|
+
const state = ((globalThis as any).__wfEngine ??= { log: [], attempts: {} });
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
_isWorkflow_: true,
|
|
7
|
+
id: "flaky",
|
|
8
|
+
name: "Flaky",
|
|
9
|
+
version: 1,
|
|
10
|
+
steps: [
|
|
11
|
+
{
|
|
12
|
+
id: "unstable",
|
|
13
|
+
retries: 2,
|
|
14
|
+
backoffMs: 1,
|
|
15
|
+
exec: async ({ data }: any) => {
|
|
16
|
+
const n = (state.attempts.unstable = (state.attempts.unstable ?? 0) + 1);
|
|
17
|
+
state.log.push(`flaky:attempt:${n}`);
|
|
18
|
+
if (n <= (data?.failUntil ?? 0)) throw new Error(`transient failure ${n}`);
|
|
19
|
+
return { attempts: n };
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Test fixture — a step that outlives its `timeout` (150ms vs 1s by default).
|
|
2
|
+
export default {
|
|
3
|
+
_isWorkflow_: true,
|
|
4
|
+
id: "hang",
|
|
5
|
+
name: "Hang",
|
|
6
|
+
version: 1,
|
|
7
|
+
steps: [
|
|
8
|
+
{
|
|
9
|
+
id: "hang",
|
|
10
|
+
timeout: "150ms",
|
|
11
|
+
exec: async ({ data }: any) => {
|
|
12
|
+
await new Promise((resolve) => setTimeout(resolve, data?.ms ?? 1000));
|
|
13
|
+
return { done: true };
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Test fixture — one step, always succeeds. Shared in-process state:
|
|
2
|
+
// `globalThis.__wfEngine = { log: string[], attempts: Record<string, number> }`
|
|
3
|
+
const state = ((globalThis as any).__wfEngine ??= { log: [], attempts: {} });
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
_isWorkflow_: true,
|
|
7
|
+
id: "simple",
|
|
8
|
+
name: "Simple",
|
|
9
|
+
version: 1,
|
|
10
|
+
steps: [
|
|
11
|
+
{
|
|
12
|
+
id: "one",
|
|
13
|
+
exec: async ({ data }: any) => {
|
|
14
|
+
state.log.push("simple:one");
|
|
15
|
+
return { echo: data?.echo ?? null };
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Test fixture — three steps, the middle one slow: lets a test pause or cancel
|
|
2
|
+
// the run while it is executing (and check that the last step never runs).
|
|
3
|
+
const state = ((globalThis as any).__wfEngine ??= { log: [], attempts: {} });
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
_isWorkflow_: true,
|
|
7
|
+
id: "slow",
|
|
8
|
+
name: "Slow",
|
|
9
|
+
version: 1,
|
|
10
|
+
steps: [
|
|
11
|
+
{ id: "fast", exec: async () => { state.log.push("slow:fast"); return { fast: true }; } },
|
|
12
|
+
{
|
|
13
|
+
id: "slow",
|
|
14
|
+
exec: async ({ data }: any) => {
|
|
15
|
+
state.log.push("slow:slow:start");
|
|
16
|
+
await new Promise((resolve) => setTimeout(resolve, data?.ms ?? 300));
|
|
17
|
+
state.log.push("slow:slow:end");
|
|
18
|
+
return { slow: true };
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{ id: "last", exec: async () => { state.log.push("slow:last"); return { last: true }; } },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Test fixture — used to check the resume version guard: the test mutates
|
|
2
|
+
// `version` on the registered definition after a run has started.
|
|
3
|
+
const state = ((globalThis as any).__wfEngine ??= { log: [], attempts: {} });
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
_isWorkflow_: true,
|
|
7
|
+
id: "versioned",
|
|
8
|
+
name: "Versioned",
|
|
9
|
+
version: 1,
|
|
10
|
+
steps: [
|
|
11
|
+
{
|
|
12
|
+
id: "v",
|
|
13
|
+
exec: async () => {
|
|
14
|
+
state.log.push("versioned:v");
|
|
15
|
+
return { ok: true };
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Test fixture — a second valid workflow, with a different `version` so tests
|
|
2
|
+
// can assert on a definition that is not the duplicated `order`.
|
|
3
|
+
export default {
|
|
4
|
+
_isWorkflow_: true,
|
|
5
|
+
id: "invoice",
|
|
6
|
+
name: "Invoices",
|
|
7
|
+
version: 3,
|
|
8
|
+
context: {
|
|
9
|
+
companyId: { type: "string", index: true }, // → index context.companyId_1
|
|
10
|
+
processedAt: { type: "date" }, // validated + coerced to a Date
|
|
11
|
+
},
|
|
12
|
+
// Beyond `context.index`: compound, unique, sparse, named
|
|
13
|
+
indexes: [
|
|
14
|
+
{ key: { "context.companyId": 1, status: 1 } },
|
|
15
|
+
{ key: { "context.batchId": 1 }, unique: true, sparse: true, name: "uniq_batch" },
|
|
16
|
+
],
|
|
17
|
+
steps: [
|
|
18
|
+
{ id: "generate", exec: async ({ data }: any) => ({ generated: data?.ref ?? null }) },
|
|
19
|
+
{ id: "send", exec: async () => ({ sent: true }) },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Test fixture — same `id` as order.workflow.ts: the duplicate must be reported
|
|
2
|
+
// and only one definition kept (never two entries, never a crash). The context
|
|
3
|
+
// declaration is identical on purpose: whichever file wins, the indexes exist.
|
|
4
|
+
export default {
|
|
5
|
+
_isWorkflow_: true,
|
|
6
|
+
id: "order",
|
|
7
|
+
name: "Orders (duplicate definition)",
|
|
8
|
+
context: {
|
|
9
|
+
customerId: { type: "string", index: true },
|
|
10
|
+
total: { type: "number", index: -1 },
|
|
11
|
+
},
|
|
12
|
+
steps: [{ id: "charge", exec: async () => ({ charged: false }) }],
|
|
13
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Test fixture — a valid, enabled workflow with declared context fields.
|
|
2
|
+
export default {
|
|
3
|
+
_isWorkflow_: true,
|
|
4
|
+
enabled: true,
|
|
5
|
+
id: "order",
|
|
6
|
+
name: "Orders",
|
|
7
|
+
version: 2,
|
|
8
|
+
context: {
|
|
9
|
+
customerId: { type: "string", index: true }, // → index context.customerId_1
|
|
10
|
+
total: { type: "number", index: -1 }, // → index context.total_-1
|
|
11
|
+
note: { type: "string" }, // not indexed
|
|
12
|
+
},
|
|
13
|
+
steps: [
|
|
14
|
+
{ id: "charge", exec: async ({ data }: any) => ({ charged: data?.ref ?? null }) },
|
|
15
|
+
{ id: "notify", exec: async () => ({ notified: true }) },
|
|
16
|
+
],
|
|
17
|
+
};
|
package/tests/hono.test.ts
CHANGED
|
@@ -182,16 +182,19 @@ describe("rate limiting (in-memory)", () => {
|
|
|
182
182
|
it("login limiter uses a distinct counter (not blocked by global)", async () => {
|
|
183
183
|
cfg.server.rateLimit = { enabled: true, windowMs: 60_000, max: 100, login: { max: 2 } };
|
|
184
184
|
const app = createApp();
|
|
185
|
-
app.post("/api/x/login", (c) => c.json({ ok: true }));
|
|
186
185
|
app.get("/echo", (c) => c.json({ ok: true }));
|
|
187
186
|
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
188
187
|
const url = `${base(server)}`;
|
|
189
188
|
|
|
189
|
+
// The login limiter has its own bucket: the third call is refused even though
|
|
190
|
+
// the global limit (100) is nowhere near. What the framework answers before
|
|
191
|
+
// that (unknown tenant here) is not the point.
|
|
190
192
|
const statuses: number[] = [];
|
|
191
193
|
for (let i = 0; i < 3; i++) {
|
|
192
|
-
statuses.push((await fetch(`${url}/api/x/login`, { method: "POST" })).status);
|
|
194
|
+
statuses.push((await fetch(`${url}/api/x/collections/users/login`, { method: "POST" })).status);
|
|
193
195
|
}
|
|
194
|
-
expect(statuses
|
|
196
|
+
expect(statuses.slice(0, 2).some((status) => status === 429)).toBe(false);
|
|
197
|
+
expect(statuses[2]).toBe(429);
|
|
195
198
|
|
|
196
199
|
// same IP on a normal route → still allowed (distinct key)
|
|
197
200
|
expect((await fetch(`${url}/echo`)).status).toBe(200);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { formatConfig } from "../server/config";
|
|
3
|
+
import { loadLifecycles, runBeforeBoot, runAfterBoot, runOnDestroy } from "../lib/lifecycle";
|
|
4
|
+
import { define } from "../lib/define";
|
|
5
|
+
|
|
6
|
+
const DIRS: Record<string, string> = {
|
|
7
|
+
normal: "packages/core/tests/fixtures/lc-normal",
|
|
8
|
+
thrower: "packages/core/tests/fixtures/lc-throw",
|
|
9
|
+
hang: "packages/core/tests/fixtures/lc-hang",
|
|
10
|
+
disabled: "packages/core/tests/fixtures/lc-disabled",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Configure cfg with a single tenant pointing at the given fixture dir. */
|
|
14
|
+
function useFixture(id: string) {
|
|
15
|
+
formatConfig({
|
|
16
|
+
server: { port: 4000 },
|
|
17
|
+
tenants: [{ id, dir: DIRS[id]!, database: { uri: "mongodb://localhost:27017/_LC_TEST" } }],
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const callsOf = (key: string): string[] => ((globalThis as any)[key] ??= []);
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
(globalThis as any).__lcNormal = [];
|
|
25
|
+
(globalThis as any).__lcHang = [];
|
|
26
|
+
(globalThis as any).__lcDisabled = [];
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("define.Lifecycle", () => {
|
|
30
|
+
it("marks the config and defaults enabled to true", () => {
|
|
31
|
+
const lc = define.Lifecycle({ beforeBoot: () => {} });
|
|
32
|
+
expect(lc._isLifecycle_).toBe(true);
|
|
33
|
+
expect(lc.enabled).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps an explicit enabled: false", () => {
|
|
37
|
+
const lc = define.Lifecycle({ enabled: false });
|
|
38
|
+
expect(lc.enabled).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("lifecycle hooks", () => {
|
|
43
|
+
it("runs beforeBoot, afterBoot and onDestroy in order", async () => {
|
|
44
|
+
useFixture("normal");
|
|
45
|
+
await loadLifecycles();
|
|
46
|
+
|
|
47
|
+
await runBeforeBoot();
|
|
48
|
+
await runAfterBoot({ port: 4000 } as any, {} as any);
|
|
49
|
+
await runOnDestroy("SIGTERM");
|
|
50
|
+
|
|
51
|
+
expect(callsOf("__lcNormal")).toEqual(["beforeBoot", "afterBoot", "onDestroy:SIGTERM"]);
|
|
52
|
+
// `replication` is tenant-bound in the hook context (like `rest`)
|
|
53
|
+
expect((globalThis as any).__lcReplicationApi).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("aborts the boot when beforeBoot throws (fail-fast)", async () => {
|
|
57
|
+
useFixture("thrower");
|
|
58
|
+
await loadLifecycles();
|
|
59
|
+
|
|
60
|
+
await expect(runBeforeBoot()).rejects.toThrow(/boom/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("bounds onDestroy with destroyTimeout instead of hanging", async () => {
|
|
64
|
+
useFixture("hang");
|
|
65
|
+
await loadLifecycles();
|
|
66
|
+
|
|
67
|
+
const started = Date.now();
|
|
68
|
+
await runOnDestroy("SIGINT"); // must resolve despite a hook that never resolves
|
|
69
|
+
const elapsed = Date.now() - started;
|
|
70
|
+
|
|
71
|
+
expect(callsOf("__lcHang")).toEqual(["onDestroy:start"]);
|
|
72
|
+
expect(elapsed).toBeGreaterThanOrEqual(100);
|
|
73
|
+
expect(elapsed).toBeLessThan(2000);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips a disabled lifecycle", async () => {
|
|
77
|
+
useFixture("disabled");
|
|
78
|
+
await loadLifecycles();
|
|
79
|
+
|
|
80
|
+
await runBeforeBoot();
|
|
81
|
+
await runAfterBoot({} as any, {} as any);
|
|
82
|
+
await runOnDestroy("SIGINT");
|
|
83
|
+
|
|
84
|
+
expect(callsOf("__lcDisabled")).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("tolerates a tenant without a lifecycle.ts file", async () => {
|
|
88
|
+
formatConfig({
|
|
89
|
+
server: { port: 4000 },
|
|
90
|
+
tenants: [{ id: "nofile", dir: "packages/core/tests/fixtures/does-not-exist", database: { uri: "mongodb://localhost:27017/_LC_TEST" } }],
|
|
91
|
+
});
|
|
92
|
+
await loadLifecycles();
|
|
93
|
+
await expect(runBeforeBoot()).resolves.toBeUndefined();
|
|
94
|
+
await expect(runOnDestroy("SIGTERM")).resolves.toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { useRest } from "../database/rest";
|
|
4
|
+
import { formatConfig } from "../server/config";
|
|
5
|
+
import { syncTenants } from "../database/tenant";
|
|
6
|
+
|
|
7
|
+
const TENANT = "locks-test";
|
|
8
|
+
const DB = "mongodb://localhost:27017/_LOCKS_TEST";
|
|
9
|
+
|
|
10
|
+
let rest: InstanceType<typeof useRest>;
|
|
11
|
+
|
|
12
|
+
/** Write a lock document as if another process had taken it */
|
|
13
|
+
async function seedLock(name: string, opts: { pid?: number; hostname?: string; expiresAt: number }) {
|
|
14
|
+
await rest.db.collection("_locks_").insertOne({
|
|
15
|
+
_id: `${TENANT}:${name}` as any,
|
|
16
|
+
tid: TENANT,
|
|
17
|
+
name,
|
|
18
|
+
acquiredAt: Date.now() - 1000,
|
|
19
|
+
expiresAt: opts.expiresAt,
|
|
20
|
+
...(opts.pid !== undefined ? { pid: opts.pid } : {}),
|
|
21
|
+
...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
|
|
22
|
+
} as any);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lockDoc = (name: string) => rest.db.collection("_locks_").findOne({ _id: `${TENANT}:${name}` as any });
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
formatConfig({
|
|
29
|
+
server: { port: 4000 },
|
|
30
|
+
tenants: [{ id: TENANT, dir: "src", database: { uri: DB } }],
|
|
31
|
+
});
|
|
32
|
+
await syncTenants();
|
|
33
|
+
rest = new useRest({ tenant_id: TENANT, internal: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
try { await rest.db.collection("_locks_").drop(); } catch (_) {}
|
|
38
|
+
try { await rest.db.collection("_workflows_").drop(); } catch (_) {}
|
|
39
|
+
try { await rest.db.collection("_replication_").drop(); } catch (_) {}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("distributed lock", () => {
|
|
43
|
+
it("records the owning pid and hostname", async () => {
|
|
44
|
+
await rest.lock("l-pid");
|
|
45
|
+
const doc: any = await lockDoc("l-pid");
|
|
46
|
+
expect(doc.pid).toBe(process.pid);
|
|
47
|
+
expect(doc.hostname).toBe(os.hostname());
|
|
48
|
+
await rest.unlock("l-pid");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("refuses a second acquisition from the same process (its own pid is not stale)", async () => {
|
|
52
|
+
await rest.lock("l-self");
|
|
53
|
+
await expect(rest.lock("l-self")).rejects.toThrow(/already held/);
|
|
54
|
+
await rest.unlock("l-self");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("takes over a lock whose local process is gone, without waiting for the TTL", async () => {
|
|
58
|
+
// A process that has exited: its pid no longer exists
|
|
59
|
+
const dead = Bun.spawn(["true"]);
|
|
60
|
+
const deadPid = dead.pid;
|
|
61
|
+
await dead.exited;
|
|
62
|
+
|
|
63
|
+
await seedLock("l-dead", { pid: deadPid, hostname: os.hostname(), expiresAt: Date.now() + 60_000 });
|
|
64
|
+
|
|
65
|
+
await rest.lock("l-dead");
|
|
66
|
+
const doc: any = await lockDoc("l-dead");
|
|
67
|
+
expect(doc.pid).toBe(process.pid);
|
|
68
|
+
await rest.unlock("l-dead");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("waits for a lock whose local process is still alive", async () => {
|
|
72
|
+
const alive = Bun.spawn(["sleep", "5"]);
|
|
73
|
+
try {
|
|
74
|
+
await seedLock("l-alive", { pid: alive.pid, hostname: os.hostname(), expiresAt: Date.now() + 60_000 });
|
|
75
|
+
|
|
76
|
+
await expect(rest.lock("l-alive")).rejects.toThrow(/already held/);
|
|
77
|
+
|
|
78
|
+
// …and the lock is taken over as soon as that process is gone
|
|
79
|
+
alive.kill();
|
|
80
|
+
await alive.exited;
|
|
81
|
+
|
|
82
|
+
await rest.lock("l-alive");
|
|
83
|
+
const doc: any = await lockDoc("l-alive");
|
|
84
|
+
expect(doc.pid).toBe(process.pid);
|
|
85
|
+
await rest.unlock("l-alive");
|
|
86
|
+
} finally {
|
|
87
|
+
try { alive.kill(); } catch (_) {}
|
|
88
|
+
}
|
|
89
|
+
}, 20_000);
|
|
90
|
+
|
|
91
|
+
it("takes over an expired lock", async () => {
|
|
92
|
+
await seedLock("l-expired", { pid: 999_999, hostname: "another-host", expiresAt: Date.now() - 1000 });
|
|
93
|
+
|
|
94
|
+
await rest.lock("l-expired");
|
|
95
|
+
const doc: any = await lockDoc("l-expired");
|
|
96
|
+
expect(doc.pid).toBe(process.pid);
|
|
97
|
+
await rest.unlock("l-expired");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("never releases a lock owned by another process", async () => {
|
|
101
|
+
await seedLock("l-other", { pid: 4242, hostname: "another-host", expiresAt: Date.now() + 60_000 });
|
|
102
|
+
|
|
103
|
+
await rest.unlock("l-other"); // not ours
|
|
104
|
+
expect(await lockDoc("l-other")).not.toBeNull();
|
|
105
|
+
|
|
106
|
+
// Ours is released
|
|
107
|
+
await rest.lock("l-ours");
|
|
108
|
+
await rest.unlock("l-ours");
|
|
109
|
+
expect(await lockDoc("l-ours")).toBeNull();
|
|
110
|
+
|
|
111
|
+
await rest.db.collection("_locks_").deleteOne({ name: "l-other" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("only takes over a remote lock when it expired (a foreign pid cannot be probed)", async () => {
|
|
115
|
+
await seedLock("l-remote", { pid: 1, hostname: "some-other-host", expiresAt: Date.now() + 60_000 });
|
|
116
|
+
|
|
117
|
+
await expect(rest.lock("l-remote")).rejects.toThrow(/already held/);
|
|
118
|
+
await rest.db.collection("_locks_").deleteOne({ name: "l-remote" });
|
|
119
|
+
});
|
|
120
|
+
});
|
package/tests/mcp.test.ts
CHANGED
|
@@ -62,7 +62,7 @@ describe("syncMcpTools", () => {
|
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
describe("MCP protocol (GET/POST /
|
|
65
|
+
describe("MCP protocol (GET/POST /api/:tenant_id/mcp)", () => {
|
|
66
66
|
it("serves tools/list and tools/call over JSON-RPC", async () => {
|
|
67
67
|
cfg.tenants = [{ id: TENANT, dir: TENANT_DIR, database: { uri: "mongodb://localhost:27017/none" } }];
|
|
68
68
|
await syncMcpTools();
|
|
@@ -72,7 +72,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
|
72
72
|
const url = server.url.href.replace(/\/$/, "");
|
|
73
73
|
|
|
74
74
|
// 1. tools/list
|
|
75
|
-
const listRes = await fetch(`${url}/
|
|
75
|
+
const listRes = await fetch(`${url}/api/${TENANT}/mcp`, {
|
|
76
76
|
method: "POST",
|
|
77
77
|
headers: { "Content-Type": "application/json" },
|
|
78
78
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
|
|
@@ -84,7 +84,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
|
84
84
|
expect(tools.some((t: any) => t.name === "badge")).toBe(true);
|
|
85
85
|
|
|
86
86
|
// 1b. resources/list — static resources
|
|
87
|
-
const resListRes = await fetch(`${url}/
|
|
87
|
+
const resListRes = await fetch(`${url}/api/${TENANT}/mcp`, {
|
|
88
88
|
method: "POST",
|
|
89
89
|
headers: { "Content-Type": "application/json" },
|
|
90
90
|
body: JSON.stringify({ jsonrpc: "2.0", id: 9, method: "resources/list", params: {} }),
|
|
@@ -94,7 +94,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
|
94
94
|
expect(resources.some((r: any) => r.uri === "orders://summary")).toBe(true);
|
|
95
95
|
|
|
96
96
|
// 1c. resources/templates/list
|
|
97
|
-
const tmplRes = await fetch(`${url}/
|
|
97
|
+
const tmplRes = await fetch(`${url}/api/${TENANT}/mcp`, {
|
|
98
98
|
method: "POST",
|
|
99
99
|
headers: { "Content-Type": "application/json" },
|
|
100
100
|
body: JSON.stringify({ jsonrpc: "2.0", id: 10, method: "resources/templates/list", params: {} }),
|
|
@@ -104,7 +104,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
|
104
104
|
expect(templates.some((t: any) => t.uriTemplate === "users://{id}")).toBe(true);
|
|
105
105
|
|
|
106
106
|
// 1d. resources/read — static URI
|
|
107
|
-
const readRes = await fetch(`${url}/
|
|
107
|
+
const readRes = await fetch(`${url}/api/${TENANT}/mcp`, {
|
|
108
108
|
method: "POST",
|
|
109
109
|
headers: { "Content-Type": "application/json" },
|
|
110
110
|
body: JSON.stringify({ jsonrpc: "2.0", id: 11, method: "resources/read", params: { uri: "orders://summary" } }),
|
|
@@ -114,7 +114,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
|
114
114
|
expect(readText).toContain("\"total\":42");
|
|
115
115
|
|
|
116
116
|
// 1e. resources/read — template URI with params
|
|
117
|
-
const readTplRes = await fetch(`${url}/
|
|
117
|
+
const readTplRes = await fetch(`${url}/api/${TENANT}/mcp`, {
|
|
118
118
|
method: "POST",
|
|
119
119
|
headers: { "Content-Type": "application/json" },
|
|
120
120
|
body: JSON.stringify({ jsonrpc: "2.0", id: 12, method: "resources/read", params: { uri: "users://abc123" } }),
|
|
@@ -123,7 +123,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
|
123
123
|
expect(readTpl?.result?.contents?.[0]?.text ?? "").toContain("\"id\":\"abc123\"");
|
|
124
124
|
|
|
125
125
|
// 1f. resources/read — unknown URI → error
|
|
126
|
-
const readBadRes = await fetch(`${url}/
|
|
126
|
+
const readBadRes = await fetch(`${url}/api/${TENANT}/mcp`, {
|
|
127
127
|
method: "POST",
|
|
128
128
|
headers: { "Content-Type": "application/json" },
|
|
129
129
|
body: JSON.stringify({ jsonrpc: "2.0", id: 13, method: "resources/read", params: { uri: "nope://x" } }),
|
|
@@ -132,7 +132,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
|
132
132
|
expect(readBad?.error).toBeDefined();
|
|
133
133
|
|
|
134
134
|
// 2. tools/call (valid args + Joi defaults)
|
|
135
|
-
const callRes = await fetch(`${url}/
|
|
135
|
+
const callRes = await fetch(`${url}/api/${TENANT}/mcp`, {
|
|
136
136
|
method: "POST",
|
|
137
137
|
headers: { "Content-Type": "application/json" },
|
|
138
138
|
body: JSON.stringify({
|
|
@@ -148,7 +148,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
|
148
148
|
expect(block.text ?? "").toContain("\"times\":1");
|
|
149
149
|
|
|
150
150
|
// 2b. protocol content blocks pass through unchanged (image block)
|
|
151
|
-
const badgeRes = await fetch(`${url}/
|
|
151
|
+
const badgeRes = await fetch(`${url}/api/${TENANT}/mcp`, {
|
|
152
152
|
method: "POST",
|
|
153
153
|
headers: { "Content-Type": "application/json" },
|
|
154
154
|
body: JSON.stringify({
|
|
@@ -161,7 +161,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
|
161
161
|
expect(badge?.result?.content?.[0]?.mimeType).toBe("image/png");
|
|
162
162
|
|
|
163
163
|
// 2b. invalid args → isError (Joi validation)
|
|
164
|
-
const badRes = await fetch(`${url}/
|
|
164
|
+
const badRes = await fetch(`${url}/api/${TENANT}/mcp`, {
|
|
165
165
|
method: "POST",
|
|
166
166
|
headers: { "Content-Type": "application/json" },
|
|
167
167
|
body: JSON.stringify({
|
|
@@ -174,7 +174,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
|
174
174
|
expect(bad?.result?.content?.[0]?.text ?? "").toContain("Invalid arguments");
|
|
175
175
|
|
|
176
176
|
// 3. unknown tenant → 404
|
|
177
|
-
const notFound = await fetch(`${url}/
|
|
177
|
+
const notFound = await fetch(`${url}/api/ghost/mcp`, {
|
|
178
178
|
method: "POST",
|
|
179
179
|
headers: { "Content-Type": "application/json" },
|
|
180
180
|
body: JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/list", params: {} }),
|