@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.
- 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 +110 -32
- package/database/ttl.ts +68 -0
- package/database/workflow.ts +466 -240
- package/index.ts +6 -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/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/messages.ts +299 -0
- package/lib/providers.ts +867 -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 +14 -72
- 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-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-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/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/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/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-control.test.ts +2 -2
- package/tests/replication-meta.test.ts +243 -0
- package/tests/upload.test.ts +44 -12
- package/tests/vars-api.test.ts +1 -1
- package/tests/vars-replication.test.ts +3 -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 +837 -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 +29 -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 +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,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
|
+
};
|
|
@@ -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,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,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: {} }),
|