@anteros/core 0.0.41 → 0.0.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) 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 +724 -58
  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 +1567 -0
  11. package/lib/agentMemory.ts +689 -0
  12. package/lib/agents.ts +180 -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/mcpClient.ts +128 -0
  25. package/lib/messages.ts +299 -0
  26. package/lib/providers.ts +970 -0
  27. package/lib/routes.ts +14 -1
  28. package/lib/scriptWorker.ts +4 -1
  29. package/lib/scripts.ts +6 -1
  30. package/lib/skills.ts +481 -0
  31. package/lib/sockets.ts +3 -1
  32. package/lib/workflow.ts +209 -37
  33. package/lib/zip.ts +109 -0
  34. package/package.json +1 -1
  35. package/server/access.ts +96 -0
  36. package/server/agents.ts +472 -0
  37. package/server/api.ts +80 -65
  38. package/server/boot.ts +11 -0
  39. package/server/hono.ts +8 -3
  40. package/server/mcp.ts +17 -63
  41. package/server/routes.ts +19 -24
  42. package/tests/agent-mcp.test.ts +116 -0
  43. package/tests/agent-memory.test.ts +617 -0
  44. package/tests/agent-provider.test.ts +288 -0
  45. package/tests/agent-runtime.test.ts +369 -0
  46. package/tests/agent-skills.test.ts +177 -0
  47. package/tests/agent-summarize.test.ts +124 -0
  48. package/tests/agent-working-memory.test.ts +404 -0
  49. package/tests/agent.test.ts +1241 -0
  50. package/tests/agents-api.test.ts +593 -0
  51. package/tests/api-private-fields.test.ts +196 -0
  52. package/tests/api.test.ts +51 -0
  53. package/tests/attachment.test.ts +241 -0
  54. package/tests/audit-operations.test.ts +1 -1
  55. package/tests/audit-redact.test.ts +29 -3
  56. package/tests/audit-run-action.test.ts +1 -1
  57. package/tests/endpoints.test.ts +80 -0
  58. package/tests/file-collection-replication.test.ts +2 -0
  59. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  60. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  61. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  62. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  63. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  64. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  65. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  66. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  67. package/tests/fixtures/attachment/data.csv +2 -0
  68. package/tests/fixtures/fake-provider.ts +128 -0
  69. package/tests/fixtures/repl-only/app.ts +3 -0
  70. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  71. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  72. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  73. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  74. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  75. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  76. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  77. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  78. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  79. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  80. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  81. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  82. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  83. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  84. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  85. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  86. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  87. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  88. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  89. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  90. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  91. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  92. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  93. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  94. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  95. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  96. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  97. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  98. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  99. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  100. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  101. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  102. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  103. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  104. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  105. package/tests/hono.test.ts +6 -3
  106. package/tests/locks.test.ts +120 -0
  107. package/tests/mcp.test.ts +11 -11
  108. package/tests/messages.test.ts +258 -0
  109. package/tests/replication-change-stream.test.ts +240 -0
  110. package/tests/replication-control.test.ts +4 -2
  111. package/tests/replication-initial-sync.test.ts +2 -0
  112. package/tests/replication-meta.test.ts +246 -0
  113. package/tests/replication.test.ts +2 -0
  114. package/tests/upload.test.ts +44 -12
  115. package/tests/vars-api.test.ts +1 -1
  116. package/tests/vars-replication.test.ts +5 -2
  117. package/tests/workflow-engine.test.ts +340 -0
  118. package/tests/workflow-retention.test.ts +103 -0
  119. package/tests/workflow.test.ts +186 -0
  120. package/types/agent.d.ts +1067 -0
  121. package/types/config.d.ts +16 -0
  122. package/types/file.d.ts +7 -7
  123. package/types/hook.d.ts +6 -0
  124. package/types/lifecycle.d.ts +6 -0
  125. package/types/mcp.d.ts +11 -1
  126. package/types/replication.d.ts +70 -4
  127. package/types/route.d.ts +7 -1
  128. package/types/scripts.d.ts +6 -0
  129. package/types/service.d.ts +7 -1
  130. package/types/tenant.d.ts +13 -0
  131. package/types/vars.d.ts +2 -2
  132. package/types/websocket.d.ts +6 -0
  133. package/types/workflow.d.ts +50 -3
@@ -5,6 +5,7 @@ import { formatConfig, cfg } from "../server/config";
5
5
  import { syncTenants } from "../database/tenant";
