notifkit 0.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/LICENSE +21 -0
- package/README.md +94 -0
- package/dist/index.d.mts +9973 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +38 -0
- package/dist/index.mjs.map +1 -0
- package/dist/main-4H6vNXvy.mjs +392 -0
- package/dist/main-4H6vNXvy.mjs.map +1 -0
- package/dist/main-BHYZfBBq.mjs +224 -0
- package/dist/main-BHYZfBBq.mjs.map +1 -0
- package/dist/main-BIcKzWHE.mjs +430 -0
- package/dist/main-BIcKzWHE.mjs.map +1 -0
- package/dist/main-ClEeP5qw.mjs +629 -0
- package/dist/main-ClEeP5qw.mjs.map +1 -0
- package/dist/main-D-oWWzR3.mjs +234 -0
- package/dist/main-D-oWWzR3.mjs.map +1 -0
- package/dist/main-Dlfy9mWs.mjs +571 -0
- package/dist/main-Dlfy9mWs.mjs.map +1 -0
- package/dist/main-Dztc2dqR.mjs +294 -0
- package/dist/main-Dztc2dqR.mjs.map +1 -0
- package/dist/main-Ok9cQJ7q.mjs +1636 -0
- package/dist/main-Ok9cQJ7q.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/src-DrSN2wCg.mjs +3424 -0
- package/dist/src-DrSN2wCg.mjs.map +1 -0
- package/drizzle/0000_spotty_jack_flag.sql +189 -0
- package/drizzle/0001_stale_shotgun.sql +17 -0
- package/drizzle/meta/0000_snapshot.json +1314 -0
- package/drizzle/meta/0001_snapshot.json +1460 -0
- package/drizzle/meta/_journal.json +20 -0
- package/package.json +110 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { J as PendingMessageScanner, Jt as buildStreamEvent, T as TemplateRepository, W as getPriorityBucket, X as StreamProducer, Y as StreamConsumer, Yt as CONSUMER_GROUPS, an as registry, b as renderWithTemplate, c as BaseWorker, en as STREAMS, et as createLogger, fn as getAiConfig, g as TemplateCache, hn as readBaseConfig, it as createDatabase, k as RedisClient, pn as loadEnv, q as globalEmitter, rt as IdempotencyGuard, u as startHealthReporter, un as AI_DEFAULTS, ut as scheduledPayloads } from "./src-DrSN2wCg.mjs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { generateText } from "ai";
|
|
4
|
+
//#region src/services/ai/main.ts
|
|
5
|
+
loadEnv();
|
|
6
|
+
const config = readBaseConfig();
|
|
7
|
+
let logger;
|
|
8
|
+
let redis;
|
|
9
|
+
let sql;
|
|
10
|
+
let db;
|
|
11
|
+
let templateRepo;
|
|
12
|
+
let templateCache;
|
|
13
|
+
let consumer;
|
|
14
|
+
let pendingScanner;
|
|
15
|
+
let worker;
|
|
16
|
+
let healthInterval = null;
|
|
17
|
+
let subscriber = null;
|
|
18
|
+
/**
|
|
19
|
+
* A model failure that retrying cannot fix (bad prompt, rejected request,
|
|
20
|
+
* unsupported model). Thrown so the notification fails once instead of being
|
|
21
|
+
* re-billed on every retry.
|
|
22
|
+
*/
|
|
23
|
+
var PermanentAiError = class extends Error {
|
|
24
|
+
constructor(message, options) {
|
|
25
|
+
super(message, options);
|
|
26
|
+
this.name = "PermanentAiError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
/** Timeouts, rate limits and 5xx are worth another attempt; 4xx are not. */
|
|
30
|
+
function isRetryableAiError(err) {
|
|
31
|
+
if (err instanceof PermanentAiError) return false;
|
|
32
|
+
const e = err;
|
|
33
|
+
if (!e) return false;
|
|
34
|
+
if (e.name === "TimeoutError" || e.name === "AbortError") return true;
|
|
35
|
+
const status = e.statusCode ?? e.status;
|
|
36
|
+
if (typeof status === "number") return status === 408 || status === 409 || status === 429 || status >= 500;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
var AiWorker = class extends BaseWorker {
|
|
40
|
+
registry;
|
|
41
|
+
idempotency;
|
|
42
|
+
redisCli;
|
|
43
|
+
generateAiContent;
|
|
44
|
+
templateCache;
|
|
45
|
+
scheduledProducer;
|
|
46
|
+
outboundProducers;
|
|
47
|
+
db;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
super(options);
|
|
50
|
+
this.registry = options.registry;
|
|
51
|
+
this.idempotency = options.idempotency;
|
|
52
|
+
this.redisCli = options.redis;
|
|
53
|
+
this.generateAiContent = options.generateAiContent;
|
|
54
|
+
this.templateCache = options.templateCache;
|
|
55
|
+
this.scheduledProducer = options.scheduledProducer;
|
|
56
|
+
this.outboundProducers = options.outboundProducers;
|
|
57
|
+
this.db = options.db;
|
|
58
|
+
}
|
|
59
|
+
async process(message) {
|
|
60
|
+
const { event } = message;
|
|
61
|
+
const payloadResult = this.registry.safeParsePayload("notification.ai_pending", event.payload);
|
|
62
|
+
if (!payloadResult.success) {
|
|
63
|
+
this.logger.warn({
|
|
64
|
+
messageId: message.id,
|
|
65
|
+
issues: payloadResult.error.issues
|
|
66
|
+
}, "invalid notification.ai_pending payload — skipping");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const pending = payloadResult.data;
|
|
70
|
+
const idempotencyKey = `${pending.enrichedEventId}:${pending.recipientId}:${pending.channel}:ai`;
|
|
71
|
+
if (!await this.idempotency.checkAndMark(idempotencyKey)) {
|
|
72
|
+
this.logger.debug({
|
|
73
|
+
messageId: message.id,
|
|
74
|
+
eventId: event.id
|
|
75
|
+
}, "duplicate ai task — skipping");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const promptEntries = Object.entries(pending.aiPrompts);
|
|
80
|
+
const maxPrompts = getAiConfig().maxPromptsPerNotification ?? AI_DEFAULTS.maxPromptsPerNotification;
|
|
81
|
+
if (promptEntries.length > maxPrompts) this.logger.warn({
|
|
82
|
+
messageId: message.id,
|
|
83
|
+
requested: promptEntries.length,
|
|
84
|
+
maxPrompts
|
|
85
|
+
}, "aiPrompts exceeds the per-notification cap — extra prompts ignored");
|
|
86
|
+
const generatedVars = {};
|
|
87
|
+
for (const [key, prompt] of promptEntries.slice(0, maxPrompts)) generatedVars[key] = await this.generateAiContent(prompt, pending.templateVariables);
|
|
88
|
+
const finalVars = {
|
|
89
|
+
...pending.templateVariables,
|
|
90
|
+
...generatedVars
|
|
91
|
+
};
|
|
92
|
+
const dbTemplate = pending.templateId ? await this.templateCache.getCachedTemplate(pending.projectId, pending.templateId) : null;
|
|
93
|
+
const rendered = renderWithTemplate(dbTemplate, finalVars);
|
|
94
|
+
const taskId = randomUUID();
|
|
95
|
+
const resolvedDestination = (pending.channel === "email" ? pending.recipient.email : pending.channel === "sms" ? pending.recipient.phone : pending.channel === "webhook" ? pending.recipient.webhook : pending.channel === "push" ? pending.recipient.pushTokens?.[0] ?? pending.recipient.pushToken : void 0) ?? (pending.channel === "push" ? void 0 : pending.recipientId);
|
|
96
|
+
const taskPayload = {
|
|
97
|
+
projectId: pending.projectId,
|
|
98
|
+
taskId,
|
|
99
|
+
enrichedEventId: pending.enrichedEventId,
|
|
100
|
+
recipientId: pending.recipientId,
|
|
101
|
+
channel: pending.channel,
|
|
102
|
+
priority: pending.priority,
|
|
103
|
+
templateId: pending.templateId,
|
|
104
|
+
templateVariables: pending.templateVariables,
|
|
105
|
+
aiPrompts: pending.aiPrompts,
|
|
106
|
+
recipient: pending.recipient,
|
|
107
|
+
renderedContent: rendered,
|
|
108
|
+
destination: resolvedDestination,
|
|
109
|
+
deliveryOptions: {
|
|
110
|
+
maxAttempts: 3,
|
|
111
|
+
timeoutMs: 1e4
|
|
112
|
+
},
|
|
113
|
+
fallbackChain: pending.fallbackChain
|
|
114
|
+
};
|
|
115
|
+
const envelope = buildStreamEvent("notification.dispatched", taskPayload, "ai-worker", event.metadata.traceId);
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
if ((pending.scheduledAt ? new Date(pending.scheduledAt).getTime() : now) > now) {
|
|
118
|
+
await this.db.insert(scheduledPayloads).values({
|
|
119
|
+
taskId,
|
|
120
|
+
payload: taskPayload
|
|
121
|
+
});
|
|
122
|
+
const scheduledEnvelope = buildStreamEvent("notification.scheduled", {
|
|
123
|
+
projectId: pending.projectId,
|
|
124
|
+
enrichedEventId: pending.enrichedEventId,
|
|
125
|
+
taskId,
|
|
126
|
+
scheduledAt: pending.scheduledAt
|
|
127
|
+
}, "ai-worker", event.metadata.traceId);
|
|
128
|
+
await this.scheduledProducer.publish(scheduledEnvelope);
|
|
129
|
+
this.logger.info({
|
|
130
|
+
messageId: message.id,
|
|
131
|
+
taskId,
|
|
132
|
+
scheduledAt: pending.scheduledAt,
|
|
133
|
+
traceId: event.metadata.traceId
|
|
134
|
+
}, "task scheduled and payload cached after AI generation");
|
|
135
|
+
} else {
|
|
136
|
+
const p = getPriorityBucket(pending.priority);
|
|
137
|
+
await (this.outboundProducers[p] ?? this.outboundProducers["normal"]).publish(envelope);
|
|
138
|
+
this.logger.info({
|
|
139
|
+
messageId: message.id,
|
|
140
|
+
taskId,
|
|
141
|
+
recipientId: pending.recipientId,
|
|
142
|
+
traceId: event.metadata.traceId
|
|
143
|
+
}, "task dispatched after AI generation");
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
await this.idempotency.unmark(idempotencyKey);
|
|
147
|
+
if (err instanceof PermanentAiError || err?.name === "PermanentAiError") {
|
|
148
|
+
this.logger.error({
|
|
149
|
+
err,
|
|
150
|
+
messageId: message.id,
|
|
151
|
+
recipientId: pending.recipientId
|
|
152
|
+
}, "AI generation failed permanently — dropping notification without retry");
|
|
153
|
+
globalEmitter.emit("notification:failed", pending.enrichedEventId, err.message, pending.channel);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
async function startAiWorker() {
|
|
161
|
+
logger = createLogger({
|
|
162
|
+
name: "ai",
|
|
163
|
+
level: config.LOG_LEVEL
|
|
164
|
+
});
|
|
165
|
+
redis = new RedisClient({
|
|
166
|
+
url: config.REDIS_URL,
|
|
167
|
+
name: "ai",
|
|
168
|
+
logger
|
|
169
|
+
});
|
|
170
|
+
const dbData = createDatabase({
|
|
171
|
+
url: config.DATABASE_URL,
|
|
172
|
+
applicationName: "ai",
|
|
173
|
+
logger
|
|
174
|
+
});
|
|
175
|
+
sql = dbData.sql;
|
|
176
|
+
db = dbData.db;
|
|
177
|
+
templateRepo = new TemplateRepository(db);
|
|
178
|
+
templateCache = new TemplateCache(templateRepo);
|
|
179
|
+
consumer = new StreamConsumer({
|
|
180
|
+
redis: redis.native,
|
|
181
|
+
stream: STREAMS.AI_PENDING,
|
|
182
|
+
group: CONSUMER_GROUPS.AI,
|
|
183
|
+
consumer: `ai-${process.pid}`,
|
|
184
|
+
dlqStream: STREAMS.DEAD_LETTER,
|
|
185
|
+
batchSize: config.WORKER_CONCURRENCY,
|
|
186
|
+
logger
|
|
187
|
+
});
|
|
188
|
+
pendingScanner = new PendingMessageScanner({
|
|
189
|
+
redis: redis.native,
|
|
190
|
+
stream: STREAMS.AI_PENDING,
|
|
191
|
+
group: CONSUMER_GROUPS.AI,
|
|
192
|
+
consumer: `ai-${process.pid}`,
|
|
193
|
+
logger
|
|
194
|
+
});
|
|
195
|
+
const outboundProducers = {
|
|
196
|
+
critical: new StreamProducer({
|
|
197
|
+
redis: redis.native,
|
|
198
|
+
stream: STREAMS.OUTBOUND_CRITICAL,
|
|
199
|
+
logger
|
|
200
|
+
}),
|
|
201
|
+
normal: new StreamProducer({
|
|
202
|
+
redis: redis.native,
|
|
203
|
+
stream: STREAMS.OUTBOUND_NORMAL,
|
|
204
|
+
logger
|
|
205
|
+
}),
|
|
206
|
+
low: new StreamProducer({
|
|
207
|
+
redis: redis.native,
|
|
208
|
+
stream: STREAMS.OUTBOUND_LOW,
|
|
209
|
+
logger
|
|
210
|
+
})
|
|
211
|
+
};
|
|
212
|
+
const scheduledProducer = new StreamProducer({
|
|
213
|
+
redis: redis.native,
|
|
214
|
+
stream: STREAMS.SCHEDULED,
|
|
215
|
+
logger
|
|
216
|
+
});
|
|
217
|
+
const idempotency = new IdempotencyGuard({
|
|
218
|
+
redis: redis.native,
|
|
219
|
+
keyPrefix: "notif:processed:ai",
|
|
220
|
+
ttlSeconds: 86400
|
|
221
|
+
});
|
|
222
|
+
async function generateAiContent(prompt, vars) {
|
|
223
|
+
const aiConfig = getAiConfig();
|
|
224
|
+
const interpolated = prompt.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? ""));
|
|
225
|
+
if (!aiConfig || !aiConfig.aiModel) {
|
|
226
|
+
logger.warn("AI worker called but no AI model was provided to NotifkitServer");
|
|
227
|
+
return `[AI Disabled] ${interpolated}`;
|
|
228
|
+
}
|
|
229
|
+
const maxOutputTokens = aiConfig.maxOutputTokens ?? AI_DEFAULTS.maxOutputTokens;
|
|
230
|
+
const timeoutMs = aiConfig.timeoutMs ?? AI_DEFAULTS.timeoutMs;
|
|
231
|
+
try {
|
|
232
|
+
const { text } = await generateText({
|
|
233
|
+
model: aiConfig.aiModel,
|
|
234
|
+
prompt: interpolated,
|
|
235
|
+
maxOutputTokens,
|
|
236
|
+
abortSignal: AbortSignal.timeout(timeoutMs)
|
|
237
|
+
});
|
|
238
|
+
return text;
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (isRetryableAiError(err)) {
|
|
241
|
+
logger.error({ err }, "AI generation failed (transient) — will retry");
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
throw new PermanentAiError(err instanceof Error ? err.message : String(err), { cause: err });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
worker = new AiWorker({
|
|
248
|
+
consumer,
|
|
249
|
+
pendingScanner,
|
|
250
|
+
logger,
|
|
251
|
+
concurrency: config.WORKER_CONCURRENCY,
|
|
252
|
+
registry,
|
|
253
|
+
idempotency,
|
|
254
|
+
redis: redis.native,
|
|
255
|
+
generateAiContent,
|
|
256
|
+
templateCache,
|
|
257
|
+
scheduledProducer,
|
|
258
|
+
outboundProducers,
|
|
259
|
+
db
|
|
260
|
+
});
|
|
261
|
+
subscriber = redis.native.duplicate();
|
|
262
|
+
await subscriber.subscribe("template.invalidated");
|
|
263
|
+
subscriber.on("message", (channel, message) => {
|
|
264
|
+
if (channel === "template.invalidated") {
|
|
265
|
+
templateCache.invalidateKey(message);
|
|
266
|
+
logger.info({ cacheKey: message }, "invalidated template cache");
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
healthInterval = startHealthReporter("ai", worker, redis, logger);
|
|
270
|
+
logger.info({
|
|
271
|
+
env: config.NODE_ENV,
|
|
272
|
+
redis: config.REDIS_URL
|
|
273
|
+
}, "ai starting");
|
|
274
|
+
await worker.start();
|
|
275
|
+
}
|
|
276
|
+
async function stopAiWorker() {
|
|
277
|
+
logger?.info("shutdown initiated");
|
|
278
|
+
if (healthInterval) {
|
|
279
|
+
clearInterval(healthInterval);
|
|
280
|
+
healthInterval = null;
|
|
281
|
+
}
|
|
282
|
+
if (subscriber) {
|
|
283
|
+
subscriber.disconnect();
|
|
284
|
+
subscriber = null;
|
|
285
|
+
}
|
|
286
|
+
if (worker) await worker.stop();
|
|
287
|
+
if (sql) await sql.end();
|
|
288
|
+
if (redis) await redis.disconnect();
|
|
289
|
+
logger?.info("ai stopped");
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
export { startAiWorker, stopAiWorker };
|
|
293
|
+
|
|
294
|
+
//# sourceMappingURL=main-Dztc2dqR.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main-Dztc2dqR.mjs","names":[],"sources":["../src/services/ai/main.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { loadEnv, readBaseConfig } from \"@/index.js\";\nimport { createLogger } from \"@/index.js\";\nimport { RedisClient, type Redis } from \"@/index.js\";\nimport {\n StreamConsumer,\n PendingMessageScanner,\n StreamProducer,\n type StreamMessage,\n} from \"@/index.js\";\nimport { BaseWorker } from \"@/index.js\";\nimport {\n STREAMS,\n CONSUMER_GROUPS,\n registry,\n buildStreamEvent,\n type NotificationAiPendingPayload,\n type NotificationDispatchedPayload,\n getAiConfig,\n AI_DEFAULTS,\n} from \"@/index.js\";\nimport { generateText } from \"ai\";\nimport { type StreamName } from \"@/contracts/streams.js\";\nimport { IdempotencyGuard } from \"@/index.js\";\nimport { TemplateRepository } from \"@/index.js\";\nimport { createDatabase } from \"@/db/index.js\";\nimport { scheduledPayloads } from \"@/db/schema.js\";\nimport { getPriorityBucket, globalEmitter, type WorkerOptions } from \"@/shared/index.js\";\nimport { renderWithTemplate, TemplateCache } from \"@/templates/index.js\";\nimport { startHealthReporter } from \"@/workers/index.js\";\n\n// ─── Bootstrap ─────────────────────────────────────────────────────────────\n\nloadEnv();\nconst config = readBaseConfig();\n\nlet logger: ReturnType<typeof createLogger>;\nlet redis: RedisClient;\nlet sql: any;\nlet db: any;\nlet templateRepo: TemplateRepository;\n\nlet templateCache: TemplateCache;\n\nlet consumer: StreamConsumer;\nlet pendingScanner: PendingMessageScanner;\nlet worker: BaseWorker;\nlet healthInterval: NodeJS.Timeout | null = null;\nlet subscriber: any = null;\n\n/**\n * A model failure that retrying cannot fix (bad prompt, rejected request,\n * unsupported model). Thrown so the notification fails once instead of being\n * re-billed on every retry.\n */\nexport class PermanentAiError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"PermanentAiError\";\n }\n}\n\n/** Timeouts, rate limits and 5xx are worth another attempt; 4xx are not. */\nexport function isRetryableAiError(err: unknown): boolean {\n if (err instanceof PermanentAiError) return false;\n\n const e = err as { name?: string; statusCode?: number; status?: number } | null;\n if (!e) return false;\n\n if (e.name === \"TimeoutError\" || e.name === \"AbortError\") return true;\n\n const status = e.statusCode ?? e.status;\n if (typeof status === \"number\") {\n return status === 408 || status === 409 || status === 429 || status >= 500;\n }\n\n // Unclassifiable (network errors, transport failures) — assume transient.\n return true;\n}\n\nexport interface AiWorkerOptions extends WorkerOptions {\n registry: any;\n idempotency: any;\n redis: Redis;\n generateAiContent: any;\n templateCache: TemplateCache;\n scheduledProducer: any;\n outboundProducers: any;\n db: any;\n}\n\nexport class AiWorker extends BaseWorker {\n private readonly registry: any;\n private readonly idempotency: any;\n private readonly redisCli: Redis;\n private readonly generateAiContent: any;\n private readonly templateCache: TemplateCache;\n private readonly scheduledProducer: any;\n private readonly outboundProducers: any;\n private readonly db: any;\n\n constructor(options: AiWorkerOptions) {\n super(options);\n this.registry = options.registry;\n this.idempotency = options.idempotency;\n this.redisCli = options.redis;\n this.generateAiContent = options.generateAiContent;\n this.templateCache = options.templateCache;\n this.scheduledProducer = options.scheduledProducer;\n this.outboundProducers = options.outboundProducers;\n this.db = options.db;\n }\n async process(message: StreamMessage): Promise<void> {\n const { event } = message;\n\n const payloadResult = this.registry.safeParsePayload(\"notification.ai_pending\", event.payload);\n if (!payloadResult.success) {\n this.logger.warn(\n { messageId: message.id, issues: payloadResult.error.issues },\n \"invalid notification.ai_pending payload — skipping\",\n );\n return;\n }\n\n const pending = payloadResult.data as NotificationAiPendingPayload;\n\n // Idempotency\n const idempotencyKey = `${pending.enrichedEventId}:${pending.recipientId}:${pending.channel}:ai`;\n if (!(await this.idempotency.checkAndMark(idempotencyKey))) {\n this.logger.debug(\n { messageId: message.id, eventId: event.id },\n \"duplicate ai task — skipping\",\n );\n return;\n }\n try {\n // Execute AI prompts. Each key is a separate billed model call, so the\n // count is capped rather than being driven by whatever the caller sent.\n const promptEntries = Object.entries(pending.aiPrompts);\n const maxPrompts =\n getAiConfig().maxPromptsPerNotification ?? AI_DEFAULTS.maxPromptsPerNotification;\n if (promptEntries.length > maxPrompts) {\n this.logger.warn(\n { messageId: message.id, requested: promptEntries.length, maxPrompts },\n \"aiPrompts exceeds the per-notification cap — extra prompts ignored\",\n );\n }\n\n const generatedVars: Record<string, string> = {};\n for (const [key, prompt] of promptEntries.slice(0, maxPrompts)) {\n generatedVars[key] = await this.generateAiContent(prompt, pending.templateVariables);\n }\n\n // Merge generated vars with original template vars\n const finalVars = { ...pending.templateVariables, ...generatedVars };\n\n const dbTemplate = pending.templateId\n ? await this.templateCache.getCachedTemplate(pending.projectId, pending.templateId)\n : null;\n\n const rendered = renderWithTemplate(dbTemplate, finalVars);\n\n const taskId = randomUUID();\n const destination =\n pending.channel === \"email\"\n ? pending.recipient.email\n : pending.channel === \"sms\"\n ? pending.recipient.phone\n : pending.channel === \"webhook\"\n ? pending.recipient.webhook\n : pending.channel === \"push\"\n ? (pending.recipient.pushTokens?.[0] ?? pending.recipient.pushToken)\n : undefined;\n const resolvedDestination =\n destination ?? (pending.channel === \"push\" ? undefined : pending.recipientId);\n\n const taskPayload: NotificationDispatchedPayload = {\n projectId: pending.projectId,\n taskId,\n enrichedEventId: pending.enrichedEventId,\n recipientId: pending.recipientId,\n channel: pending.channel,\n priority: pending.priority,\n templateId: pending.templateId,\n templateVariables: pending.templateVariables,\n aiPrompts: pending.aiPrompts,\n recipient: pending.recipient,\n renderedContent: rendered,\n destination: resolvedDestination,\n deliveryOptions: {\n maxAttempts: 3,\n timeoutMs: 10_000,\n },\n fallbackChain: pending.fallbackChain,\n };\n\n const envelope = buildStreamEvent(\n \"notification.dispatched\",\n taskPayload as Record<string, unknown>,\n \"ai-worker\",\n event.metadata.traceId,\n );\n\n // Route by scheduledAt\n const now = Date.now();\n const scheduledAt = pending.scheduledAt ? new Date(pending.scheduledAt).getTime() : now;\n\n if (scheduledAt > now) {\n await this.db.insert(scheduledPayloads).values({\n taskId,\n payload: taskPayload,\n });\n\n const scheduledEnvelope = buildStreamEvent(\n \"notification.scheduled\",\n {\n projectId: pending.projectId,\n enrichedEventId: pending.enrichedEventId,\n taskId,\n scheduledAt: pending.scheduledAt!,\n },\n \"ai-worker\",\n event.metadata.traceId,\n );\n\n await this.scheduledProducer.publish(scheduledEnvelope);\n this.logger.info(\n {\n messageId: message.id,\n taskId,\n scheduledAt: pending.scheduledAt,\n traceId: event.metadata.traceId,\n },\n \"task scheduled and payload cached after AI generation\",\n );\n } else {\n const p = getPriorityBucket(pending.priority);\n const outboundProducer = this.outboundProducers[p] ?? this.outboundProducers[\"normal\"]!;\n\n await outboundProducer.publish(envelope);\n this.logger.info(\n {\n messageId: message.id,\n taskId,\n recipientId: pending.recipientId,\n traceId: event.metadata.traceId,\n },\n \"task dispatched after AI generation\",\n );\n }\n } catch (err) {\n await this.idempotency.unmark(idempotencyKey);\n if (err instanceof PermanentAiError || (err as Error)?.name === \"PermanentAiError\") {\n // Retrying re-bills the same failing prompt. Fail the notification once.\n this.logger.error(\n { err, messageId: message.id, recipientId: pending.recipientId },\n \"AI generation failed permanently — dropping notification without retry\",\n );\n globalEmitter.emit(\n \"notification:failed\",\n pending.enrichedEventId,\n (err as Error).message,\n pending.channel,\n );\n return;\n }\n throw err;\n }\n }\n}\n\nexport async function startAiWorker() {\n logger = createLogger({ name: \"ai\", level: config.LOG_LEVEL });\n redis = new RedisClient({ url: config.REDIS_URL, name: \"ai\", logger });\n const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: \"ai\", logger });\n sql = dbData.sql;\n db = dbData.db;\n templateRepo = new TemplateRepository(db);\n templateCache = new TemplateCache(templateRepo);\n consumer = new StreamConsumer({\n redis: redis.native,\n stream: STREAMS.AI_PENDING as StreamName,\n group: CONSUMER_GROUPS.AI,\n consumer: `ai-${process.pid}`,\n dlqStream: STREAMS.DEAD_LETTER,\n batchSize: config.WORKER_CONCURRENCY,\n logger,\n });\n\n pendingScanner = new PendingMessageScanner({\n redis: redis.native,\n stream: STREAMS.AI_PENDING as StreamName,\n group: CONSUMER_GROUPS.AI,\n consumer: `ai-${process.pid}`,\n logger,\n });\n\n const outboundProducers = {\n critical: new StreamProducer({\n redis: redis.native,\n stream: STREAMS.OUTBOUND_CRITICAL,\n logger,\n }),\n normal: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_NORMAL, logger }),\n low: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_LOW, logger }),\n };\n\n const scheduledProducer = new StreamProducer({\n redis: redis.native,\n stream: STREAMS.SCHEDULED,\n logger,\n });\n\n const idempotency = new IdempotencyGuard({\n redis: redis.native,\n keyPrefix: \"notif:processed:ai\",\n ttlSeconds: 86_400,\n });\n\n // AI generation\n async function generateAiContent(prompt: string, vars: Record<string, unknown>): Promise<string> {\n const aiConfig = getAiConfig();\n const interpolated = prompt.replace(/\\{\\{(\\w+)\\}\\}/g, (_, k: string) => String(vars[k] ?? \"\"));\n\n if (!aiConfig || !aiConfig.aiModel) {\n logger.warn(\"AI worker called but no AI model was provided to NotifkitServer\");\n return `[AI Disabled] ${interpolated}`;\n }\n\n const maxOutputTokens = aiConfig.maxOutputTokens ?? AI_DEFAULTS.maxOutputTokens;\n const timeoutMs = aiConfig.timeoutMs ?? AI_DEFAULTS.timeoutMs;\n\n try {\n const { text } = await generateText({\n model: aiConfig.aiModel,\n prompt: interpolated,\n maxOutputTokens,\n abortSignal: AbortSignal.timeout(timeoutMs),\n });\n\n return text;\n } catch (err) {\n // BaseWorker retries a throw up to maxRetriesBeforeDlq, and every retry is\n // another billed call. Only re-throw for failures a retry could actually\n // fix; a malformed prompt or a rejected request must not be re-billed.\n if (isRetryableAiError(err)) {\n logger.error({ err }, \"AI generation failed (transient) — will retry\");\n throw err;\n }\n throw new PermanentAiError(err instanceof Error ? err.message : String(err), { cause: err });\n }\n }\n\n worker = new AiWorker({\n consumer,\n pendingScanner,\n logger,\n concurrency: config.WORKER_CONCURRENCY,\n registry,\n idempotency,\n redis: redis.native,\n generateAiContent,\n templateCache,\n scheduledProducer,\n outboundProducers,\n db,\n });\n\n subscriber = redis.native.duplicate();\n await subscriber.subscribe(\"template.invalidated\");\n subscriber.on(\"message\", (channel: string, message: string) => {\n if (channel === \"template.invalidated\") {\n templateCache.invalidateKey(message);\n logger.info({ cacheKey: message }, \"invalidated template cache\");\n }\n });\n\n healthInterval = startHealthReporter(\"ai\", worker, redis, logger);\n\n logger.info({ env: config.NODE_ENV, redis: config.REDIS_URL }, \"ai starting\");\n await worker.start();\n}\n\nexport async function stopAiWorker(): Promise<void> {\n logger?.info(\"shutdown initiated\");\n if (healthInterval) {\n clearInterval(healthInterval);\n healthInterval = null;\n }\n if (subscriber) {\n subscriber.disconnect();\n subscriber = null;\n }\n if (worker) await worker.stop();\n if (sql) await sql.end();\n if (redis) await redis.disconnect();\n logger?.info(\"ai stopped\");\n}\n"],"mappings":"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"}
|