@anteros/core 0.0.41 → 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.
Files changed (102) 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 +110 -32
  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 +1327 -0
  11. package/lib/agentMemory.ts +586 -0
  12. package/lib/agents.ts +157 -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/messages.ts +299 -0
  25. package/lib/providers.ts +867 -0
  26. package/lib/routes.ts +14 -1
  27. package/lib/scriptWorker.ts +4 -1
  28. package/lib/scripts.ts +6 -1
  29. package/lib/sockets.ts +3 -1
  30. package/lib/workflow.ts +209 -37
  31. package/lib/zip.ts +109 -0
  32. package/package.json +1 -1
  33. package/server/access.ts +96 -0
  34. package/server/agents.ts +451 -0
  35. package/server/api.ts +14 -72
  36. package/server/boot.ts +11 -0
  37. package/server/hono.ts +8 -3
  38. package/server/mcp.ts +17 -63
  39. package/server/routes.ts +19 -24
  40. package/tests/agent-memory.test.ts +617 -0
  41. package/tests/agent-working-memory.test.ts +402 -0
  42. package/tests/agent.test.ts +1239 -0
  43. package/tests/agents-api.test.ts +593 -0
  44. package/tests/api.test.ts +51 -0
  45. package/tests/attachment.test.ts +241 -0
  46. package/tests/audit-operations.test.ts +1 -1
  47. package/tests/audit-redact.test.ts +29 -3
  48. package/tests/audit-run-action.test.ts +1 -1
  49. package/tests/endpoints.test.ts +80 -0
  50. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  51. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  52. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  53. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  54. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  55. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  56. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  57. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  58. package/tests/fixtures/attachment/data.csv +2 -0
  59. package/tests/fixtures/fake-provider.ts +128 -0
  60. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  61. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  62. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  63. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  64. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  65. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  66. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  67. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  68. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  69. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  70. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  71. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  72. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  73. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  74. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  75. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  76. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  77. package/tests/hono.test.ts +6 -3
  78. package/tests/locks.test.ts +120 -0
  79. package/tests/mcp.test.ts +11 -11
  80. package/tests/messages.test.ts +258 -0
  81. package/tests/replication-control.test.ts +2 -2
  82. package/tests/replication-meta.test.ts +243 -0
  83. package/tests/upload.test.ts +44 -12
  84. package/tests/vars-api.test.ts +1 -1
  85. package/tests/vars-replication.test.ts +3 -2
  86. package/tests/workflow-engine.test.ts +340 -0
  87. package/tests/workflow-retention.test.ts +103 -0
  88. package/tests/workflow.test.ts +186 -0
  89. package/types/agent.d.ts +837 -0
  90. package/types/config.d.ts +16 -0
  91. package/types/file.d.ts +7 -7
  92. package/types/hook.d.ts +6 -0
  93. package/types/lifecycle.d.ts +6 -0
  94. package/types/mcp.d.ts +11 -1
  95. package/types/replication.d.ts +29 -0
  96. package/types/route.d.ts +7 -1
  97. package/types/scripts.d.ts +6 -0
  98. package/types/service.d.ts +7 -1
  99. package/types/tenant.d.ts +13 -0
  100. package/types/vars.d.ts +2 -2
  101. package/types/websocket.d.ts +6 -0
  102. package/types/workflow.d.ts +50 -3
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Fake model providers for the agent tests — one local HTTP endpoint per
3
+ * protocol, recording what the runtime sent.
4
+ *
5
+ * Shared by `agent.test.ts` (runtime) and `agents-api.test.ts` (HTTP surface),
6
+ * so a change in the wire format is fixed in one place.
7
+ */
8
+
9
+ export type Recorded = { path: string; body: any; headers: Record<string, string> };
10
+
11
+ export type Fake = {
12
+ requests: Recorded[];
13
+ url: string;
14
+ hits: () => number;
15
+ stop: () => void;
16
+ };
17
+
18
+ /** A local endpoint that records the requests and answers from `handler`. */
19
+ export function fakeProvider(handler: (body: any, req: Request, hits: number) => Response | Promise<Response>): Fake {
20
+ const requests: Recorded[] = [];
21
+ let hits = 0;
22
+ const server = Bun.serve({
23
+ port: 0,
24
+ hostname: "127.0.0.1",
25
+ fetch: async (req) => {
26
+ const body = await req.json().catch(() => null);
27
+ requests.push({
28
+ path: new URL(req.url).pathname,
29
+ body,
30
+ headers: Object.fromEntries(req.headers.entries()),
31
+ });
32
+ hits += 1;
33
+ return handler(body, req, hits);
34
+ },
35
+ });
36
+ return {
37
+ requests,
38
+ url: `http://127.0.0.1:${server.port}`,
39
+ hits: () => hits,
40
+ stop: () => server.stop(true),
41
+ };
42
+ }
43
+
44
+ /** Typed element access (`noUncheckedIndexedAccess` is on). */
45
+ export function at<T>(list: T[], index = 0): T {
46
+ const item = list[index];
47
+ if (item === undefined) throw new Error(`No element #${index} (length ${list.length})`);
48
+ return item;
49
+ }
50
+
51
+ export function request(provider: Fake, index = 0): Recorded {
52
+ const recorded = provider.requests[index];
53
+ if (!recorded) throw new Error(`No request #${index} was recorded (got ${provider.requests.length})`);
54
+ return recorded;
55
+ }
56
+
57
+ export const USAGE = { prompt_tokens: 11, completion_tokens: 4, total_tokens: 15 };
58
+ export const A_USAGE = { input_tokens: 13, output_tokens: 6 };
59
+
60
+ /** An SSE response from a list of already-serialized events. */
61
+ export function sse(events: string[]): Response {
62
+ return new Response(events.map((event) => `data: ${event}\n\n`).join(""), {
63
+ headers: { "Content-Type": "text/event-stream" },
64
+ });
65
+ }
66
+
67
+ // ─── OpenAI-compatible ───────────────────────────────────────────────────
68
+
69
+ export function openaiText(text: string, finishReason = "stop"): Response {
70
+ return Response.json({
71
+ choices: [{ message: { role: "assistant", content: text }, finish_reason: finishReason }],
72
+ usage: USAGE,
73
+ });
74
+ }
75
+
76
+ export function openaiTool(name: string, args: any, id = "call_1"): Response {
77
+ return Response.json({
78
+ choices: [{
79
+ message: {
80
+ role: "assistant",
81
+ content: null,
82
+ tool_calls: [{ id, type: "function", function: { name, arguments: JSON.stringify(args) } }],
83
+ },
84
+ finish_reason: "tool_calls",
85
+ }],
86
+ usage: USAGE,
87
+ });
88
+ }
89
+
90
+ export function openaiStream(pieces: string[]): Response {
91
+ const events = pieces.map((text) => JSON.stringify({ choices: [{ delta: { content: text } }] }));
92
+ events.push(JSON.stringify({ choices: [], usage: USAGE }));
93
+ events.push(JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] }));
94
+ events.push("[DONE]");
95
+ return sse(events);
96
+ }
97
+
98
+ // ─── Anthropic ───────────────────────────────────────────────────────────
99
+
100
+ export function anthropicText(text: string, stopReason = "end_turn"): Response {
101
+ return Response.json({
102
+ content: [{ type: "text", text }],
103
+ stop_reason: stopReason,
104
+ usage: A_USAGE,
105
+ });
106
+ }
107
+
108
+ export function anthropicTool(name: string, input: any, id = "toolu_1"): Response {
109
+ return Response.json({
110
+ content: [{ type: "tool_use", id, name, input }],
111
+ stop_reason: "tool_use",
112
+ usage: A_USAGE,
113
+ });
114
+ }
115
+
116
+ export function anthropicStream(pieces: string[]): Response {
117
+ const events: string[] = [
118
+ JSON.stringify({ type: "message_start", message: { usage: { input_tokens: 9, output_tokens: 0 } } }),
119
+ JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }),
120
+ ];
121
+ for (const text of pieces) {
122
+ events.push(JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }));
123
+ }
124
+ events.push(JSON.stringify({ type: "content_block_stop", index: 0 }));
125
+ events.push(JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 5 } }));
126
+ events.push(JSON.stringify({ type: "message_stop" }));
127
+ return sse(events);
128
+ }
@@ -0,0 +1,16 @@
1
+ import { define, agents } from "../../../../index";
2
+
3
+ /**
4
+ * The collection this tenant names for its agent memory. The loader publishes it
5
+ * (`cfg.agentMemories`), so the replication engine copies it like a declared
6
+ * collection — `replication.exclude: ['memory']` is the opt-out.
7
+ */
8
+ export const AGENT_THREADS = "agent_threads";
9
+
10
+ export default define.Agent({
11
+ id: "chat",
12
+ description: "Fixture agent whose memory lives in a tenant-named collection.",
13
+ instructions: "Fixture agent — no model call is ever made in these tests.",
14
+ provider: { model: "test-model", compatible: "openai", apiKey: "fixture" },
15
+ memory: new agents.memory.Mongo({ collection: AGENT_THREADS }),
16
+ });
@@ -0,0 +1,13 @@
1
+ // Test fixture — a collection whose replication key is a **number** (not a date).
2
+ // The delete guard must still match on the destination.
3
+ export default {
4
+ _isCollection_: true,
5
+ _isTimeSerie_: false,
6
+ type: "document",
7
+ slug: "counters",
8
+ fields: [
9
+ { name: "seq", type: "number" },
10
+ { name: "title", type: "string" },
11
+ ],
12
+ replication: { enabled: true, key: "seq" },
13
+ };
@@ -0,0 +1,9 @@
1
+ // Test fixture — a declared collection that opted into replication.
2
+ export default {
3
+ _isCollection_: true,
4
+ _isTimeSerie_: false,
5
+ type: "document",
6
+ slug: "orders",
7
+ fields: [{ name: "title", type: "string" }],
8
+ replication: { enabled: true },
9
+ };
@@ -0,0 +1,8 @@
1
+ // Test fixture — a workflow whose runs must follow the data to the destination.
2
+ export default {
3
+ _isWorkflow_: true,
4
+ id: "run",
5
+ name: "Run",
6
+ version: 1,
7
+ steps: [{ id: "one", exec: async () => ({ ok: true }) }],
8
+ };
@@ -1,6 +1,9 @@
1
- // Test fixture — a namespace WITHOUT `api.access` (must be denied over HTTP).
1
+ // Test fixture — a namespace WITHOUT `api.access` (must be denied over HTTP),
2
+ // and which explicitly opts OUT of replication.
2
3
  export default {
3
4
  _isVars_: true,
4
5
  namespace: "private",
5
6
  vars: { secret: "shh" },
7
+ // Framework collections are replicated by default: opting out is explicit
8
+ replication: { enabled: false },
6
9
  };