6
6
  import { getFileCollection } from "../database/file";
7
7
  import { useRest } from "../database/rest";
8
+ import { createApi } from "../lib/api";
8
9
 
9
10
  const TENANT = "up-test";
10
11
  const DB = "mongodb://localhost:27017/_DB_UP_TEST";
@@ -75,7 +76,7 @@ describe("upload — audit trail", () => {
75
76
  form.append("file", pngFile("audited.png"));
76
77
  form.append("label", "audited");
77
78
 
78
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
79
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
79
80
  expect(res.status).toBe(200);
80
81
  const body: any = await res.json();
81
82
 
@@ -101,10 +102,10 @@ describe("upload — audit trail", () => {
101
102
  const form = new FormData();
102
103
  form.append("file", pngFile("deleted.png"));
103
104
  const uploaded: any = await (
104
- await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form })
105
+ await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form })
105
106
  ).json();
106
107
 
107
- const res = await fetch(`${url}/files/${TENANT}/${SLUG}/${uploaded._id}`, { method: "DELETE" });
108
+ const res = await fetch(`${url}/api/${TENANT}/files/${SLUG}/${uploaded._id}`, { method: "DELETE" });
108
109
  expect(res.status).toBe(200);
109
110
 
110
111
  const logged: any = await auditEntry("deleteFile");
