@exulu/backend 2.3.0 → 3.0.0

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.
@@ -60,6 +60,17 @@ export type BullMqJobData = {
60
60
  evaluation?: string;
61
61
  item?: string;
62
62
  context?: string;
63
+ // Email-triggered routines (spec 2026-07-15): session-backed workflow runs.
64
+ /** agent_sessions id to run in; when absent the worker creates one. */
65
+ session?: string;
66
+ /** Existing job_results row to UPDATE instead of INSERT (continuation/retry/email intake). */
67
+ jobResultId?: string;
68
+ /** Skip steps before this index (resume after approval pause / retry-from-step). */
69
+ resumeFromIndex?: number;
70
+ /** Persisted to job_results.trigger — run provenance for the runs views. */
71
+ triggerSource?: "email" | "schedule" | "manual" | "api";
72
+ /** Persisted to job_results.trigger_metadata (email: from/subject/message_id; schedule: cron). */
73
+ triggerMetadata?: Record<string, unknown>;
63
74
  };
64
75
 
65
76
  export const bullmqDecorator = async ({
@@ -0,0 +1,41 @@
1
+ import { TERMINAL_JOB_STATES } from "@EXULU_TYPES/enums/jobs";
2
+ import { maybePruneJobResults } from "./prune-job-results";
3
+
4
+ describe("TERMINAL_JOB_STATES", () => {
5
+ it("is the single source of truth for prunable terminal states", () => {
6
+ expect(TERMINAL_JOB_STATES).toEqual(["completed", "failed", "filtered", "cancelled"]);
7
+ });
8
+
9
+ it("never contains live or paused states", () => {
10
+ for (const state of ["waiting", "active", "delayed", "paused", "waiting_approval", "stuck"]) {
11
+ expect(TERMINAL_JOB_STATES).not.toContain(state);
12
+ }
13
+ });
14
+ });
15
+
16
+ describe("maybePruneJobResults", () => {
17
+ it("prunes only TERMINAL_JOB_STATES rows (every 100th call)", async () => {
18
+ const whereInCalls: any[][] = [];
19
+ const builder: any = {
20
+ whereIn: (...args: any[]) => {
21
+ whereInCalls.push(args);
22
+ return builder;
23
+ },
24
+ orderBy: () => builder,
25
+ offset: () => builder,
26
+ limit: () => builder,
27
+ first: async () => undefined, // under cap: nothing to delete
28
+ where: () => builder,
29
+ del: async () => 0,
30
+ };
31
+ const db: any = jest.fn(() => builder);
32
+
33
+ // The module-level counter only reaches the prune body every 100th call.
34
+ for (let i = 0; i < 100; i++) {
35
+ await maybePruneJobResults(db);
36
+ }
37
+
38
+ expect(whereInCalls.length).toBeGreaterThanOrEqual(1);
39
+ expect(whereInCalls[0]).toEqual(["state", TERMINAL_JOB_STATES]);
40
+ });
41
+ });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * We now write a job_results row at enqueue time, so the table grows faster.
5
5
  * To bound it, every PRUNE_EVERY-th call we delete the oldest terminal rows
6
- * (state failed/completed) beyond the newest MAX_TERMINAL — keeping a rolling
6
+ * (states in TERMINAL_JOB_STATES) beyond the newest MAX_TERMINAL — keeping a rolling
7
7
  * window of recent finished jobs. Waiting/active/delayed rows are never
8
8
  * pruned (they're still live).
9
9
  *
@@ -13,9 +13,10 @@
13
13
  * overlapping runs.
14
14
  */
15
15
 
16
+ import { TERMINAL_JOB_STATES } from "@EXULU_TYPES/enums/jobs";
17
+
16
18
  const MAX_TERMINAL = 10_000;
17
19
  const PRUNE_EVERY = 100;
18
- const TERMINAL_STATES = ["failed", "completed"];
19
20
 
20
21
  let sinceLastPrune = 0;
21
22
  let pruning = false;
@@ -30,7 +31,7 @@ export async function maybePruneJobResults(db: any): Promise<void> {
30
31
  // and everything older. Dialect-agnostic (knex offset/limit) so it works on
31
32
  // both Postgres and MySQL.
32
33
  const boundary = await db("job_results")
33
- .whereIn("state", TERMINAL_STATES)
34
+ .whereIn("state", TERMINAL_JOB_STATES)
34
35
  .orderBy("createdAt", "desc")
35
36
  .offset(MAX_TERMINAL)
36
37
  .limit(1)
@@ -38,7 +39,7 @@ export async function maybePruneJobResults(db: any): Promise<void> {
38
39
 
39
40
  if (boundary?.createdAt) {
40
41
  const deleted = await db("job_results")
41
- .whereIn("state", TERMINAL_STATES)
42
+ .whereIn("state", TERMINAL_JOB_STATES)
42
43
  .where("createdAt", "<=", boundary.createdAt)
43
44
  .del();
44
45
  if (deleted) {
package/ee/schemas.ts CHANGED
@@ -260,6 +260,26 @@ export const jobResultsSchema: ExuluTableDefinition = {
260
260
  name: "type",
261
261
  type: "text",
262
262
  },
263
+ // Email-triggered routines (spec 2026-07-15 §3.3): run provenance +
264
+ // session cross-link. `workflow` replaces label-substring filtering
265
+ // (indexed via the composite index created in init-exulu-db.ts).
266
+ // Pre-migration rows keep trigger = NULL (displayed as "—").
267
+ {
268
+ name: "trigger",
269
+ type: "text",
270
+ },
271
+ {
272
+ name: "trigger_metadata",
273
+ type: "json",
274
+ },
275
+ {
276
+ name: "session",
277
+ type: "text",
278
+ },
279
+ {
280
+ name: "workflow",
281
+ type: "text",
282
+ },
263
283
  ],
264
284
  };
265
285
 
@@ -392,5 +412,80 @@ export const workflowTemplatesSchema: ExuluTableDefinition = {
392
412
  type: "json",
393
413
  required: true,
394
414
  },
415
+ // Escape hatch for the approval behavior change (spec §5.2): when true
416
+ // the run keeps the legacy blanket tool pre-approval and never pauses.
417
+ {
418
+ name: "auto_approve_tools",
419
+ type: "boolean",
420
+ default: false,
421
+ },
395
422
  ],
396
- };
423
+ };
424
+
425
+ // Email-triggered routines (spec §3.1): one inbound trigger per routine.
426
+ // RBAC is false — access is checked via the parent workflow_templates row
427
+ // (routine read for listing, routine write + workflows:write role for CRUD),
428
+ // resolved explicitly in the custom GraphQL resolvers. graphql: false keeps
429
+ // the auto-CRUD generator away from this table; the API surface is the
430
+ // custom workflowTriggers / upsertWorkflowEmailTrigger / deleteWorkflowTrigger
431
+ // resolvers only.
432
+ export const workflowTriggersSchema: ExuluTableDefinition = {
433
+ type: "workflow_triggers",
434
+ name: {
435
+ plural: "workflow_triggers",
436
+ singular: "workflow_trigger",
437
+ },
438
+ RBAC: false,
439
+ graphql: false,
440
+ fields: [
441
+ {
442
+ name: "workflow",
443
+ type: "uuid",
444
+ required: true,
445
+ },
446
+ {
447
+ // 'email' for now; extensible ('webhook' later).
448
+ name: "type",
449
+ type: "text",
450
+ required: true,
451
+ },
452
+ {
453
+ name: "enabled",
454
+ type: "boolean",
455
+ default: false,
456
+ },
457
+ {
458
+ // Generated server-side: {routine-slug}-{8 hex}@{inbound_domain}.
459
+ // Real UNIQUE column (not JSON) because the webhook resolves
460
+ // triggers by recipient address.
461
+ name: "address",
462
+ type: "text",
463
+ required: true,
464
+ unique: true,
465
+ index: true,
466
+ },
467
+ {
468
+ // allowed_senders / filters / filtered_run_retention /
469
+ // rate_limit_per_hour / sender_rate_limit_per_hour (spec §3.1).
470
+ name: "config",
471
+ type: "json",
472
+ required: true,
473
+ },
474
+ {
475
+ // Captured from the admin who saves the trigger; email runs execute
476
+ // under this identity (same principle as cron).
477
+ name: "run_as_user",
478
+ type: "number",
479
+ },
480
+ {
481
+ name: "run_as_role",
482
+ type: "uuid",
483
+ },
484
+ {
485
+ // RBAC:false means addCoreFields does not add created_by; add it
486
+ // explicitly (audit trail, spec §3.1 core fields).
487
+ name: "created_by",
488
+ type: "number",
489
+ },
490
+ ],
491
+ };
@@ -0,0 +1,236 @@
1
+ import type { UIMessage } from "ai";
2
+
3
+ // ee/workers.ts pulls in the whole worker runtime; mock everything with
4
+ // side effects / heavy transitive imports. Specifiers match workers.ts's
5
+ // own import strings (moduleNameMapper resolves both aliased forms).
6
+ jest.mock("@SRC/postgres/client", () => ({
7
+ postgresClient: jest.fn(async () => ({ db: jest.fn() })),
8
+ }));
9
+ jest.mock("@SRC/utils/enabled-tools.ts", () => ({
10
+ getEnabledTools: jest.fn(async () => []),
11
+ }));
12
+ jest.mock("@SRC/exulu/resolve-model.ts", () => ({
13
+ resolveModel: jest.fn(async () => ({ apiKey: undefined, languageModel: {} })),
14
+ }));
15
+ jest.mock("@SRC/exulu/statistics", () => ({
16
+ updateStatistic: jest.fn(async () => undefined),
17
+ }));
18
+ jest.mock("@SRC/exulu/storage.ts", () => ({ ExuluStorage: class {} }));
19
+ jest.mock("@SRC/exulu/context.ts", () => ({ getTableName: jest.fn() }));
20
+ jest.mock("@SRC/exulu/app/singleton", () => ({ exuluApp: { get: jest.fn() } }));
21
+ jest.mock("@SRC/exulu/provider.ts", () => ({
22
+ saveChat: jest.fn(async () => undefined),
23
+ getAgentMessages: jest.fn(async () => []),
24
+ }));
25
+
26
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
27
+ const providerModule = require("@SRC/exulu/provider.ts") as {
28
+ saveChat: jest.Mock;
29
+ getAgentMessages: jest.Mock;
30
+ };
31
+
32
+ import { FlowStepError, processUiMessagesFlow } from "./workers";
33
+
34
+ const step = (id: string, text: string): UIMessage =>
35
+ ({ id, role: "user", parts: [{ type: "text", text }] }) as UIMessage;
36
+
37
+ const assistant = (id: string, parts: any[]): UIMessage =>
38
+ ({ id, role: "assistant", parts }) as UIMessage;
39
+
40
+ const approvalPart = {
41
+ type: "tool-create_offer",
42
+ state: "approval-requested",
43
+ approval: { id: "appr-1" },
44
+ };
45
+
46
+ /**
47
+ * Stub ExuluProvider: generateStream returns a fake AI-SDK stream whose
48
+ * toUIMessageStream immediately finishes with [history + step + response].
49
+ * `responses[n]` = assistant messages appended by the n-th generateStream call.
50
+ * A response of `null` makes that call's stream error (onError + reject).
51
+ */
52
+ const makeStubProvider = (responses: (UIMessage[] | null)[]) => {
53
+ let call = 0;
54
+ const generateStream = jest.fn(async (opts: any) => {
55
+ const index = call++;
56
+ const original: UIMessage[] = [...(opts.previousMessages ?? []), opts.message];
57
+ return {
58
+ originalMessages: original,
59
+ previousMessages: opts.previousMessages ?? [],
60
+ stream: {
61
+ toUIMessageStream: (streamOpts: any) => ({
62
+ async *[Symbol.asyncIterator]() {
63
+ const response = responses[index];
64
+ if (response === null) {
65
+ streamOpts.onError(new Error("provider exploded"));
66
+ return;
67
+ }
68
+ await streamOpts.onFinish({ messages: [...original, ...(response ?? [])] });
69
+ },
70
+ }),
71
+ },
72
+ };
73
+ });
74
+ return { provider: { generateStream } as any, generateStream };
75
+ };
76
+
77
+ const baseArgs = (provider: any) => ({
78
+ providers: [] as any[],
79
+ agent: { id: "agent-1", name: "Agent", model: "model-1", tools: [], instructions: "do" } as any,
80
+ provider,
81
+ contexts: [] as any[],
82
+ user: { id: 7, role: { id: "role-1" } } as any,
83
+ tools: [{ name: "Create Offer" }] as any[],
84
+ config: {} as any,
85
+ });
86
+
87
+ afterEach(() => jest.clearAllMocks());
88
+
89
+ describe("processUiMessagesFlow (headless — unchanged legacy behavior)", () => {
90
+ it("passes session undefined + blanket approvedTools and never persists", async () => {
91
+ const { provider, generateStream } = makeStubProvider([[assistant("a1", [{ type: "text", text: "ok" }])]]);
92
+ const result = await processUiMessagesFlow({
93
+ ...baseArgs(provider),
94
+ inputMessages: [step("s1", "hello")],
95
+ });
96
+ expect(generateStream).toHaveBeenCalledTimes(1);
97
+ const opts = generateStream.mock.calls[0][0];
98
+ expect(opts.session).toBeUndefined();
99
+ expect(Array.isArray(opts.approvedTools)).toBe(true);
100
+ expect(providerModule.saveChat).not.toHaveBeenCalled();
101
+ expect(result.pausedAtStepIndex).toBeUndefined();
102
+ expect(result.messages.map((m) => m.id)).toContain("a1");
103
+ });
104
+ });
105
+
106
+ describe("processUiMessagesFlow (session-backed)", () => {
107
+ it("passes the session, rewrites step ids, and persists at each step boundary", async () => {
108
+ const { provider, generateStream } = makeStubProvider([
109
+ [assistant("a1", [{ type: "text", text: "one" }])],
110
+ [assistant("a2", [{ type: "text", text: "two" }])],
111
+ ]);
112
+ await processUiMessagesFlow({
113
+ ...baseArgs(provider),
114
+ inputMessages: [step("s1", "first"), step("s2", "second")],
115
+ sessionId: "sess-1",
116
+ });
117
+ expect(generateStream).toHaveBeenCalledTimes(2);
118
+ for (const call of generateStream.mock.calls) {
119
+ expect(call[0].session).toBe("sess-1");
120
+ // steps_json ids repeat across runs — persisted ids must be fresh:
121
+ expect(call[0].message.id).toMatch(/^wfmsg-/);
122
+ }
123
+ expect(providerModule.saveChat).toHaveBeenCalledTimes(2);
124
+ expect(providerModule.saveChat.mock.calls[0][0]).toMatchObject({ session: "sess-1", user: 7 });
125
+ });
126
+
127
+ it("drops the blanket approvedTools when respectToolApprovals is set", async () => {
128
+ const { provider, generateStream } = makeStubProvider([[assistant("a1", [{ type: "text", text: "ok" }])]]);
129
+ await processUiMessagesFlow({
130
+ ...baseArgs(provider),
131
+ inputMessages: [step("s1", "x")],
132
+ sessionId: "sess-1",
133
+ respectToolApprovals: true,
134
+ });
135
+ expect(generateStream.mock.calls[0][0].approvedTools).toBeUndefined();
136
+ });
137
+
138
+ it("pauses at the step whose final message requests approval and skips later steps", async () => {
139
+ const { provider, generateStream } = makeStubProvider([
140
+ [assistant("a1", [approvalPart])],
141
+ [assistant("a2", [{ type: "text", text: "never reached" }])],
142
+ ]);
143
+ const result = await processUiMessagesFlow({
144
+ ...baseArgs(provider),
145
+ inputMessages: [step("s1", "gated"), step("s2", "after")],
146
+ sessionId: "sess-1",
147
+ respectToolApprovals: true,
148
+ });
149
+ expect(result.pausedAtStepIndex).toBe(0);
150
+ expect(generateStream).toHaveBeenCalledTimes(1);
151
+ // The paused transcript was persisted before returning:
152
+ expect(providerModule.saveChat).toHaveBeenCalledTimes(1);
153
+ });
154
+
155
+ it("resumeFromIndex skips completed steps and reloads history from agent_messages", async () => {
156
+ providerModule.getAgentMessages.mockResolvedValueOnce([
157
+ { content: JSON.stringify(step("old-1", "first")) },
158
+ { content: JSON.stringify(assistant("old-a1", [{ type: "text", text: "done" }])) },
159
+ ]);
160
+ const { provider, generateStream } = makeStubProvider([
161
+ [assistant("a2", [{ type: "text", text: "resumed" }])],
162
+ ]);
163
+ const result = await processUiMessagesFlow({
164
+ ...baseArgs(provider),
165
+ inputMessages: [step("s1", "first"), step("s2", "second")],
166
+ sessionId: "sess-1",
167
+ resumeFromIndex: 1,
168
+ });
169
+ expect(providerModule.getAgentMessages).toHaveBeenCalledWith({
170
+ session: "sess-1",
171
+ includeAllUsers: true,
172
+ });
173
+ expect(generateStream).toHaveBeenCalledTimes(1); // only step index 1
174
+ expect(generateStream.mock.calls[0][0].previousMessages.map((m: UIMessage) => m.id)).toEqual([
175
+ "old-1",
176
+ "old-a1",
177
+ ]);
178
+ expect(result.messages.map((m) => m.id)).toContain("a2");
179
+ });
180
+
181
+ it("wraps step failures in FlowStepError carrying the failing step index", async () => {
182
+ const { provider } = makeStubProvider([
183
+ [assistant("a1", [{ type: "text", text: "ok" }])],
184
+ null, // step 1 explodes
185
+ ]);
186
+ const promise = processUiMessagesFlow({
187
+ ...baseArgs(provider),
188
+ inputMessages: [step("s1", "one"), step("s2", "two")],
189
+ sessionId: "sess-1",
190
+ });
191
+ await expect(promise).rejects.toThrow("provider exploded");
192
+ await promise.catch((error: unknown) => {
193
+ expect(error).toBeInstanceOf(FlowStepError);
194
+ expect((error as FlowStepError).stepIndex).toBe(1);
195
+ });
196
+ });
197
+
198
+ it("a rerun after a step-1 failure persists only steps >= 1 — no duplicate messages (spec §5.4/§9)", async () => {
199
+ // First run: step 0 succeeds (one boundary persist), step 1 explodes.
200
+ const first = makeStubProvider([
201
+ [assistant("a1", [{ type: "text", text: "one" }])],
202
+ null, // step 1 explodes
203
+ ]);
204
+ await expect(
205
+ processUiMessagesFlow({
206
+ ...baseArgs(first.provider),
207
+ inputMessages: [step("s1", "one"), step("s2", "two")],
208
+ sessionId: "sess-1",
209
+ }),
210
+ ).rejects.toThrow("provider exploded");
211
+ expect(providerModule.saveChat).toHaveBeenCalledTimes(1); // step 0 only
212
+
213
+ // Rerun from the failed step (what the worker's retry loop does with
214
+ // FlowStepError.stepIndex): prior history reloads from agent_messages;
215
+ // step 0 must NOT run or persist again.
216
+ providerModule.saveChat.mockClear();
217
+ providerModule.getAgentMessages.mockResolvedValueOnce([
218
+ { content: JSON.stringify(step("old-s1", "one")) },
219
+ { content: JSON.stringify(assistant("a1", [{ type: "text", text: "one" }])) },
220
+ ]);
221
+ const second = makeStubProvider([[assistant("a2", [{ type: "text", text: "two" }])]]);
222
+ await processUiMessagesFlow({
223
+ ...baseArgs(second.provider),
224
+ inputMessages: [step("s1", "one"), step("s2", "two")],
225
+ sessionId: "sess-1",
226
+ resumeFromIndex: 1,
227
+ });
228
+ expect(second.generateStream).toHaveBeenCalledTimes(1); // only step index 1
229
+ expect(providerModule.saveChat).toHaveBeenCalledTimes(1); // only the step-1 boundary
230
+ const persisted = providerModule.saveChat.mock.calls[0][0].messages as UIMessage[];
231
+ expect(persisted.map((m) => m.id)).toContain("a2");
232
+ // Step 0's message reaches saveChat only via the reloaded history (same
233
+ // ids — saveChat's message_id merge keeps it a no-op), never as a re-run.
234
+ expect(persisted.filter((m) => m.id === "a1")).toHaveLength(1);
235
+ });
236
+ });