@agentium/queue 3.1.2 → 4.1.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.
- package/README.md +63 -19
- package/dist/connection.d.ts +27 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/durable.d.ts +58 -0
- package/dist/durable.d.ts.map +1 -0
- package/dist/index.cjs +480 -298
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +477 -278
- package/dist/job-producer.d.ts +47 -21
- package/dist/job-producer.d.ts.map +1 -1
- package/dist/job-types.d.ts +4 -0
- package/dist/job-types.d.ts.map +1 -1
- package/dist/job-worker.d.ts +0 -2
- package/dist/job-worker.d.ts.map +1 -1
- package/package.json +10 -7
package/dist/index.js
CHANGED
|
@@ -1,284 +1,483 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { durableCanonical, durableTaskDigest } from "@agentium/core";
|
|
4
|
+
//#region src/connection.ts
|
|
5
|
+
function queueConnection(value) {
|
|
6
|
+
if (typeof value !== "string") return {
|
|
7
|
+
...value,
|
|
8
|
+
...value.tls ? { tls: {} } : { tls: void 0 }
|
|
9
|
+
};
|
|
10
|
+
const url = new URL(value);
|
|
11
|
+
if (!["redis:", "rediss:"].includes(url.protocol)) throw new Error("Queue connection URL must use redis or rediss");
|
|
12
|
+
const db = url.pathname === "/" || !url.pathname ? 0 : Number(url.pathname.slice(1));
|
|
13
|
+
if (!Number.isSafeInteger(db) || db < 0) throw new Error("Invalid Redis database");
|
|
14
|
+
return {
|
|
15
|
+
host: url.hostname.replace(/^\[(.*)\]$/, "$1"),
|
|
16
|
+
port: Number(url.port || 6379),
|
|
17
|
+
db,
|
|
18
|
+
...url.username ? { username: decodeURIComponent(url.username) } : {},
|
|
19
|
+
...url.password ? { password: decodeURIComponent(url.password) } : {},
|
|
20
|
+
...url.protocol === "rediss:" ? { tls: {} } : {}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/durable.ts
|
|
25
|
+
const requireQueue = createRequire(import.meta.url);
|
|
26
|
+
function envelope(task) {
|
|
27
|
+
return {
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
type: "durable",
|
|
30
|
+
tenantId: task.identity.tenantId,
|
|
31
|
+
taskId: task.id,
|
|
32
|
+
inputDigest: durableTaskDigest(task)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function parseEnvelope(value) {
|
|
36
|
+
if (!value || typeof value !== "object") throw new Error("Invalid durable queue envelope");
|
|
37
|
+
const input = value;
|
|
38
|
+
if (Object.keys(input).some((key) => ![
|
|
39
|
+
"schemaVersion",
|
|
40
|
+
"type",
|
|
41
|
+
"tenantId",
|
|
42
|
+
"taskId",
|
|
43
|
+
"inputDigest"
|
|
44
|
+
].includes(key)) || input.schemaVersion !== 1 || input.type !== "durable" || typeof input.tenantId !== "string" || !input.tenantId.trim() || input.tenantId.length > 512 || typeof input.taskId !== "string" || !input.taskId.trim() || input.taskId.length > 512 || typeof input.inputDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(input.inputDigest)) throw new Error("Invalid durable queue envelope");
|
|
45
|
+
return input;
|
|
46
|
+
}
|
|
47
|
+
/** Queue delivery is a hint; Mongo/another atomic task store owns authoritative state.
|
|
48
|
+
* Persistence precedes enqueue. If Redis fails, call wake() after retrying admission.
|
|
49
|
+
* The stable task key prevents a delivery retry from creating a second execution.
|
|
50
|
+
*/
|
|
51
|
+
var DurableAgentQueue = class {
|
|
52
|
+
config;
|
|
53
|
+
queue;
|
|
54
|
+
constructor(config) {
|
|
55
|
+
this.config = config;
|
|
56
|
+
const { Queue } = requireQueue("bullmq");
|
|
57
|
+
this.queue = new Queue(config.queueName ?? "agentium-durable", {
|
|
58
|
+
connection: queueConnection(config.connection),
|
|
59
|
+
defaultJobOptions: config.defaultJobOptions
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async enqueue(task) {
|
|
63
|
+
const copy = JSON.parse(durableCanonical(task));
|
|
64
|
+
if (!copy.driver?.id || !Number.isSafeInteger(copy.driver.version) || copy.driver.version < 1) throw new Error("Durable tasks require a versioned driver reference");
|
|
65
|
+
await this.config.admit(structuredClone(copy));
|
|
66
|
+
const key = {
|
|
67
|
+
tenantId: copy.identity.tenantId,
|
|
68
|
+
taskId: copy.id
|
|
69
|
+
};
|
|
70
|
+
const existing = await this.config.supervisor.get(key);
|
|
71
|
+
if (existing) {
|
|
72
|
+
if (durableTaskDigest(existing) !== durableTaskDigest(copy)) throw new Error("Task ID already has a different immutable envelope");
|
|
73
|
+
} else try {
|
|
74
|
+
await this.config.supervisor.create(copy);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const raced = await this.config.supervisor.get(key);
|
|
77
|
+
if (!raced || durableTaskDigest(raced) !== durableTaskDigest(copy)) throw error;
|
|
78
|
+
}
|
|
79
|
+
return this.wake(key);
|
|
80
|
+
}
|
|
81
|
+
/** Explicit re-delivery after approval, reconciliation, or a failed initial enqueue. */
|
|
82
|
+
async wake(key) {
|
|
83
|
+
const task = await this.config.supervisor.get(key);
|
|
84
|
+
if (!task) throw new Error("Durable task not found");
|
|
85
|
+
await this.config.admit(structuredClone(task));
|
|
86
|
+
const job = await this.queue.add("durable", envelope(task), {
|
|
87
|
+
jobId: randomUUID(),
|
|
88
|
+
removeOnComplete: true
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
taskId: task.id,
|
|
92
|
+
deliveryId: job.id
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async cancel(key, reason) {
|
|
96
|
+
const task = await this.config.supervisor.get(key);
|
|
97
|
+
if (!task) throw new Error("Durable task not found");
|
|
98
|
+
await this.config.admit(structuredClone(task));
|
|
99
|
+
await this.config.supervisor.cancel(key, reason);
|
|
100
|
+
await this.wake(key);
|
|
101
|
+
}
|
|
102
|
+
async close() {
|
|
103
|
+
await this.queue.close();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
var DurableAgentWorker = class {
|
|
107
|
+
worker;
|
|
108
|
+
constructor(config) {
|
|
109
|
+
const drivers = /* @__PURE__ */ new Map();
|
|
110
|
+
for (const driver of config.drivers) {
|
|
111
|
+
const key = `${driver.id}@${driver.version}`;
|
|
112
|
+
if (!driver.id || !Number.isSafeInteger(driver.version) || driver.version < 1 || driver.recoverable !== true || drivers.has(key)) throw new Error("Durable drivers require unique versioned, explicitly recoverable registrations");
|
|
113
|
+
drivers.set(key, driver);
|
|
114
|
+
}
|
|
115
|
+
const workerId = config.workerId ?? `worker-${randomUUID()}`;
|
|
116
|
+
if (!Number.isSafeInteger(config.concurrency ?? 5) || (config.concurrency ?? 5) < 1) throw new Error("Worker concurrency must be positive");
|
|
117
|
+
const { Worker, DelayedError } = requireQueue("bullmq");
|
|
118
|
+
this.worker = new Worker(config.queueName ?? "agentium-durable", async (job, token) => {
|
|
119
|
+
const delivery = parseEnvelope(job.data);
|
|
120
|
+
const key = {
|
|
121
|
+
tenantId: delivery.tenantId,
|
|
122
|
+
taskId: delivery.taskId
|
|
123
|
+
};
|
|
124
|
+
const task = await config.supervisor.get(key);
|
|
125
|
+
if (!task || envelope(task).inputDigest !== delivery.inputDigest) throw new Error("Durable delivery does not match persisted task");
|
|
126
|
+
await config.admit(structuredClone(task));
|
|
127
|
+
const driver = task.driver ? drivers.get(`${task.driver.id}@${task.driver.version}`) : void 0;
|
|
128
|
+
if (!driver) throw new Error("Durable execution driver is not registered");
|
|
129
|
+
const result = await config.supervisor.run(key, workerId, async (context) => {
|
|
130
|
+
await config.admit(structuredClone(context.task));
|
|
131
|
+
return driver.execute(context);
|
|
132
|
+
});
|
|
133
|
+
if (result.lease && (result.state === "running" || result.state === "cancel_requested")) {
|
|
134
|
+
await job.moveToDelayed(Date.now() + Math.max(25, Math.min(config.supervisor.leaseMs, result.lease.expiresAt - result.updatedAt)), token);
|
|
135
|
+
throw new DelayedError();
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
taskId: result.id,
|
|
139
|
+
state: result.state,
|
|
140
|
+
revision: result.revision
|
|
141
|
+
};
|
|
142
|
+
}, {
|
|
143
|
+
connection: queueConnection(config.connection),
|
|
144
|
+
concurrency: config.concurrency ?? 5
|
|
145
|
+
});
|
|
146
|
+
this.worker.on("error", (error) => config.onError?.(error));
|
|
147
|
+
}
|
|
148
|
+
/** Drains owned work. Active durable cancellation is explicit through queue.cancel(). */
|
|
149
|
+
async stop() {
|
|
150
|
+
await this.worker.close();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/job-producer.ts
|
|
155
|
+
const require$1 = createRequire(import.meta.url);
|
|
156
|
+
const LEGACY_MIGRATION = "Legacy repeat schedule maintenance requires BullMQ v5. Stop writers/workers, use BullMQ 5.81.5 to pause, drain, inventory and migrate legacy repeats, then restart with BullMQ v6.";
|
|
9
157
|
var AgentQueue = class {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
158
|
+
queue;
|
|
159
|
+
queueEvents;
|
|
160
|
+
queueName;
|
|
161
|
+
constructor(config) {
|
|
162
|
+
this.queueName = config.queueName ?? "agentium-jobs";
|
|
163
|
+
const connection = queueConnection(config.connection);
|
|
164
|
+
try {
|
|
165
|
+
const { Queue, QueueEvents } = require$1("bullmq");
|
|
166
|
+
this.queue = new Queue(this.queueName, {
|
|
167
|
+
connection,
|
|
168
|
+
defaultJobOptions: config.defaultJobOptions
|
|
169
|
+
});
|
|
170
|
+
this.queueEvents = new QueueEvents(this.queueName, { connection });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err?.code === "MODULE_NOT_FOUND" || err?.message?.includes("Cannot find module")) throw new Error("bullmq and ioredis are required for AgentQueue. Install them: npm install bullmq ioredis");
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async enqueueAgentRun(opts) {
|
|
177
|
+
const payload = {
|
|
178
|
+
type: "agent",
|
|
179
|
+
agentName: opts.agentName,
|
|
180
|
+
input: opts.input,
|
|
181
|
+
sessionId: opts.sessionId,
|
|
182
|
+
tenantId: opts.tenantId,
|
|
183
|
+
userId: opts.userId
|
|
184
|
+
};
|
|
185
|
+
const jobOpts = {};
|
|
186
|
+
if (opts.priority !== void 0) jobOpts.priority = opts.priority;
|
|
187
|
+
if (opts.delay !== void 0) jobOpts.delay = opts.delay;
|
|
188
|
+
if (opts.attempts !== void 0) jobOpts.attempts = opts.attempts;
|
|
189
|
+
if (opts.backoff) jobOpts.backoff = opts.backoff;
|
|
190
|
+
if (opts.repeat) return this.enqueueRecurring(`agent:${opts.agentName}`, payload, opts.repeat, jobOpts);
|
|
191
|
+
return { jobId: (await this.queue.add(`agent:${opts.agentName}`, payload, jobOpts)).id };
|
|
192
|
+
}
|
|
193
|
+
async enqueueWorkflow(opts) {
|
|
194
|
+
const payload = {
|
|
195
|
+
type: "workflow",
|
|
196
|
+
workflowName: opts.workflowName,
|
|
197
|
+
userId: opts.userId,
|
|
198
|
+
initialState: opts.initialState,
|
|
199
|
+
sessionId: opts.sessionId,
|
|
200
|
+
tenantId: opts.tenantId
|
|
201
|
+
};
|
|
202
|
+
const jobOpts = {};
|
|
203
|
+
if (opts.priority !== void 0) jobOpts.priority = opts.priority;
|
|
204
|
+
if (opts.delay !== void 0) jobOpts.delay = opts.delay;
|
|
205
|
+
if (opts.attempts !== void 0) jobOpts.attempts = opts.attempts;
|
|
206
|
+
if (opts.backoff) jobOpts.backoff = opts.backoff;
|
|
207
|
+
if (opts.repeat) return this.enqueueRecurring(`workflow:${opts.workflowName}`, payload, opts.repeat, jobOpts);
|
|
208
|
+
return { jobId: (await this.queue.add(`workflow:${opts.workflowName}`, payload, jobOpts)).id };
|
|
209
|
+
}
|
|
210
|
+
async enqueueTeamRun(opts) {
|
|
211
|
+
const payload = {
|
|
212
|
+
type: "team",
|
|
213
|
+
teamName: opts.teamName,
|
|
214
|
+
input: opts.input,
|
|
215
|
+
sessionId: opts.sessionId,
|
|
216
|
+
tenantId: opts.tenantId,
|
|
217
|
+
userId: opts.userId
|
|
218
|
+
};
|
|
219
|
+
const jobOpts = {};
|
|
220
|
+
if (opts.priority !== void 0) jobOpts.priority = opts.priority;
|
|
221
|
+
if (opts.delay !== void 0) jobOpts.delay = opts.delay;
|
|
222
|
+
if (opts.attempts !== void 0) jobOpts.attempts = opts.attempts;
|
|
223
|
+
if (opts.backoff) jobOpts.backoff = opts.backoff;
|
|
224
|
+
if (opts.repeat) return this.enqueueRecurring(`team:${opts.teamName}`, payload, opts.repeat, jobOpts);
|
|
225
|
+
return { jobId: (await this.queue.add(`team:${opts.teamName}`, payload, jobOpts)).id };
|
|
226
|
+
}
|
|
227
|
+
async getJobStatus(jobId) {
|
|
228
|
+
const job = await this.queue.getJob(jobId);
|
|
229
|
+
if (!job) throw new Error(`Job ${jobId} not found`);
|
|
230
|
+
const state = await job.getState();
|
|
231
|
+
return {
|
|
232
|
+
jobId: job.id,
|
|
233
|
+
state,
|
|
234
|
+
progress: typeof job.progress === "number" ? job.progress : void 0,
|
|
235
|
+
result: job.returnvalue,
|
|
236
|
+
error: job.failedReason,
|
|
237
|
+
createdAt: new Date(job.timestamp),
|
|
238
|
+
processedAt: job.processedOn ? new Date(job.processedOn) : void 0,
|
|
239
|
+
finishedAt: job.finishedOn ? new Date(job.finishedOn) : void 0
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
async cancelJob(jobId) {
|
|
243
|
+
const job = await this.queue.getJob(jobId);
|
|
244
|
+
if (job) await job.remove();
|
|
245
|
+
}
|
|
246
|
+
onCompleted(handler) {
|
|
247
|
+
this.queueEvents.on("completed", ({ jobId, returnvalue }) => {
|
|
248
|
+
handler(jobId, returnvalue);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
onFailed(handler) {
|
|
252
|
+
this.queueEvents.on("failed", ({ jobId, failedReason }) => {
|
|
253
|
+
handler(jobId, new Error(failedReason));
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async enqueueRecurring(name, data, repeat, options) {
|
|
257
|
+
const id = `repeat-${createHash("sha256").update(JSON.stringify({
|
|
258
|
+
name,
|
|
259
|
+
data,
|
|
260
|
+
repeat
|
|
261
|
+
})).digest("hex")}`;
|
|
262
|
+
await this.assertNoLegacySchedule(name, repeat.pattern);
|
|
263
|
+
return { jobId: (await this.queue.upsertJobScheduler(id, {
|
|
264
|
+
pattern: repeat.pattern,
|
|
265
|
+
tz: repeat.timezone
|
|
266
|
+
}, {
|
|
267
|
+
name,
|
|
268
|
+
data,
|
|
269
|
+
opts: options
|
|
270
|
+
})).id };
|
|
271
|
+
}
|
|
272
|
+
async schedulerRecords() {
|
|
273
|
+
try {
|
|
274
|
+
return await this.queue.getJobSchedulers(0, -1, true);
|
|
275
|
+
} catch (cause) {
|
|
276
|
+
if (cause instanceof Error && /legacy repeatable job/i.test(cause.message)) throw new Error(LEGACY_MIGRATION, { cause });
|
|
277
|
+
throw cause;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
hasLegacyMaintenance() {
|
|
281
|
+
return typeof this.queue.getRepeatableJobs === "function" && typeof this.queue.removeRepeatableByKey === "function";
|
|
282
|
+
}
|
|
283
|
+
async assertNoLegacySchedule(name, pattern) {
|
|
284
|
+
if (!this.hasLegacyMaintenance()) {
|
|
285
|
+
if ((await this.schedulerRecords()).some((job) => typeof job.iterationCount !== "number")) throw new Error(LEGACY_MIGRATION);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if ((await this.listLegacySchedules()).some((item) => item.name === name && item.pattern === pattern)) throw new Error("A legacy repeat schedule already exists; pause and explicitly migrate it before creating a Job Scheduler");
|
|
289
|
+
}
|
|
290
|
+
async schedule(opts) {
|
|
291
|
+
if (!opts.id || !opts.cron) throw new Error("schedule() requires a stable id and cron");
|
|
292
|
+
if ([
|
|
293
|
+
opts.agent,
|
|
294
|
+
opts.workflow,
|
|
295
|
+
opts.team
|
|
296
|
+
].filter(Boolean).length !== 1) throw new Error("schedule() requires exactly one agent, workflow or team");
|
|
297
|
+
let data;
|
|
298
|
+
let name;
|
|
299
|
+
if (opts.agent) {
|
|
300
|
+
name = `agent:${opts.agent.name}`;
|
|
301
|
+
data = {
|
|
302
|
+
type: "agent",
|
|
303
|
+
agentName: opts.agent.name,
|
|
304
|
+
input: opts.agent.input,
|
|
305
|
+
sessionId: opts.agent.sessionId,
|
|
306
|
+
userId: opts.agent.userId,
|
|
307
|
+
tenantId: opts.agent.tenantId
|
|
308
|
+
};
|
|
309
|
+
} else if (opts.team) {
|
|
310
|
+
name = `team:${opts.team.name}`;
|
|
311
|
+
data = {
|
|
312
|
+
type: "team",
|
|
313
|
+
teamName: opts.team.name,
|
|
314
|
+
input: opts.team.input,
|
|
315
|
+
sessionId: opts.team.sessionId,
|
|
316
|
+
userId: opts.team.userId,
|
|
317
|
+
tenantId: opts.team.tenantId
|
|
318
|
+
};
|
|
319
|
+
} else {
|
|
320
|
+
name = `workflow:${opts.workflow.name}`;
|
|
321
|
+
data = {
|
|
322
|
+
type: "workflow",
|
|
323
|
+
workflowName: opts.workflow.name,
|
|
324
|
+
initialState: opts.workflow.initialState,
|
|
325
|
+
sessionId: opts.workflow.sessionId,
|
|
326
|
+
userId: opts.workflow.userId,
|
|
327
|
+
tenantId: opts.workflow.tenantId
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
await this.assertNoLegacySchedule(name, opts.cron);
|
|
331
|
+
return { jobId: (await this.queue.upsertJobScheduler(opts.id, {
|
|
332
|
+
pattern: opts.cron,
|
|
333
|
+
tz: opts.timezone
|
|
334
|
+
}, {
|
|
335
|
+
name,
|
|
336
|
+
data
|
|
337
|
+
})).id };
|
|
338
|
+
}
|
|
339
|
+
async unschedule(id) {
|
|
340
|
+
if (!await this.queue.removeJobScheduler(id)) throw new Error(`Schedule "${id}" not found`);
|
|
341
|
+
}
|
|
342
|
+
async listSchedules() {
|
|
343
|
+
const jobs = await this.schedulerRecords();
|
|
344
|
+
if (!this.hasLegacyMaintenance() && jobs.some((job) => typeof job.iterationCount !== "number")) throw new Error(LEGACY_MIGRATION);
|
|
345
|
+
return jobs.filter((job) => typeof job.iterationCount === "number").map((job) => ({
|
|
346
|
+
id: job.key,
|
|
347
|
+
pattern: job.pattern ?? "",
|
|
348
|
+
timezone: job.tz,
|
|
349
|
+
next: new Date(job.next)
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
/** BullMQ v5 maintenance only. Never silently converts persisted legacy repeat records. */
|
|
353
|
+
async listLegacySchedules() {
|
|
354
|
+
if (!this.hasLegacyMaintenance()) throw new Error(LEGACY_MIGRATION);
|
|
355
|
+
const schedulerIds = new Set((await this.schedulerRecords()).filter((job) => typeof job.iterationCount === "number").map((job) => job.key));
|
|
356
|
+
return (await this.queue.getRepeatableJobs()).filter((job) => !schedulerIds.has(job.key)).map((job) => ({
|
|
357
|
+
key: job.key,
|
|
358
|
+
name: job.name,
|
|
359
|
+
pattern: job.pattern,
|
|
360
|
+
timezone: job.tz,
|
|
361
|
+
next: job.next
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
async pause() {
|
|
365
|
+
await this.queue.pause();
|
|
366
|
+
}
|
|
367
|
+
async resume() {
|
|
368
|
+
await this.queue.resume();
|
|
369
|
+
}
|
|
370
|
+
/** Maintenance-only removal: callers retain inventory/payload backups and stop concurrent schedule writers. */
|
|
371
|
+
async removeLegacySchedule(key) {
|
|
372
|
+
if (!this.hasLegacyMaintenance()) throw new Error(LEGACY_MIGRATION);
|
|
373
|
+
if (!await this.queue.isPaused() || await this.queue.getActiveCount() !== 0) throw new Error("Pause the queue and drain active jobs before migrating schedules");
|
|
374
|
+
if (!(await this.listLegacySchedules()).some((job) => job.key === key)) throw new Error("Legacy schedule not found");
|
|
375
|
+
await this.queue.removeRepeatableByKey(key);
|
|
376
|
+
}
|
|
377
|
+
async close() {
|
|
378
|
+
const results = await Promise.allSettled([this.queue.close(), this.queueEvents.close()]);
|
|
379
|
+
for (const r of results) if (r.status === "rejected") console.warn("[JobProducer] Error during close:", r.reason);
|
|
380
|
+
}
|
|
170
381
|
};
|
|
171
|
-
|
|
172
|
-
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/job-worker.ts
|
|
384
|
+
const require = createRequire(import.meta.url);
|
|
173
385
|
var AgentWorker = class {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
async stop(timeoutMs = 3e4) {
|
|
250
|
-
await Promise.race([
|
|
251
|
-
this.worker.close(),
|
|
252
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("Worker drain timeout")), timeoutMs))
|
|
253
|
-
]).catch((err) => {
|
|
254
|
-
console.warn("[JobWorker] Error during stop:", err?.message ?? err);
|
|
255
|
-
});
|
|
256
|
-
}
|
|
386
|
+
worker;
|
|
387
|
+
constructor(config) {
|
|
388
|
+
const legacy = config;
|
|
389
|
+
if (legacy.attempts !== void 0 || legacy.backoffDelay !== void 0) throw new Error("Configure retries on AgentQueue defaultJobOptions or enqueue options; Worker retry options never controlled jobs");
|
|
390
|
+
const queueName = config.queueName ?? "agentium-jobs";
|
|
391
|
+
const concurrency = config.concurrency ?? 5;
|
|
392
|
+
const connection = queueConnection(config.connection);
|
|
393
|
+
try {
|
|
394
|
+
const { Worker } = require("bullmq");
|
|
395
|
+
this.worker = new Worker(queueName, async (job) => {
|
|
396
|
+
const payload = job.data;
|
|
397
|
+
if (payload.type === "agent") {
|
|
398
|
+
const agent = config.agentRegistry[payload.agentName];
|
|
399
|
+
if (!agent) throw new Error(`Agent "${payload.agentName}" not found in registry`);
|
|
400
|
+
const runId = randomUUID();
|
|
401
|
+
let progress = 0;
|
|
402
|
+
let progressWrites = Promise.resolve();
|
|
403
|
+
const onChunk = (event) => {
|
|
404
|
+
if (event.runId !== runId) return;
|
|
405
|
+
const next = ++progress;
|
|
406
|
+
progressWrites = progressWrites.then(() => job.updateProgress(next)).catch(() => {});
|
|
407
|
+
};
|
|
408
|
+
agent.eventBus.on("run.stream.chunk", onChunk);
|
|
409
|
+
try {
|
|
410
|
+
return await agent.run(payload.input, {
|
|
411
|
+
runId,
|
|
412
|
+
sessionId: payload.sessionId,
|
|
413
|
+
userId: payload.userId,
|
|
414
|
+
tenantId: payload.tenantId
|
|
415
|
+
});
|
|
416
|
+
} finally {
|
|
417
|
+
agent.eventBus.off("run.stream.chunk", onChunk);
|
|
418
|
+
await progressWrites;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (payload.type === "workflow") {
|
|
422
|
+
const workflow = config.workflowRegistry?.[payload.workflowName];
|
|
423
|
+
if (!workflow) throw new Error(`Workflow "${payload.workflowName}" not found in registry`);
|
|
424
|
+
return await workflow.run({
|
|
425
|
+
sessionId: payload.sessionId,
|
|
426
|
+
initialState: payload.initialState,
|
|
427
|
+
userId: payload.userId,
|
|
428
|
+
tenantId: payload.tenantId
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
if (payload.type === "team") {
|
|
432
|
+
const team = config.teamRegistry?.[payload.teamName];
|
|
433
|
+
if (!team) throw new Error(`Team "${payload.teamName}" not found in registry`);
|
|
434
|
+
return await team.run(payload.input, {
|
|
435
|
+
sessionId: payload.sessionId,
|
|
436
|
+
userId: payload.userId,
|
|
437
|
+
tenantId: payload.tenantId
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
throw new Error(`Unknown job type: ${payload.type}`);
|
|
441
|
+
}, {
|
|
442
|
+
connection,
|
|
443
|
+
concurrency
|
|
444
|
+
});
|
|
445
|
+
} catch (err) {
|
|
446
|
+
if (err?.code === "MODULE_NOT_FOUND" || err?.message?.includes("Cannot find module")) throw new Error("bullmq and ioredis are required for AgentWorker. Install them: npm install bullmq ioredis");
|
|
447
|
+
throw err;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
start() {}
|
|
451
|
+
async stop(timeoutMs = 3e4) {
|
|
452
|
+
let timer;
|
|
453
|
+
try {
|
|
454
|
+
await Promise.race([this.worker.close(), new Promise((_, reject) => {
|
|
455
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("Worker drain timeout")), timeoutMs);
|
|
456
|
+
})]);
|
|
457
|
+
} finally {
|
|
458
|
+
if (timer) clearTimeout(timer);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
257
461
|
};
|
|
258
|
-
|
|
259
|
-
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/progress.ts
|
|
260
464
|
function bridgeEventBusToJob(eventBus, job, runId) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
eventBus.off("tool.call", onToolCall);
|
|
278
|
-
};
|
|
465
|
+
let progressCount = 0;
|
|
466
|
+
const onChunk = (evt) => {
|
|
467
|
+
if (evt.runId !== runId) return;
|
|
468
|
+
progressCount++;
|
|
469
|
+
job.updateProgress(progressCount).catch(() => {});
|
|
470
|
+
};
|
|
471
|
+
const onToolCall = (evt) => {
|
|
472
|
+
if (evt.runId !== runId) return;
|
|
473
|
+
job.log(`Tool call: ${evt.toolName}`).catch(() => {});
|
|
474
|
+
};
|
|
475
|
+
eventBus.on("run.stream.chunk", onChunk);
|
|
476
|
+
eventBus.on("tool.call", onToolCall);
|
|
477
|
+
return () => {
|
|
478
|
+
eventBus.off("run.stream.chunk", onChunk);
|
|
479
|
+
eventBus.off("tool.call", onToolCall);
|
|
480
|
+
};
|
|
279
481
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
AgentWorker,
|
|
283
|
-
bridgeEventBusToJob
|
|
284
|
-
};
|
|
482
|
+
//#endregion
|
|
483
|
+
export { AgentQueue, AgentWorker, DurableAgentQueue, DurableAgentWorker, bridgeEventBusToJob };
|