@@ -122,7 +123,7 @@ describe("upload — single file (unchanged contract)", () => {
122
123
  form.append("file", pngFile("one.png"));
123
124
  form.append("label", "single");
124
125
 
125
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
126
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
126
127
  expect(res.status).toBe(200);
127
128
 
128
129
  const body: any = await res.json();
@@ -130,7 +131,7 @@ describe("upload — single file (unchanged contract)", () => {
130
131
  expect(body._id).toBeString();
131
132
  expect(body._file.name).toBe("one.png");
132
133
  expect(body._file.mimetype).toBe("image/png");
133
- expect(body._file.url).toContain(`/files/${TENANT}/${SLUG}/`);
134
+ expect(body._file.url).toContain(`/api/${TENANT}/files/${SLUG}/`);
134
135
  expect(body.createdAt).toBeString();
135
136
  expect(body.updatedAt).toBeString();
136
137
 
@@ -148,7 +149,7 @@ describe("upload — single file (unchanged contract)", () => {
148
149
  const form = new FormData();
149
150
  form.append("upload", pngFile("alt.png"));
150
151
 
151
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
152
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
152
153
  expect(res.status).toBe(200);
153
154
  const body: any = await res.json();
154
155
  expect(Array.isArray(body)).toBe(false);
@@ -164,7 +165,7 @@ describe("upload — multiple files", () => {
164
165
  form.append("file", pngFile("c.png"));
165
166
  form.append("label", "batch");
166
167
 
167
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
168
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
168
169
  expect(res.status).toBe(200);
169
170
 
170
171
  const body: any = await res.json();
@@ -178,7 +179,7 @@ describe("upload — multiple files", () => {
178
179
  for (const id of ids) {
179
180
  const doc: any = await rest.findOne(SLUG, id);
180
181
  expect(doc.label).toBe("batch");
181
- expect(doc._file.url).toContain(`/files/${TENANT}/${SLUG}/`);
182
+ expect(doc._file.url).toContain(`/api/${TENANT}/files/${SLUG}/`);
182
183
  // Uploads carry timestamps (required by the replication cursor)
183
184
  expect(doc.createdAt).toBeString();
184
185
  expect(doc.updatedAt).toBeString();
@@ -189,7 +190,7 @@ describe("upload — multiple files", () => {
189
190
  const form = new FormData();
190
191
  form.append("label", "nofile");
191
192
 
192
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
193
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
193
194
  expect(res.status).toBe(400);
194
195
  const body: any = await res.json();
195
196
  expect(body.code).toBe("FILE_REQUIRED");
@@ -200,7 +201,7 @@ describe("upload — multiple files", () => {
200
201
  form.append("file", pngFile("ok.png"));
201
202
  form.append("file", new File([Buffer.from("nope")], "bad.exe", { type: "application/x-msdownload" }));
202
203
 
203
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
204
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
204
205
  expect(res.status).toBe(400);
205
206
  const body: any = await res.json();
206
207
  expect(body.code).toBe("MIMETYPE_NOT_ALLOWED");
@@ -214,7 +215,7 @@ describe("upload — custom field types (toBson, like collections)", () => {
214
215
  form.append("label", "dated");
215
216
  form.append("takenAt", "2024-05-01T10:00:00.000Z");
216
217
 
217
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
218
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
218
219
  expect(res.status).toBe(200);
219
220
  const body: any = await res.json();
220
221
 
@@ -227,7 +228,7 @@ describe("upload — custom field types (toBson, like collections)", () => {
227
228
  const form = new FormData();
228
229
  form.append("file", pngFile("2024-01-01")); // ← a file literally named like a date
229
230
 
230
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
231
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
231
232
  expect(res.status).toBe(200);
232
233
  const body: any = await res.json();
233
234
  expect(body._file.name).toBe("2024-01-01");
@@ -237,3 +238,34 @@ describe("upload — custom field types (toBson, like collections)", () => {
237
238
  expect(typeof raw._file.name).toBe("string");
238
239
  });
239
240
  });
241
+
242
+ describe("api.files — the in-process facade", () => {
243
+ it("builds a file URL, with the transformations the route understands", () => {
244
+ const api = createApi(rest);
245
+
246
+ expect(api.files.url(SLUG, "f1.png")).toBe(`/api/${TENANT}/files/${SLUG}/f1.png`);
247
+ expect(api.files.url(SLUG, "f1.png", { width: 200, format: "webp" }))
248
+ .toBe(`/api/${TENANT}/files/${SLUG}/f1.png?w=200&format=webp`);
249
+ expect(api.files.url(SLUG, "f1.png", { width: 200, height: 100, format: "avif", quality: 70 }))
250
+ .toBe(`/api/${TENANT}/files/${SLUG}/f1.png?w=200&h=100&format=avif&q=70`);
251
+ });
252
+
253
+ it("deletes the binary and the document — the same path as the route", async () => {
254
+ const form = new FormData();
255
+ form.append("file", pngFile("to-delete.png"));
256
+ const uploaded: any = await (
257
+ await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form })
258
+ ).json();
259
+
260
+ const fs = await import("node:fs/promises");
261
+ const storage = getFileCollection(SLUG, TENANT)?.storage as any;
262
+ const dir = `${process.cwd()}/${storage?.path ?? "storage"}/${TENANT}/${SLUG}`;
263
+ expect((await fs.readdir(dir)).some((name) => name.startsWith(uploaded._id))).toBe(true);
264
+
265
+ const result = await createApi(rest).files.delete(SLUG, uploaded._id);
266
+
267
+ expect(result).toEqual({ message: "File deleted", ok: true });
268
+ expect(await rest.findOne(SLUG, uploaded._id)).toBeNull();
269
+ expect((await fs.readdir(dir)).some((name) => name.startsWith(uploaded._id))).toBe(false);
270
+ });
271
+ });
@@ -34,7 +34,7 @@ afterAll(async () => {
34
34
  });
35
35
 
36
36
  const post = (action: string, body: any) =>
37
- fetch(`${url}/vars/${TENANT}/${action}`, {
37
+ fetch(`${url}/api/${TENANT}/vars/${action}`, {
38
38
  method: "POST",
39
39
  headers: { "Content-Type": "application/json" },
40
40
  body: JSON.stringify(body),
@@ -21,6 +21,8 @@ beforeAll(async () => {
21
21
  dir: "packages/core/tests/fixtures/vars-tenant",
22
22
  database: { uri: SRC_DB },
23
23
  replication: {
24
+ // These cases exercise the scan engine (vars namespace filter)
25
+ mode: "scan",
24
26
  runOnBoot: false,
25
27
  schedule: { interval: "1h" },
26
28
  destinations: [{ id: "backup", uri: DEST_DB }],
@@ -85,8 +87,9 @@ describe("vars replication", () => {
85
87
  expect(dest?.value).toBe("SCOPED-1");
86
88
  });
87
89
 
88
- it("does NOT replicate namespaces that did not opt in", async () => {
89
- // `private.var.ts` has no `replication` — it must never reach the destination.
90
+ it("does NOT replicate namespaces that opt out (`enabled: false`)", async () => {
91
+ // `private.var.ts` declares `replication: { enabled: false }` — it must
92
+ // never reach the destination.
90
93
  await rest.vars.set("private", "secret", "top-secret");
91
94
  await replicateTenant(TEST_TENANT);
92
95
 
@@ -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
+ });