@@ -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,3 @@
1
+ // Test fixture — throws while being imported: the loader must isolate the
2
+ // failure and keep loading the other workflows of the tenant.
3
+ throw new Error("boom — broken workflow fixture");
@@ -0,0 +1,8 @@
1
+ // Test fixture — `enabled: false` must not be registered.
2
+ export default {
3
+ _isWorkflow_: true,
4
+ enabled: false,
5
+ id: "nightly",
6
+ name: "Nightly",
7
+ steps: [{ id: "run", exec: async () => ({ ok: true }) }],
8
+ };
@@ -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,7 @@
1
+ // Test fixture — no `id`: must be ignored (with a warning), not registered
2
+ // under a bogus `undefined` key.
3
+ export default {
4
+ _isWorkflow_: true,
5
+ name: "Anonymous",
6
+ steps: [{ id: "run", exec: async () => ({ ok: true }) }],
7
+ };
@@ -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
+ };
@@ -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).toEqual([200, 200, 429]);
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,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 /mcp/:tenant_id)", () => {
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}/mcp/${TENANT}`, {
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}/mcp/${TENANT}`, {
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}/mcp/${TENANT}`, {
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}/mcp/${TENANT}`, {
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}/mcp/${TENANT}`, {
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}/mcp/${TENANT}`, {
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}/mcp/${TENANT}`, {
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}/mcp/${TENANT}`, {
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}/mcp/${TENANT}`, {
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}/mcp/ghost`, {
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: {} }),