@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,216 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { MongoClient, ObjectId } from "mongodb";
|
|
3
|
+
import { useRest } from "../database/rest";
|
|
4
|
+
import { formatConfig, cfg } from "../server/config";
|
|
5
|
+
import { syncVars } from "../database/vars";
|
|
6
|
+
import { define } from "../lib/define";
|
|
7
|
+
|
|
8
|
+
const TEST_TENANT = "vars";
|
|
9
|
+
const DB = "mongodb://localhost:27017/_VARS_TEST";
|
|
10
|
+
|
|
11
|
+
let rest: InstanceType<typeof useRest>;
|
|
12
|
+
let client: MongoClient;
|
|
13
|
+
const raw = () => client.db().collection("_vars_");
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
formatConfig({
|
|
17
|
+
server: { port: 4000 },
|
|
18
|
+
tenants: [{
|
|
19
|
+
id: TEST_TENANT,
|
|
20
|
+
dir: "packages/core/tests/fixtures/vars-tenant",
|
|
21
|
+
database: { uri: DB },
|
|
22
|
+
}],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
client = new MongoClient(DB, { serverSelectionTimeoutMS: 3000 });
|
|
26
|
+
await client.connect();
|
|
27
|
+
const tenant: any = cfg.tenants?.[0];
|
|
28
|
+
tenant.database.client = client;
|
|
29
|
+
tenant.database.db = client.db();
|
|
30
|
+
|
|
31
|
+
await syncVars();
|
|
32
|
+
|
|
33
|
+
rest = new useRest({ tenant_id: TEST_TENANT });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
try { await client.db().dropDatabase(); } catch (_) {}
|
|
38
|
+
try { await client.close(); } catch (_) {}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("define.Vars", () => {
|
|
42
|
+
it("marks the definition", () => {
|
|
43
|
+
const v = define.Vars({ namespace: "x", vars: { a: 1 } });
|
|
44
|
+
expect(v._isVars_).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("vars loading & seeding", () => {
|
|
49
|
+
it("loads the definitions from vars/*.var.ts", () => {
|
|
50
|
+
const namespaces = (cfg.vars ?? []).map((v) => v.namespace).sort();
|
|
51
|
+
expect(namespaces).toEqual(["config", "private"]);
|
|
52
|
+
expect((cfg.vars ?? []).every((v) => v._tenant_ === TEST_TENANT)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("seeds default values at the global scope", async () => {
|
|
56
|
+
expect(await rest.vars.get<string>("config", "licence")).toBe("RDX00");
|
|
57
|
+
expect(await rest.vars.get<number>("config", "maxUsers")).toBe(100);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("never overwrites an existing value when re-seeding", async () => {
|
|
61
|
+
await rest.vars.set("config", "licence", "CHANGED");
|
|
62
|
+
await syncVars();
|
|
63
|
+
expect(await rest.vars.get<string>("config", "licence")).toBe("CHANGED");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("always creates the scope + TTL indexes", async () => {
|
|
67
|
+
const names = (await raw().listIndexes().toArray()).map((i) => i.name);
|
|
68
|
+
expect(names).toContain("scope_1_ns_1");
|
|
69
|
+
expect(names).toContain("expiresAt_1");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("rest.vars — CRUD", () => {
|
|
74
|
+
it("set / get / has / del", async () => {
|
|
75
|
+
await rest.vars.set("app", "theme", "dark");
|
|
76
|
+
expect(await rest.vars.get<string>("app", "theme")).toBe("dark");
|
|
77
|
+
expect(await rest.vars.has("app", "theme")).toBe(true);
|
|
78
|
+
|
|
79
|
+
expect(await rest.vars.del("app", "theme")).toBe(true);
|
|
80
|
+
expect(await rest.vars.get<string>("app", "theme")).toBeUndefined();
|
|
81
|
+
expect(await rest.vars.has("app", "theme")).toBe(false);
|
|
82
|
+
expect(await rest.vars.del("app", "theme")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("stores rich values (objects, arrays, booleans)", async () => {
|
|
86
|
+
const value = { max: 10, tags: ["a", "b"], on: true };
|
|
87
|
+
await rest.vars.set("app", "limits", value);
|
|
88
|
+
expect(await rest.vars.get<any>("app", "limits")).toEqual(value);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("setMany / all / clear", async () => {
|
|
92
|
+
await rest.vars.setMany("bulk", { a: 1, b: 2, c: 3 });
|
|
93
|
+
expect(await rest.vars.all("bulk")).toEqual({ a: 1, b: 2, c: 3 });
|
|
94
|
+
expect(await rest.vars.clear("bulk")).toBe(3);
|
|
95
|
+
expect(await rest.vars.all("bulk")).toEqual({});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("incr is atomic (and creates a missing key)", async () => {
|
|
99
|
+
await rest.vars.set("counters", "hits", 5);
|
|
100
|
+
expect(await rest.vars.incr("counters", "hits")).toBe(6);
|
|
101
|
+
expect(await rest.vars.incr("counters", "hits", 10)).toBe(16);
|
|
102
|
+
expect(await rest.vars.incr("counters", "fresh", 3)).toBe(3);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("isolates namespaces", async () => {
|
|
106
|
+
await rest.vars.set("ns1", "k", "a");
|
|
107
|
+
await rest.vars.set("ns2", "k", "b");
|
|
108
|
+
expect(await rest.vars.get<string>("ns1", "k")).toBe("a");
|
|
109
|
+
expect(await rest.vars.get<string>("ns2", "k")).toBe("b");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("rest.vars — scope (per company)", () => {
|
|
114
|
+
it("keeps a distinct value per scope", async () => {
|
|
115
|
+
const acme = new ObjectId().toHexString();
|
|
116
|
+
const globex = new ObjectId().toHexString();
|
|
117
|
+
|
|
118
|
+
await rest.vars.scope(acme).set("config", "licence", "ACME-001");
|
|
119
|
+
await rest.vars.scope(globex).set("config", "licence", "GLBX-002");
|
|
120
|
+
|
|
121
|
+
expect(await rest.vars.scope(acme).get<string>("config", "licence")).toBe("ACME-001");
|
|
122
|
+
expect(await rest.vars.scope(globex).get<string>("config", "licence")).toBe("GLBX-002");
|
|
123
|
+
// global value untouched
|
|
124
|
+
expect(await rest.vars.get<string>("config", "licence")).not.toBe("ACME-001");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("accepts an explicit scope option", async () => {
|
|
128
|
+
const id = new ObjectId().toHexString();
|
|
129
|
+
await rest.vars.set("config", "licence", "SCOPED", { scope: id });
|
|
130
|
+
expect(await rest.vars.get<string>("config", "licence", { scope: id })).toBe("SCOPED");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("rest.vars — meta", () => {
|
|
135
|
+
it("stores and returns validated meta", async () => {
|
|
136
|
+
const id = new ObjectId().toHexString();
|
|
137
|
+
const acme = rest.vars.scope(id);
|
|
138
|
+
|
|
139
|
+
await acme.set("config", "licence", "ACME-003", { meta: { note: "Contrat 2026" } });
|
|
140
|
+
const entry = await acme.entry("config", "licence");
|
|
141
|
+
|
|
142
|
+
expect(entry?.value).toBe("ACME-003");
|
|
143
|
+
expect(entry?.meta).toEqual({ note: "Contrat 2026" });
|
|
144
|
+
expect(String(entry?.scope)).toBe(id);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("rejects unknown meta keys", async () => {
|
|
148
|
+
await expect(
|
|
149
|
+
rest.vars.set("config", "licence", "X", { meta: { nope: 1 } }),
|
|
150
|
+
).rejects.toThrow(/nope/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("filters entries on meta with `where`", async () => {
|
|
154
|
+
const id = new ObjectId().toHexString();
|
|
155
|
+
const acme = rest.vars.scope(id);
|
|
156
|
+
|
|
157
|
+
await acme.setMany("flags", { a: true, b: false }, { meta: { note: "grp" } });
|
|
158
|
+
const found = await acme.entries("flags", { where: { note: "grp" } });
|
|
159
|
+
|
|
160
|
+
expect(found.length).toBe(2);
|
|
161
|
+
expect(await acme.entries("flags", { where: { note: "other" } })).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("rest.vars — validation", () => {
|
|
166
|
+
it("rejects a value that does not match its spec", async () => {
|
|
167
|
+
await expect(rest.vars.set("config", "maxUsers", "not-a-number")).rejects.toThrow(/maxUsers/);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("rest.vars — TTL", () => {
|
|
172
|
+
it("stores an expiry set per call", async () => {
|
|
173
|
+
await rest.vars.set("ttl", "k", "v", { ttl: "1h" });
|
|
174
|
+
const entry = await rest.vars.entry("ttl", "k");
|
|
175
|
+
expect(entry?.expiresAt).toBeInstanceOf(Date);
|
|
176
|
+
expect(entry!.expiresAt!.getTime()).toBeGreaterThan(Date.now());
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("applies the variable's default TTL", async () => {
|
|
180
|
+
await rest.vars.set("config", "session", "s1"); // `session` declares ttl: '1h'
|
|
181
|
+
const entry = await rest.vars.entry("config", "session");
|
|
182
|
+
expect(entry?.expiresAt).toBeInstanceOf(Date);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("`set` without ttl clears an existing one", async () => {
|
|
186
|
+
await rest.vars.set("ttl", "clear", "v", { ttl: "1h" });
|
|
187
|
+
await rest.vars.set("ttl", "clear", "v2"); // no ttl → authoritative reset
|
|
188
|
+
const entry = await rest.vars.entry("ttl", "clear");
|
|
189
|
+
expect(entry?.expiresAt).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("`expire` sets / renews the TTL", async () => {
|
|
193
|
+
await rest.vars.set("ttl", "renew", "v");
|
|
194
|
+
expect(await rest.vars.expire("ttl", "renew", "30m")).toBe(true);
|
|
195
|
+
const entry = await rest.vars.entry("ttl", "renew");
|
|
196
|
+
expect(entry?.expiresAt).toBeInstanceOf(Date);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("hides an expired entry from reads", async () => {
|
|
200
|
+
await raw().insertOne({
|
|
201
|
+
_id: "config:gone",
|
|
202
|
+
ns: "config", key: "gone", scope: null,
|
|
203
|
+
value: "x", meta: {},
|
|
204
|
+
expiresAt: new Date(Date.now() - 1000),
|
|
205
|
+
createdAt: new Date(), updatedAt: new Date(),
|
|
206
|
+
} as any);
|
|
207
|
+
|
|
208
|
+
expect(await rest.vars.get<string>("config", "gone")).toBeUndefined();
|
|
209
|
+
expect(await rest.vars.has("config", "gone")).toBe(false);
|
|
210
|
+
expect(await rest.vars.entry("config", "gone")).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("rejects an invalid ttl", async () => {
|
|
214
|
+
await expect(rest.vars.set("ttl", "bad", "v", { ttl: "abc" })).rejects.toThrow(/ttl/);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "bun:test";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { formatConfig } from "../server/config";
|
|
4
|
+
import { syncTenants } from "../database/tenant";
|
|
5
|
+
import { syncWorkflows, ensureWorkflowIndexes, getWorkflow } from "../lib/workflow";
|
|
6
|
+
import { useRest } from "../database/rest";
|
|
7
|
+
import type { WorkflowRun } from "../types/workflow";
|
|
8
|
+
|
|
9
|
+
const TENANT = "wf-engine";
|
|
10
|
+
const DIR = "packages/core/tests/fixtures/workflow-engine";
|
|
11
|
+
|
|
12
|
+
let rest: InstanceType<typeof useRest>;
|
|
13
|
+
/** Shared, in-process state written by the fixture steps */
|
|
14
|
+
const state: { log: string[]; attempts: Record<string, number> } = ((globalThis as any).__wfEngine ??= {
|
|
15
|
+
log: [],
|
|
16
|
+
attempts: {},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function runsOf(workflowId: string): Promise<WorkflowRun[]> {
|
|
20
|
+
const col = rest.db.collection("_workflows_");
|
|
21
|
+
return (await col.find({ workflowId }).sort({ createdAt: -1 }).toArray()) as unknown as WorkflowRun[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The run currently being executed (its `_id` is only known from the database) */
|
|
25
|
+
async function currentRun(workflowId: string): Promise<any> {
|
|
26
|
+
for (let i = 0; i < 80; i++) {
|
|
27
|
+
const [run] = await runsOf(workflowId);
|
|
28
|
+
if (run && run.status === "running") return run;
|
|
29
|
+
await Bun.sleep(25);
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`no running '${workflowId}' run found`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Insert a run document directly, to set up a state the engine must recover from */
|
|
35
|
+
async function insertRun(doc: Partial<WorkflowRun> & { _id: string; workflowId: string }): Promise<void> {
|
|
36
|
+
await rest.db.collection("_workflows_").insertOne({
|
|
37
|
+
tenant_id: TENANT,
|
|
38
|
+
status: "failed",
|
|
39
|
+
progress: 0,
|
|
40
|
+
data: {},
|
|
41
|
+
currentStep: 0,
|
|
42
|
+
totalExecuted: 0,
|
|
43
|
+
totalSkipped: 0,
|
|
44
|
+
steps: [],
|
|
45
|
+
compensations: [],
|
|
46
|
+
createdAt: new Date(),
|
|
47
|
+
updatedAt: new Date(),
|
|
48
|
+
...doc,
|
|
49
|
+
} as any);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
formatConfig({
|
|
54
|
+
server: { port: 4000 },
|
|
55
|
+
tenants: [{ id: TENANT, dir: DIR, database: { uri: "mongodb://localhost:27017/_WF_ENGINE" } }],
|
|
56
|
+
});
|
|
57
|
+
await syncTenants();
|
|
58
|
+
await syncWorkflows();
|
|
59
|
+
await ensureWorkflowIndexes();
|
|
60
|
+
rest = new useRest({ tenant_id: TENANT, internal: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
state.log.length = 0;
|
|
65
|
+
for (const key of Object.keys(state.attempts)) delete state.attempts[key];
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
try { await rest.db.collection("_workflows_").drop(); } catch (_) {}
|
|
70
|
+
try { await rest.db.collection("_locks_").drop(); } catch (_) {}
|
|
71
|
+
delete (globalThis as any).__wfEngine;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("workflow locks", () => {
|
|
75
|
+
it("runs a step once when two nodes resume the same run", async () => {
|
|
76
|
+
await insertRun({
|
|
77
|
+
_id: "lock-1",
|
|
78
|
+
workflowId: "simple",
|
|
79
|
+
status: "paused",
|
|
80
|
+
steps: [{ stepId: "one", status: "pending", startedAt: new Date() } as any],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const [a, b] = await Promise.allSettled([
|
|
84
|
+
rest.workflow.resume("lock-1"),
|
|
85
|
+
rest.workflow.resume("lock-1"),
|
|
86
|
+
]);
|
|
87
|
+
const outcomes = [a, b].map((r) => r.status);
|
|
88
|
+
const errors = [a, b].map((r: any) => r.reason?.code).filter(Boolean) as string[];
|
|
89
|
+
|
|
90
|
+
// One node wins; the other is either locked out or finds the run finished
|
|
91
|
+
expect(outcomes).toContain("fulfilled");
|
|
92
|
+
for (const code of errors) expect(["RUN_LOCKED", "RUN_TERMINAL"]).toContain(code);
|
|
93
|
+
|
|
94
|
+
// …and above all: the step was NOT executed twice
|
|
95
|
+
expect(state.log.filter((l) => l === "simple:one").length).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("releases the lock when the run finishes (a later resume is not locked)", async () => {
|
|
99
|
+
const run = await rest.workflow.run("simple", { echo: "release" });
|
|
100
|
+
const locks = await rest.db.collection("_locks_").countDocuments({ name: `workflow:${run._id}` });
|
|
101
|
+
expect(locks).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("refuses a resume while another node holds the run", async () => {
|
|
105
|
+
await insertRun({
|
|
106
|
+
_id: "lock-2",
|
|
107
|
+
workflowId: "simple",
|
|
108
|
+
status: "paused",
|
|
109
|
+
steps: [{ stepId: "one", status: "pending", startedAt: new Date() } as any],
|
|
110
|
+
});
|
|
111
|
+
// Simulate another node holding the lock (not expired)
|
|
112
|
+
await rest.db.collection("_locks_").insertOne({
|
|
113
|
+
_id: `${TENANT}:workflow:lock-2` as any,
|
|
114
|
+
tid: TENANT,
|
|
115
|
+
name: "workflow:lock-2",
|
|
116
|
+
acquiredAt: Date.now(),
|
|
117
|
+
expiresAt: Date.now() + 60_000,
|
|
118
|
+
} as any);
|
|
119
|
+
|
|
120
|
+
await expect(rest.workflow.resume("lock-2")).rejects.toThrow(/another node/);
|
|
121
|
+
await rest.db.collection("_locks_").deleteOne({ name: "workflow:lock-2" });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("workflow pause & cancel", () => {
|
|
126
|
+
it("pauses an in-flight run at the next step boundary", async () => {
|
|
127
|
+
const pending = rest.workflow.run("slow", { ms: 600 });
|
|
128
|
+
const running = await currentRun("slow");
|
|
129
|
+
|
|
130
|
+
expect(await rest.workflow.pause(running._id)).toBe(true);
|
|
131
|
+
const run: any = await pending;
|
|
132
|
+
|
|
133
|
+
expect(run.status).toBe("paused");
|
|
134
|
+
expect(state.log).toEqual(["slow:fast", "slow:slow:start", "slow:slow:end"]);
|
|
135
|
+
expect(run.steps[2].status).toBe("pending"); // the last step never ran
|
|
136
|
+
}, 20_000);
|
|
137
|
+
|
|
138
|
+
it("resumes a paused run from the step it stopped at", async () => {
|
|
139
|
+
const [paused] = await runsOf("slow");
|
|
140
|
+
state.log.length = 0;
|
|
141
|
+
|
|
142
|
+
const run = await rest.workflow.resume(paused!._id, { ms: 10 });
|
|
143
|
+
expect(run.status).toBe("completed");
|
|
144
|
+
expect(state.log).toEqual(["slow:last"]);
|
|
145
|
+
}, 20_000);
|
|
146
|
+
|
|
147
|
+
it("cancels an in-flight run permanently", async () => {
|
|
148
|
+
const pending = rest.workflow.run("slow", { ms: 600 });
|
|
149
|
+
const running = await currentRun("slow");
|
|
150
|
+
|
|
151
|
+
expect(await rest.workflow.cancel(running._id)).toBe(true);
|
|
152
|
+
const run: any = await pending;
|
|
153
|
+
|
|
154
|
+
expect(run.status).toBe("cancelled");
|
|
155
|
+
expect(state.log).not.toContain("slow:last");
|
|
156
|
+
await expect(rest.workflow.resume(run._id)).rejects.toThrow(/cancelled|not applicable/);
|
|
157
|
+
}, 20_000);
|
|
158
|
+
|
|
159
|
+
it("does not cancel a run that already completed", async () => {
|
|
160
|
+
const done = await rest.workflow.run("simple", { echo: "terminal" });
|
|
161
|
+
expect(await rest.workflow.cancel(done._id)).toBe(false);
|
|
162
|
+
|
|
163
|
+
const stored: any = await rest.workflow.getRun(done._id);
|
|
164
|
+
expect(stored.status).toBe("completed");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("workflow step retries & timeout", () => {
|
|
169
|
+
it("retries a transient failure instead of rolling the saga back", async () => {
|
|
170
|
+
const run = await rest.workflow.run("flaky", { failUntil: 2 });
|
|
171
|
+
expect(run.status).toBe("completed");
|
|
172
|
+
expect(state.log).toEqual(["flaky:attempt:1", "flaky:attempt:2", "flaky:attempt:3"]);
|
|
173
|
+
expect(run.steps[0]!.output).toEqual({ attempts: 3 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("gives up after `retries` and fails the run", async () => {
|
|
177
|
+
await expect(rest.workflow.run("flaky", { failUntil: 5 })).rejects.toThrow(/transient failure/);
|
|
178
|
+
const [run] = await runsOf("flaky");
|
|
179
|
+
expect(run!.status).toBe("failed");
|
|
180
|
+
expect(state.attempts.unstable).toBe(3); // 1 attempt + 2 retries
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("aborts a step that exceeds its timeout", async () => {
|
|
184
|
+
await expect(rest.workflow.run("hang", { ms: 1000 })).rejects.toThrow(/timed out/);
|
|
185
|
+
const [run] = await runsOf("hang");
|
|
186
|
+
expect(run!.status).toBe("failed");
|
|
187
|
+
expect(run!.error?.code).toBe("WORKFLOW_STEP_TIMEOUT");
|
|
188
|
+
}, 15_000);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("workflow compensations", () => {
|
|
192
|
+
it("compensates the completed steps and marks the run `compensated`", async () => {
|
|
193
|
+
await expect(rest.workflow.run("failing", {})).rejects.toThrow(/boom-b/);
|
|
194
|
+
const [run] = await runsOf("failing");
|
|
195
|
+
|
|
196
|
+
expect(run!.status).toBe("compensated");
|
|
197
|
+
expect(run!.steps[2]!.status).toBe("pending"); // never reached
|
|
198
|
+
expect(state.log).toEqual([
|
|
199
|
+
"failing:a:rest=client",
|
|
200
|
+
"simple:one", // the sub-workflow run from the step
|
|
201
|
+
"failing:a:sub-workflow-done",
|
|
202
|
+
"failing:b",
|
|
203
|
+
"failing:undo-a:1",
|
|
204
|
+
]);
|
|
205
|
+
}, 15_000);
|
|
206
|
+
|
|
207
|
+
it("refuses to resume a compensated run (never replays the steps)", async () => {
|
|
208
|
+
const [run] = await runsOf("failing");
|
|
209
|
+
state.log.length = 0;
|
|
210
|
+
|
|
211
|
+
await expect(rest.workflow.resume(run!._id)).rejects.toThrow(/compensated/);
|
|
212
|
+
expect(state.log).toEqual([]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("never replays a compensation through resumeAll", async () => {
|
|
216
|
+
const [before] = await runsOf("failing");
|
|
217
|
+
state.log.length = 0;
|
|
218
|
+
|
|
219
|
+
await rest.workflow.resumeAll();
|
|
220
|
+
|
|
221
|
+
// The compensated run is left alone and its compensation is not replayed
|
|
222
|
+
const [after] = await runsOf("failing");
|
|
223
|
+
expect(after!.status).toBe("compensated");
|
|
224
|
+
expect(state.log.filter((l) => l.startsWith("failing:undo-a"))).toEqual([]);
|
|
225
|
+
expect(before!._id).toBe(after!._id);
|
|
226
|
+
}, 15_000);
|
|
227
|
+
|
|
228
|
+
it("finishes an interrupted rollback without replaying what already succeeded", async () => {
|
|
229
|
+
await insertRun({
|
|
230
|
+
_id: "rollback-1",
|
|
231
|
+
workflowId: "failing",
|
|
232
|
+
status: "failed",
|
|
233
|
+
steps: [
|
|
234
|
+
{ stepId: "a", status: "completed", output: { a: 1 }, startedAt: new Date() } as any,
|
|
235
|
+
{ stepId: "b", status: "failed", error: { message: "boom-b" }, startedAt: new Date() } as any,
|
|
236
|
+
{ stepId: "c", status: "pending", startedAt: new Date() } as any,
|
|
237
|
+
],
|
|
238
|
+
// `undo-a` failed the first time: the rollback must be finished
|
|
239
|
+
compensations: [{ stepId: "undo-a", status: "failed", error: { message: "network" }, startedAt: new Date() } as any],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const run: any = await rest.workflow.resume("rollback-1");
|
|
243
|
+
expect(run.status).toBe("compensated");
|
|
244
|
+
expect(run.steps[1].status).toBe("failed"); // never continued forward
|
|
245
|
+
expect(state.log.length).toBe(1);
|
|
246
|
+
expect(state.log[0]).toStartWith("failing:undo-a");
|
|
247
|
+
expect(run.compensations.length).toBe(2); // the failed one + the successful retry
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("replays nothing when the rollback already succeeded", async () => {
|
|
251
|
+
await insertRun({
|
|
252
|
+
_id: "rollback-2",
|
|
253
|
+
workflowId: "failing",
|
|
254
|
+
status: "failed",
|
|
255
|
+
steps: [
|
|
256
|
+
{ stepId: "a", status: "completed", output: { a: 1 }, startedAt: new Date() } as any,
|
|
257
|
+
{ stepId: "b", status: "failed", error: { message: "boom-b" }, startedAt: new Date() } as any,
|
|
258
|
+
],
|
|
259
|
+
compensations: [{ stepId: "undo-a", status: "completed", startedAt: new Date() } as any],
|
|
260
|
+
});
|
|
261
|
+
state.log.length = 0;
|
|
262
|
+
|
|
263
|
+
const run: any = await rest.workflow.resume("rollback-2");
|
|
264
|
+
expect(run.status).toBe("compensated");
|
|
265
|
+
expect(state.log).toEqual([]); // the successful compensation was not replayed
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("workflow run ownership", () => {
|
|
270
|
+
it("stops when another node takes the run over", async () => {
|
|
271
|
+
const pending = rest.workflow.run("slow", { ms: 500 });
|
|
272
|
+
const running = await currentRun("slow");
|
|
273
|
+
|
|
274
|
+
// Simulate a takeover: another process owns the run lock now
|
|
275
|
+
await rest.db.collection("_locks_").updateOne(
|
|
276
|
+
{ _id: `${TENANT}:workflow:${running._id}` as any },
|
|
277
|
+
{ $set: { pid: process.pid + 1, hostname: os.hostname(), expiresAt: Date.now() + 60_000 } },
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const run: any = await pending;
|
|
281
|
+
expect(run.steps[2].status).toBe("pending"); // the last step never ran
|
|
282
|
+
expect(state.log).not.toContain("slow:last");
|
|
283
|
+
|
|
284
|
+
await rest.db.collection("_locks_").deleteOne({ _id: `${TENANT}:workflow:${running._id}` as any });
|
|
285
|
+
}, 20_000);
|
|
286
|
+
|
|
287
|
+
it("records the executing pid on the run", async () => {
|
|
288
|
+
const run = await rest.workflow.run("simple", { echo: "who" });
|
|
289
|
+
expect(run.pid).toBe(process.pid);
|
|
290
|
+
expect(run.hostname).toBe(os.hostname());
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("workflow resume guards", () => {
|
|
295
|
+
it("refuses to resume a run started by another version", async () => {
|
|
296
|
+
await insertRun({
|
|
297
|
+
_id: "version-1",
|
|
298
|
+
workflowId: "versioned",
|
|
299
|
+
workflowVersion: 1,
|
|
300
|
+
status: "paused",
|
|
301
|
+
steps: [{ stepId: "v", status: "pending", startedAt: new Date() } as any],
|
|
302
|
+
});
|
|
303
|
+
// The workflow has been deployed in version 2 since the run started
|
|
304
|
+
getWorkflow("versioned", TENANT)!.version = 2;
|
|
305
|
+
|
|
306
|
+
await expect(rest.workflow.resume("version-1")).rejects.toThrow(/version/);
|
|
307
|
+
|
|
308
|
+
// …unless the operator forces it
|
|
309
|
+
state.log.length = 0;
|
|
310
|
+
const run: any = await rest.workflow.resume("version-1", undefined, { force: true });
|
|
311
|
+
expect(run.status).toBe("completed");
|
|
312
|
+
expect(state.log).toEqual(["versioned:v"]);
|
|
313
|
+
|
|
314
|
+
getWorkflow("versioned", TENANT)!.version = 1;
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("workflow resumeAll", () => {
|
|
319
|
+
it("resumes every paused run, batch by batch", async () => {
|
|
320
|
+
for (let i = 0; i < 5; i++) {
|
|
321
|
+
await insertRun({
|
|
322
|
+
_id: `bulk-${i}`,
|
|
323
|
+
workflowId: "simple",
|
|
324
|
+
status: "paused",
|
|
325
|
+
steps: [{ stepId: "one", status: "pending", startedAt: new Date() } as any],
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const { resumed } = await rest.workflow.resumeAll(undefined, { batchSize: 2 });
|
|
330
|
+
expect(resumed).toBeGreaterThanOrEqual(5);
|
|
331
|
+
|
|
332
|
+
const left = await rest.db.collection("_workflows_")
|
|
333
|
+
.countDocuments({ _id: /^bulk-/, status: { $in: ["paused", "failed"] } });
|
|
334
|
+
expect(left).toBe(0);
|
|
335
|
+
|
|
336
|
+
const done = await rest.db.collection("_workflows_")
|
|
337
|
+
.countDocuments({ _id: /^bulk-/, status: "completed" });
|
|
338
|
+
expect(done).toBe(5);
|
|
339
|
+
}, 20_000);
|
|
340
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { formatConfig, cfg } from "../server/config";
|
|
3
|
+
import { syncTenants, getTenant } from "../database/tenant";
|
|
4
|
+
import { syncWorkflows, ensureWorkflowIndexes, resolveWorkflowsRetention } from "../lib/workflow";
|
|
5
|
+
|
|
6
|
+
const SERVER_RETENTION = "30d";
|
|
7
|
+
|
|
8
|
+
const INHERIT = "wfr-inherit";
|
|
9
|
+
const OVERRIDE = "wfr-override";
|
|
10
|
+
const OFF = "wfr-off";
|
|
11
|
+
|
|
12
|
+
const DBS = {
|
|
13
|
+
[INHERIT]: "mongodb://localhost:27017/_WF_RET_INHERIT",
|
|
14
|
+
[OVERRIDE]: "mongodb://localhost:27017/_WF_RET_OVERRIDE",
|
|
15
|
+
[OFF]: "mongodb://localhost:27017/_WF_RET_OFF",
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
const TTL_INDEX = "_workflow_runs_ttl_";
|
|
19
|
+
|
|
20
|
+
/** TTL index of a tenant's `_workflows_` collection, or undefined */
|
|
21
|
+
async function ttlIndex(tenantId: string): Promise<any> {
|
|
22
|
+
const db = getTenant(tenantId)!.database!.db!;
|
|
23
|
+
return (await db.collection("_workflows_").listIndexes().toArray())
|
|
24
|
+
.find((i: any) => i.name === TTL_INDEX);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
formatConfig({
|
|
29
|
+
server: {
|
|
30
|
+
port: 4000,
|
|
31
|
+
// Server-wide default — inherited unless the tenant declares its own
|
|
32
|
+
workflows: { retention: SERVER_RETENTION },
|
|
33
|
+
},
|
|
34
|
+
tenants: [
|
|
35
|
+
{ id: INHERIT, dir: "packages/core/tests/fixtures/workflow-engine", database: { uri: DBS[INHERIT] } },
|
|
36
|
+
{
|
|
37
|
+
id: OVERRIDE,
|
|
38
|
+
dir: "packages/core/tests/fixtures/workflow-engine",
|
|
39
|
+
database: { uri: DBS[OVERRIDE] },
|
|
40
|
+
workflows: { retention: "1h" },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: OFF,
|
|
44
|
+
dir: "packages/core/tests/fixtures/workflow-engine",
|
|
45
|
+
database: { uri: DBS[OFF] },
|
|
46
|
+
workflows: { retention: false },
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await syncTenants();
|
|
52
|
+
await syncWorkflows();
|
|
53
|
+
await ensureWorkflowIndexes();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterAll(async () => {
|
|
57
|
+
for (const id of Object.keys(DBS)) {
|
|
58
|
+
try { await getTenant(id)?.database?.db?.collection("_workflows_").drop(); } catch (_) {}
|
|
59
|
+
}
|
|
60
|
+
delete (cfg.server as any).workflows;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("workflow run retention", () => {
|
|
64
|
+
it("resolves the tenant first, the server default as fallback", () => {
|
|
65
|
+
expect(resolveWorkflowsRetention({ id: INHERIT, dir: "x", database: { uri: "" } } as any)).toBe(SERVER_RETENTION);
|
|
66
|
+
expect(resolveWorkflowsRetention({ id: OVERRIDE, dir: "x", database: { uri: "" }, workflows: { retention: "1h" } } as any)).toBe("1h");
|
|
67
|
+
// `false` is a value, not an absence — it must override the server default
|
|
68
|
+
expect(resolveWorkflowsRetention({ id: OFF, dir: "x", database: { uri: "" }, workflows: { retention: false } } as any)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("applies the server default to a tenant without its own config", async () => {
|
|
72
|
+
const ttl = await ttlIndex(INHERIT);
|
|
73
|
+
expect(ttl).toBeDefined();
|
|
74
|
+
expect(ttl.key).toEqual({ completedAt: 1 });
|
|
75
|
+
expect(ttl.expireAfterSeconds).toBe(30 * 86400);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("lets the tenant override the server default", async () => {
|
|
79
|
+
const ttl = await ttlIndex(OVERRIDE);
|
|
80
|
+
expect(ttl).toBeDefined();
|
|
81
|
+
expect(ttl.expireAfterSeconds).toBe(3600);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lets a tenant opt out with `false`", async () => {
|
|
85
|
+
expect(await ttlIndex(OFF)).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("only prunes finished runs (`completedAt` is never set on an in-flight one)", async () => {
|
|
89
|
+
const db = getTenant(INHERIT)!.database!.db!;
|
|
90
|
+
const col: any = db.collection("_workflows_");
|
|
91
|
+
await col.insertMany([
|
|
92
|
+
{ _id: "ret-running", workflowId: "simple", status: "running", createdAt: new Date(), updatedAt: new Date() },
|
|
93
|
+
{ _id: "ret-paused", workflowId: "simple", status: "paused", createdAt: new Date(), updatedAt: new Date() },
|
|
94
|
+
{ _id: "ret-done", workflowId: "simple", status: "completed", completedAt: new Date(), createdAt: new Date(), updatedAt: new Date() },
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
const missing = await col.countDocuments({ _id: { $in: ["ret-running", "ret-paused"] }, completedAt: { $exists: false } });
|
|
98
|
+
const finished = await col.countDocuments({ completedAt: { $exists: true } });
|
|
99
|
+
|
|
100
|
+
expect(missing).toBe(2); // never pruned by a TTL on `completedAt`
|
|
101
|
+
expect(finished).toBe(1); // only the finished run can expire
|
|
102
|
+
});
|
|
103
|
+
});
|