@memberjunction/scheduling-engine 5.44.0 → 5.45.1

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.
@@ -0,0 +1,693 @@
1
+ /**
2
+ * @fileoverview Driver for the User Routine Dispatcher scheduled job (P1.5).
3
+ *
4
+ * One admin-owned Scheduled Job of this type (seeded via metadata, 1-minute cron) sweeps
5
+ * `MJ: User Routines` for due routines, claims each by advancing NextRunAt BEFORE running
6
+ * (so an overlapping pass never double-runs it), executes the routine's target (Agent /
7
+ * Action / Prompt) with bounded concurrency and per-routine error isolation, records each
8
+ * execution in `MJ: User Routine Runs` (telemetry lives on the linked AgentRun / PromptRun /
9
+ * ActionExecutionLog — never duplicated), and notifies the owner + recipients per the
10
+ * routine's NotifyCondition through the MJ template + notification stack.
11
+ *
12
+ * @module @memberjunction/scheduling-engine
13
+ */
14
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
15
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
16
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
17
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
18
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
19
+ };
20
+ import { RegisterClass, SafeJSONParse, UUIDsEqual, NormalizeUUID } from '@memberjunction/global';
21
+ import { ValidationResult, ValidationErrorInfo, ValidationErrorType, Metadata, RunView, } from '@memberjunction/core';
22
+ import { MJEnvironmentEntityExtended, } from '@memberjunction/core-entities';
23
+ import { AIPromptParams } from '@memberjunction/ai-core-plus';
24
+ import { AgentRunner } from '@memberjunction/ai-agents';
25
+ import { AIPromptRunner } from '@memberjunction/ai-prompts';
26
+ import { ActionEngineServer } from '@memberjunction/actions';
27
+ import { TemplateEngineServer } from '@memberjunction/templates';
28
+ import { NotificationEngine } from '@memberjunction/notifications';
29
+ import { BaseScheduledJob } from '../BaseScheduledJob.js';
30
+ import { ComputeRoutineNextRunAt, ComputeResultHash, EvaluateNotifyCondition, IsRoutineDue, RoutineNeedsSeeding, BuildDueRoutineFilter, SortRecipientsBySequence, RunWithBoundedConcurrency, } from '../UserRoutineProcessor.js';
31
+ /** Default bound on concurrent routine executions within a single dispatcher sweep. */
32
+ const DEFAULT_MAX_CONCURRENT_ROUTINES = 3;
33
+ /** Name of the metadata-seeded default notification template (resolved BY NAME, never hardcoded ID). */
34
+ const DEFAULT_NOTIFICATION_TEMPLATE_NAME = 'User Routine Notification - Default';
35
+ /** Name of the metadata-seeded `MJ: User Notification Types` row used for in-app delivery. */
36
+ const NOTIFICATION_TYPE_NAME = 'User Routine';
37
+ /** Name of the Explorer application that scopes (hides) routine conversations. */
38
+ const ROUTINES_APPLICATION_NAME = 'Routines';
39
+ /** Cap on the persisted ResultSummary length — keeps run rows compact; full detail lives on the linked run/log records. */
40
+ const RESULT_SUMMARY_MAX_LENGTH = 4000;
41
+ /**
42
+ * Driver for the User Routine Dispatcher scheduled job.
43
+ *
44
+ * Configuration schema (stored in ScheduledJob.Configuration):
45
+ * `{ MaxConcurrentRoutines?: number }`
46
+ *
47
+ * Execution result details (stored in ScheduledJobRun.Details):
48
+ * `{ RoutinesEvaluated, RoutinesSeeded, RoutinesRun, Succeeded, Failed, Notified }`
49
+ */
50
+ let UserRoutineDispatcherDriver = class UserRoutineDispatcherDriver extends BaseScheduledJob {
51
+ /**
52
+ * The dispatcher is a by-design 1-minute sweeper: each pass claims due routines and
53
+ * does bounded work, so the engine's high-frequency cron warning doesn't apply.
54
+ */
55
+ get IsHighFrequencyByDesign() {
56
+ return true;
57
+ }
58
+ async Execute(context) {
59
+ const config = this.parseDispatcherConfiguration(context.Schedule);
60
+ const maxConcurrent = config.MaxConcurrentRoutines ?? DEFAULT_MAX_CONCURRENT_ROUTINES;
61
+ const now = new Date();
62
+ const candidates = await this.loadCandidateRoutines(context.ContextUser, now);
63
+ void context.heartbeat?.();
64
+ // Seed NextRunAt for never-scheduled routines (do NOT run them on this pass).
65
+ const seedable = candidates.filter(r => RoutineNeedsSeeding(r, now));
66
+ for (const routine of seedable) {
67
+ await this.seedNextRunAt(routine, now);
68
+ void context.heartbeat?.();
69
+ }
70
+ // Claim + run the due routines with bounded concurrency and per-routine isolation.
71
+ const due = candidates.filter(r => IsRoutineDue(r, now));
72
+ const claimed = [];
73
+ for (const routine of due) {
74
+ if (await this.claimRoutine(routine, now)) {
75
+ claimed.push(routine);
76
+ }
77
+ }
78
+ const summaries = await RunWithBoundedConcurrency(claimed, maxConcurrent, async (routine) => {
79
+ // Per-routine error isolation: one routine failing must never kill the sweep.
80
+ try {
81
+ return await this.executeRoutine(routine, context);
82
+ }
83
+ catch (error) {
84
+ this.logError(`Routine "${routine.Name}" (${routine.ID}) failed outside run tracking`, error);
85
+ return { RoutineID: routine.ID, RunStatus: 'Failed', Notified: false };
86
+ }
87
+ });
88
+ const succeeded = summaries.filter(s => s.RunStatus === 'Success').length;
89
+ const failed = summaries.filter(s => s.RunStatus === 'Failed').length;
90
+ const notified = summaries.filter(s => s.Notified).length;
91
+ return {
92
+ Success: true, // the SWEEP succeeded; individual routine failures are recorded on their run rows
93
+ Details: {
94
+ RoutinesEvaluated: candidates.length,
95
+ RoutinesSeeded: seedable.length,
96
+ RoutinesRun: claimed.length,
97
+ Succeeded: succeeded,
98
+ Failed: failed,
99
+ Notified: notified,
100
+ },
101
+ };
102
+ }
103
+ ValidateConfiguration(schedule) {
104
+ const result = new ValidationResult();
105
+ const config = this.parseDispatcherConfiguration(schedule);
106
+ const max = config.MaxConcurrentRoutines;
107
+ if (max != null && (typeof max !== 'number' || !Number.isInteger(max) || max < 1)) {
108
+ result.Errors.push(new ValidationErrorInfo('Configuration.MaxConcurrentRoutines', 'MaxConcurrentRoutines must be a positive integer when provided', max, ValidationErrorType.Failure));
109
+ }
110
+ result.Success = result.Errors.length === 0;
111
+ return result;
112
+ }
113
+ FormatNotification(context, result) {
114
+ const details = (result.Details ?? {});
115
+ if (!result.Success) {
116
+ return {
117
+ Subject: `User Routine Dispatcher failed: ${context.Schedule.Name}`,
118
+ Body: `The dispatcher sweep "${context.Schedule.Name}" failed.\n\nError: ${result.ErrorMessage ?? 'unknown'}`,
119
+ Priority: 'High',
120
+ Metadata: details,
121
+ };
122
+ }
123
+ return {
124
+ Subject: `User Routine Dispatcher: ${details['RoutinesRun'] ?? 0} routine(s) executed`,
125
+ Body: `The dispatcher sweep "${context.Schedule.Name}" evaluated ${details['RoutinesEvaluated'] ?? 0} routine(s): ` +
126
+ `${details['RoutinesRun'] ?? 0} run (${details['Succeeded'] ?? 0} succeeded, ${details['Failed'] ?? 0} failed), ` +
127
+ `${details['RoutinesSeeded'] ?? 0} seeded, ${details['Notified'] ?? 0} notification(s) dispatched.`,
128
+ Priority: (details['Failed'] ?? 0) > 0 ? 'Normal' : 'Low',
129
+ Metadata: details,
130
+ };
131
+ }
132
+ // ========================================================================
133
+ // Sweep phases
134
+ // ========================================================================
135
+ /** Tolerant parse — the dispatcher needs no configuration, so empty/missing/invalid JSON yields defaults. */
136
+ parseDispatcherConfiguration(schedule) {
137
+ if (!schedule.Configuration) {
138
+ return {};
139
+ }
140
+ return (SafeJSONParse(schedule.Configuration) ?? {});
141
+ }
142
+ /**
143
+ * Load Active routines that are inside their activation window and either due or in
144
+ * need of NextRunAt seeding. The SQL prefilter narrows the sweep; JS re-verifies.
145
+ */
146
+ async loadCandidateRoutines(contextUser, now) {
147
+ const rv = new RunView(); // global-provider-ok: the dispatcher is a server-global scheduled task, not per-request/per-tenant
148
+ const result = await rv.RunView({
149
+ EntityName: 'MJ: User Routines',
150
+ ExtraFilter: BuildDueRoutineFilter(now.toISOString()),
151
+ ResultType: 'entity_object',
152
+ }, contextUser);
153
+ if (!result.Success) {
154
+ throw new Error(`Failed to load due routines: ${result.ErrorMessage}`);
155
+ }
156
+ return result.Results ?? [];
157
+ }
158
+ /** Compute + persist NextRunAt for a never-scheduled routine WITHOUT running it. */
159
+ async seedNextRunAt(routine, now) {
160
+ try {
161
+ routine.NextRunAt = ComputeRoutineNextRunAt(routine.CronExpression, routine.Timezone, now, routine.StartAt);
162
+ const saved = await routine.Save();
163
+ if (!saved) {
164
+ this.logError(`Seeding NextRunAt for routine "${routine.Name}" failed: ${routine.LatestResult?.CompleteMessage ?? 'unknown'}`);
165
+ }
166
+ }
167
+ catch (error) {
168
+ // Invalid cron/timezone on a legacy row — log and move on; the entity server
169
+ // rejects new rows like this at save time.
170
+ this.logError(`Cannot compute NextRunAt for routine "${routine.Name}" (${routine.ID})`, error);
171
+ }
172
+ }
173
+ /**
174
+ * Claim a due routine by advancing NextRunAt to the next cron occurrence BEFORE running.
175
+ * Persisting the claim first means an overlapping dispatcher pass (or a crash mid-run)
176
+ * can never double-run this occurrence — the routine simply isn't due anymore. Cross-
177
+ * process exclusion of whole sweeps is additionally provided by the Scheduled Job lock
178
+ * (the dispatcher job runs with ConcurrencyMode='Skip').
179
+ *
180
+ * @returns true when the claim persisted and the routine should run on this pass.
181
+ */
182
+ async claimRoutine(routine, now) {
183
+ try {
184
+ routine.NextRunAt = ComputeRoutineNextRunAt(routine.CronExpression, routine.Timezone, now, routine.StartAt);
185
+ const saved = await routine.Save();
186
+ if (!saved) {
187
+ this.log(`Claim for routine "${routine.Name}" did not persist (${routine.LatestResult?.CompleteMessage ?? 'unknown'}) — skipping this pass`);
188
+ return false;
189
+ }
190
+ return true;
191
+ }
192
+ catch (error) {
193
+ this.logError(`Claim for routine "${routine.Name}" (${routine.ID}) threw — skipping this pass`, error);
194
+ return false;
195
+ }
196
+ }
197
+ // ========================================================================
198
+ // Per-routine execution
199
+ // ========================================================================
200
+ /**
201
+ * Execute one claimed routine end-to-end: run row → target execution → run/routine
202
+ * bookkeeping → notification decision + delivery. Never throws for target failures —
203
+ * those are recorded on the run row; only run-row creation failures propagate.
204
+ */
205
+ async executeRoutine(routine, context) {
206
+ const contextUser = context.ContextUser;
207
+ const run = await this.createRunRow(routine, contextUser);
208
+ this.log(`Running routine "${routine.Name}" (${routine.TargetType} target)`, true);
209
+ let outcome;
210
+ try {
211
+ outcome = await this.executeTarget(routine, contextUser, context.heartbeat);
212
+ }
213
+ catch (error) {
214
+ outcome = {
215
+ Success: false,
216
+ ResultContent: '',
217
+ ErrorMessage: error instanceof Error ? error.message : String(error),
218
+ AgentRunID: null,
219
+ PromptRunID: null,
220
+ ActionExecutionLogID: null,
221
+ };
222
+ }
223
+ void context.heartbeat?.();
224
+ const resultSummary = this.buildResultSummary(outcome);
225
+ const resultHash = ComputeResultHash(outcome.ResultContent.length > 0 ? outcome.ResultContent : resultSummary);
226
+ const priorResultHash = routine.LastResultHash;
227
+ await this.finalizeRunRow(run, outcome, resultSummary, resultHash);
228
+ await this.updateRoutineAfterRun(routine, run);
229
+ let notified = false;
230
+ if (EvaluateNotifyCondition(routine.NotifyCondition, run.Status, resultHash, priorResultHash)) {
231
+ notified = await this.sendRoutineNotifications(routine, run, contextUser);
232
+ if (notified) {
233
+ run.NotificationSent = true;
234
+ const saved = await run.Save();
235
+ if (!saved) {
236
+ this.logError(`Failed to flag NotificationSent on run ${run.ID}: ${run.LatestResult?.CompleteMessage ?? 'unknown'}`);
237
+ }
238
+ }
239
+ }
240
+ return { RoutineID: routine.ID, RunStatus: run.Status, Notified: notified };
241
+ }
242
+ /** Create the `MJ: User Routine Runs` row in its initial Running state. */
243
+ async createRunRow(routine, contextUser, provider) {
244
+ const md = (provider ?? new Metadata()); // global-provider-ok: server-global scheduled task
245
+ const run = await md.GetEntityObject('MJ: User Routine Runs', contextUser);
246
+ run.NewRecord();
247
+ run.RoutineID = routine.ID;
248
+ run.StartedAt = new Date();
249
+ run.Status = 'Running';
250
+ const saved = await run.Save();
251
+ if (!saved) {
252
+ throw new Error(`Failed to create run row for routine "${routine.Name}": ${run.LatestResult?.CompleteMessage ?? 'unknown'}`);
253
+ }
254
+ return run;
255
+ }
256
+ /** Persist the run's terminal state + linkage. Telemetry stays on the linked records. */
257
+ async finalizeRunRow(run, outcome, resultSummary, resultHash) {
258
+ run.CompletedAt = new Date();
259
+ run.Status = outcome.Success ? 'Success' : 'Failed';
260
+ run.ResultSummary = resultSummary;
261
+ run.ResultHash = resultHash;
262
+ run.ErrorMessage = outcome.ErrorMessage;
263
+ run.AgentRunID = outcome.AgentRunID;
264
+ run.PromptRunID = outcome.PromptRunID;
265
+ run.ActionExecutionLogID = outcome.ActionExecutionLogID;
266
+ const saved = await run.Save();
267
+ if (!saved) {
268
+ this.logError(`Failed to finalize run ${run.ID}: ${run.LatestResult?.CompleteMessage ?? 'unknown'}`);
269
+ }
270
+ }
271
+ /** Roll the run outcome up onto the routine (LastRunAt / LastRunStatus / LastResultHash). */
272
+ async updateRoutineAfterRun(routine, run) {
273
+ routine.LastRunAt = run.StartedAt;
274
+ routine.LastRunStatus = run.Status;
275
+ routine.LastResultHash = run.ResultHash;
276
+ const saved = await routine.Save();
277
+ if (!saved) {
278
+ this.logError(`Failed to update routine "${routine.Name}" after run: ${routine.LatestResult?.CompleteMessage ?? 'unknown'}`);
279
+ }
280
+ }
281
+ /** Compact, capped text describing the outcome — the run row's human-readable summary. */
282
+ buildResultSummary(outcome) {
283
+ const base = outcome.Success
284
+ ? (outcome.ResultContent.trim().length > 0 ? outcome.ResultContent.trim() : 'Completed successfully.')
285
+ : `Failed: ${outcome.ErrorMessage ?? 'unknown error'}`;
286
+ return base.length > RESULT_SUMMARY_MAX_LENGTH ? `${base.substring(0, RESULT_SUMMARY_MAX_LENGTH - 1)}…` : base;
287
+ }
288
+ // ========================================================================
289
+ // Target execution (Agent / Action / Prompt)
290
+ // ========================================================================
291
+ /**
292
+ * Dispatch by target type. The parameter type is derived from the generated entity so
293
+ * a future CHECK-constraint widening surfaces here at compile time.
294
+ */
295
+ async executeTarget(routine, contextUser, heartbeat) {
296
+ const targetType = routine.TargetType;
297
+ switch (targetType) {
298
+ case 'Agent':
299
+ return this.executeAgentTarget(routine, contextUser, heartbeat);
300
+ case 'Prompt':
301
+ return this.executePromptTarget(routine, contextUser);
302
+ case 'Action':
303
+ return this.executeActionTarget(routine, contextUser);
304
+ default:
305
+ throw new Error(`Unsupported routine TargetType '${targetType}' — no executor registered`);
306
+ }
307
+ }
308
+ /**
309
+ * Run an Agent target via AgentRunner, threading StartingPayload + RequestedSkillIDs.
310
+ *
311
+ * When the routine's dedicated conversation is available (existing `ConversationID`,
312
+ * or creatable — see {@link EnsureRoutineConversation}), the run goes through
313
+ * `RunAgentInConversation` so it lands as a proper conversation turn: a user
314
+ * ConversationDetail carrying InitialMessage, an assistant ConversationDetail with the
315
+ * agent result, and the AIAgentRun stamped with ConversationID/ConversationDetailID.
316
+ * When no conversation can be resolved, the run falls back to standalone `RunAgent`
317
+ * — identical outcome recording, just no conversation thread.
318
+ */
319
+ async executeAgentTarget(routine, contextUser, heartbeat) {
320
+ const md = new Metadata(); // global-provider-ok: server-global scheduled task
321
+ const agent = await md.GetEntityObject('MJ: AI Agents', contextUser);
322
+ if (!await agent.Load(routine.TargetID)) {
323
+ throw new Error(`Agent ${routine.TargetID} not found for routine "${routine.Name}"`);
324
+ }
325
+ const userMessage = routine.InitialMessage ?? `Run routine "${routine.Name}"`;
326
+ const runner = new AgentRunner();
327
+ const baseParams = {
328
+ agent,
329
+ conversationMessages: [{ role: 'user', content: userMessage }],
330
+ payload: routine.StartingPayload ? SafeJSONParse(routine.StartingPayload) ?? undefined : undefined,
331
+ requestedSkillIDs: this.parseRequestedSkillIDs(routine),
332
+ contextUser,
333
+ // Keep the dispatcher job's lease alive while a long agent run makes progress.
334
+ onProgress: () => { void heartbeat?.(); },
335
+ };
336
+ const conversationId = await this.EnsureRoutineConversation(routine, contextUser);
337
+ const result = conversationId
338
+ ? (await runner.RunAgentInConversation(baseParams, { conversationId, userMessage })).agentResult
339
+ : await runner.RunAgent(baseParams);
340
+ return {
341
+ Success: result.success,
342
+ ResultContent: result.payload != null ? JSON.stringify(result.payload) : '',
343
+ ErrorMessage: result.agentRun?.ErrorMessage ?? null,
344
+ AgentRunID: result.agentRun?.ID ?? null,
345
+ PromptRunID: null,
346
+ ActionExecutionLogID: null,
347
+ };
348
+ }
349
+ /**
350
+ * Resolves (or lazily creates) the routine's dedicated conversation. Public so the
351
+ * integration suite can exercise the creation/reuse contract without an LLM call.
352
+ *
353
+ * The conversation is owned by the routine's owner and created with
354
+ * `ApplicationScope='Application'` + the "${ROUTINES_APPLICATION_NAME}" Application's ID,
355
+ * which keeps it OUT of the default chat list (the same hide mechanism meeting-room and
356
+ * Form Builder cockpit conversations use) while remaining fully reachable from the
357
+ * routine's UI. It is also Linked to the routine record (LinkedEntityID/LinkedRecordID)
358
+ * and pins the routine's agent as DefaultAgentID.
359
+ *
360
+ * Best-effort by design: any resolution/creation failure logs and returns null so the
361
+ * run proceeds standalone — a missing Routines app must never break a scheduled run.
362
+ */
363
+ async EnsureRoutineConversation(routine, owner) {
364
+ if (routine.ConversationID) {
365
+ return routine.ConversationID;
366
+ }
367
+ try {
368
+ const md = new Metadata(); // global-provider-ok: server-global scheduled task
369
+ const app = await this.findRoutinesApplication(owner);
370
+ if (!app) {
371
+ this.log(`Routines application not found — running "${routine.Name}" standalone (no conversation)`);
372
+ return null;
373
+ }
374
+ const conversation = await md.GetEntityObject('MJ: Conversations', owner);
375
+ conversation.NewRecord();
376
+ conversation.Name = routine.Name;
377
+ conversation.Type = 'Routine';
378
+ conversation.UserID = owner.ID;
379
+ conversation.EnvironmentID = routine.EnvironmentID ?? MJEnvironmentEntityExtended.DefaultEnvironmentID;
380
+ conversation.ApplicationScope = 'Application';
381
+ conversation.ApplicationID = app.ID;
382
+ if (routine.TargetType === 'Agent') {
383
+ conversation.DefaultAgentID = routine.TargetID;
384
+ }
385
+ const routineEntity = md.EntityByName('MJ: User Routines');
386
+ if (routineEntity) {
387
+ conversation.LinkedEntityID = routineEntity.ID;
388
+ conversation.LinkedRecordID = routine.ID;
389
+ }
390
+ if (!await conversation.Save()) {
391
+ this.logError(`Failed to create conversation for routine "${routine.Name}": ${conversation.LatestResult?.CompleteMessage ?? 'unknown'} — running standalone`);
392
+ return null;
393
+ }
394
+ routine.ConversationID = conversation.ID;
395
+ if (!await routine.Save()) {
396
+ // The conversation still serves this run; persistence retries next run.
397
+ this.logError(`Failed to persist ConversationID on routine "${routine.Name}": ${routine.LatestResult?.CompleteMessage ?? 'unknown'}`);
398
+ }
399
+ return conversation.ID;
400
+ }
401
+ catch (error) {
402
+ this.logError(`EnsureRoutineConversation failed for "${routine.Name}": ${error instanceof Error ? error.message : String(error)} — running standalone`);
403
+ return null;
404
+ }
405
+ }
406
+ /** The "${ROUTINES_APPLICATION_NAME}" Application row whose scope hides routine conversations from the default chat list. */
407
+ async findRoutinesApplication(contextUser) {
408
+ const result = await new RunView().RunView({
409
+ EntityName: 'MJ: Applications',
410
+ ExtraFilter: `Name='${ROUTINES_APPLICATION_NAME}'`,
411
+ Fields: ['ID'],
412
+ ResultType: 'simple',
413
+ }, contextUser);
414
+ return result.Success && result.Results.length > 0 ? result.Results[0] : null;
415
+ }
416
+ /** Parse RequestedSkillIDs (JSON array of AISkill IDs) — invalid/non-array content is ignored with a log. */
417
+ parseRequestedSkillIDs(routine) {
418
+ if (!routine.RequestedSkillIDs) {
419
+ return undefined;
420
+ }
421
+ const parsed = SafeJSONParse(routine.RequestedSkillIDs);
422
+ if (Array.isArray(parsed) && parsed.every((v) => typeof v === 'string')) {
423
+ return parsed.length > 0 ? parsed : undefined;
424
+ }
425
+ this.logError(`Routine "${routine.Name}": RequestedSkillIDs is not a JSON string array — ignoring`);
426
+ return undefined;
427
+ }
428
+ /** Run a Prompt target via AIPromptRunner, passing StartingPayload as the data context. */
429
+ async executePromptTarget(routine, contextUser) {
430
+ const md = new Metadata(); // global-provider-ok: server-global scheduled task
431
+ const prompt = await md.GetEntityObject('AI Prompts', contextUser);
432
+ if (!await prompt.Load(routine.TargetID)) {
433
+ throw new Error(`Prompt ${routine.TargetID} not found for routine "${routine.Name}"`);
434
+ }
435
+ const params = new AIPromptParams();
436
+ params.prompt = prompt;
437
+ params.data = routine.StartingPayload ? SafeJSONParse(routine.StartingPayload) ?? {} : {};
438
+ params.contextUser = contextUser;
439
+ const runner = new AIPromptRunner();
440
+ const result = await runner.ExecutePrompt(params);
441
+ return {
442
+ Success: result.success,
443
+ ResultContent: result.rawResult ?? '',
444
+ ErrorMessage: result.errorMessage ?? null,
445
+ AgentRunID: null,
446
+ PromptRunID: result.promptRun?.ID ?? null,
447
+ ActionExecutionLogID: null,
448
+ };
449
+ }
450
+ /** Run an Action target via ActionEngineServer; StartingPayload maps to input params by name. */
451
+ async executeActionTarget(routine, contextUser) {
452
+ await ActionEngineServer.Instance.Config(false, contextUser);
453
+ const action = ActionEngineServer.Instance.Actions.find(a => UUIDsEqual(a.ID, routine.TargetID));
454
+ if (!action) {
455
+ throw new Error(`Action ${routine.TargetID} not found for routine "${routine.Name}"`);
456
+ }
457
+ const actionResult = await ActionEngineServer.Instance.RunAction({
458
+ Action: action,
459
+ ContextUser: contextUser,
460
+ Filters: [],
461
+ Params: this.buildActionParams(routine),
462
+ });
463
+ const outputParams = (actionResult.Params ?? []).filter(p => p.Type === 'Output' || p.Type === 'Both');
464
+ const content = outputParams.length > 0
465
+ ? JSON.stringify(Object.fromEntries(outputParams.map(p => [p.Name, p.Value])))
466
+ : (actionResult.Message ?? '');
467
+ return {
468
+ Success: actionResult.Success,
469
+ ResultContent: content,
470
+ ErrorMessage: actionResult.Success ? null : (actionResult.Message ?? 'Action failed'),
471
+ AgentRunID: null,
472
+ PromptRunID: null,
473
+ ActionExecutionLogID: actionResult.LogEntry?.ID ?? null,
474
+ };
475
+ }
476
+ /** Map the routine's StartingPayload JSON object to ActionParam inputs (key → param name). */
477
+ buildActionParams(routine) {
478
+ if (!routine.StartingPayload) {
479
+ return [];
480
+ }
481
+ const parsed = SafeJSONParse(routine.StartingPayload);
482
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
483
+ this.logError(`Routine "${routine.Name}": StartingPayload for an Action target must be a JSON object of param values — ignoring`);
484
+ return [];
485
+ }
486
+ return Object.entries(parsed).map(([name, value]) => ({ Name: name, Value: value, Type: 'Input' }));
487
+ }
488
+ // ========================================================================
489
+ // Notification rendering + delivery
490
+ // ========================================================================
491
+ /**
492
+ * Deliver notifications for a run to the routine's owner + recipients (Sequence order),
493
+ * honoring per-recipient Channel and the routine's channel toggles. In-app goes through
494
+ * the standard NotificationEngine (metadata-seeded 'User Routine' type) with a raw
495
+ * `MJ: User Notifications` fallback; email is a structured TODO (see queueEmailDelivery).
496
+ *
497
+ * Best-effort: any delivery error is logged, never propagated into the run outcome.
498
+ *
499
+ * @returns true when at least one notification was actually delivered.
500
+ */
501
+ async sendRoutineNotifications(routine, run, contextUser) {
502
+ try {
503
+ const title = `Routine "${routine.Name}": ${run.Status === 'Success' ? 'completed' : 'failed'}`;
504
+ const message = await this.renderNotificationMessage(routine, run, contextUser);
505
+ const recipients = await this.loadRecipients(routine, contextUser);
506
+ let anySent = false;
507
+ const deliveredUserIds = new Set();
508
+ // Owner first, then recipients in Sequence order.
509
+ if (routine.NotifyViaInApp) {
510
+ if (await this.deliverInApp(routine.UserID, title, message, routine, run, contextUser)) {
511
+ anySent = true;
512
+ }
513
+ deliveredUserIds.add(NormalizeUUID(routine.UserID));
514
+ }
515
+ if (routine.NotifyViaEmail) {
516
+ this.queueEmailDelivery(routine.UserID, null, title, message, routine);
517
+ }
518
+ for (const recipient of SortRecipientsBySequence(recipients)) {
519
+ if (recipient.Channel === 'InApp') {
520
+ if (!routine.NotifyViaInApp) {
521
+ continue; // in-app channel disabled at the routine level
522
+ }
523
+ if (!recipient.UserID) {
524
+ this.logError(`Routine "${routine.Name}": InApp recipient ${recipient.ID} has no UserID — skipping`);
525
+ continue;
526
+ }
527
+ if (deliveredUserIds.has(NormalizeUUID(recipient.UserID))) {
528
+ continue; // already notified (e.g. the owner listed as a recipient)
529
+ }
530
+ if (await this.deliverInApp(recipient.UserID, title, message, routine, run, contextUser)) {
531
+ anySent = true;
532
+ }
533
+ deliveredUserIds.add(NormalizeUUID(recipient.UserID));
534
+ }
535
+ else if (recipient.Channel === 'Email') {
536
+ if (!routine.NotifyViaEmail) {
537
+ continue; // email channel disabled at the routine level
538
+ }
539
+ this.queueEmailDelivery(recipient.UserID, recipient.Email, title, message, routine);
540
+ }
541
+ }
542
+ return anySent;
543
+ }
544
+ catch (error) {
545
+ this.logError(`Notification delivery for routine "${routine.Name}" failed (non-fatal)`, error);
546
+ return false;
547
+ }
548
+ }
549
+ /** Load the routine's recipients (ordering applied in JS via SortRecipientsBySequence). */
550
+ async loadRecipients(routine, contextUser) {
551
+ const rv = new RunView(); // global-provider-ok: server-global scheduled task
552
+ const result = await rv.RunView({
553
+ EntityName: 'MJ: User Routine Recipients',
554
+ ExtraFilter: `RoutineID='${routine.ID}'`,
555
+ OrderBy: 'Sequence ASC',
556
+ ResultType: 'entity_object',
557
+ }, contextUser);
558
+ if (!result.Success) {
559
+ this.logError(`Failed to load recipients for routine "${routine.Name}": ${result.ErrorMessage}`);
560
+ return [];
561
+ }
562
+ return result.Results ?? [];
563
+ }
564
+ /**
565
+ * Render the notification body through the MJ template stack: the routine's own
566
+ * NotificationTemplateID when set, else the metadata-seeded default template resolved
567
+ * BY NAME. Falls back to a plain-text body when no template resolves or rendering fails.
568
+ */
569
+ async renderNotificationMessage(routine, run, contextUser) {
570
+ const fallback = this.buildPlainTextMessage(routine, run);
571
+ try {
572
+ await TemplateEngineServer.Instance.Config(false, contextUser);
573
+ const template = this.resolveNotificationTemplate(routine);
574
+ if (!template) {
575
+ return fallback;
576
+ }
577
+ // In-app messages prefer the compact Text (Markdown) body when the template
578
+ // provides one — the HTML body is email-styled and belongs to the email channel.
579
+ const content = template.GetHighestPriorityContent('Text') ?? template.GetHighestPriorityContent();
580
+ if (!content) {
581
+ return fallback;
582
+ }
583
+ // BaseEntity getters are not spreadable — GetAll() yields plain objects for the renderer.
584
+ const data = {
585
+ routine: routine.GetAll(),
586
+ run: run.GetAll(),
587
+ resultSummary: run.ResultSummary ?? '',
588
+ status: run.Status,
589
+ };
590
+ const rendered = await TemplateEngineServer.Instance.RenderTemplate(template, content, data, true);
591
+ if (rendered.Success && rendered.Output) {
592
+ return rendered.Output;
593
+ }
594
+ this.logError(`Template render failed for routine "${routine.Name}": ${rendered.Message ?? 'unknown'} — using plain-text fallback`);
595
+ return fallback;
596
+ }
597
+ catch (error) {
598
+ this.logError(`Template resolution failed for routine "${routine.Name}" — using plain-text fallback`, error);
599
+ return fallback;
600
+ }
601
+ }
602
+ /** Resolve the routine's template (by ID) or the seeded default (by name) from the template cache. */
603
+ resolveNotificationTemplate(routine) {
604
+ if (routine.NotificationTemplateID) {
605
+ const own = TemplateEngineServer.Instance.Templates.find(t => UUIDsEqual(t.ID, routine.NotificationTemplateID));
606
+ if (own) {
607
+ return own;
608
+ }
609
+ this.logError(`Routine "${routine.Name}": NotificationTemplateID ${routine.NotificationTemplateID} not found in template cache — trying the default`);
610
+ }
611
+ return TemplateEngineServer.Instance.FindTemplate(DEFAULT_NOTIFICATION_TEMPLATE_NAME) ?? undefined;
612
+ }
613
+ /** Plain-text notification body used when no template is resolvable. */
614
+ buildPlainTextMessage(routine, run) {
615
+ const when = (run.CompletedAt ?? run.StartedAt).toISOString();
616
+ const statusLine = run.Status === 'Success'
617
+ ? `completed successfully at ${when}.`
618
+ : `failed at ${when}.${run.ErrorMessage ? `\nError: ${run.ErrorMessage}` : ''}`;
619
+ const summary = run.ResultSummary ? `\n\n${run.ResultSummary}` : '';
620
+ return `Routine "${routine.Name}" ${statusLine}${summary}`;
621
+ }
622
+ /**
623
+ * In-app delivery: the standard NotificationEngine path first (respects the seeded
624
+ * 'User Routine' type + per-user preferences); when the engine cannot deliver (e.g. the
625
+ * type is not seeded in this instance), fall back to a raw `MJ: User Notifications` row
626
+ * — the same pattern used by TaskOrchestrator / shareNotification's default dispatch.
627
+ *
628
+ * @returns true when a notification record was actually created (a user who opted out
629
+ * via preferences yields false WITHOUT triggering the raw fallback).
630
+ */
631
+ async deliverInApp(userId, title, message, routine, run, contextUser) {
632
+ const resourceConfiguration = { type: 'UserRoutine', routineId: routine.ID, runId: run.ID };
633
+ try {
634
+ await NotificationEngine.Instance.Config(false, contextUser);
635
+ const result = await NotificationEngine.Instance.SendNotification({
636
+ userId,
637
+ typeNameOrId: NOTIFICATION_TYPE_NAME,
638
+ title,
639
+ message,
640
+ resourceConfiguration,
641
+ }, contextUser);
642
+ if (result.success) {
643
+ // success + no in-app channel means the user opted out — respect it, no fallback.
644
+ return result.deliveryChannels.inApp;
645
+ }
646
+ this.log(`NotificationEngine could not deliver for routine "${routine.Name}" (${(result.errors ?? []).join('; ')}) — using raw fallback`, true);
647
+ }
648
+ catch (error) {
649
+ this.log(`NotificationEngine unavailable (${error instanceof Error ? error.message : error}) — using raw fallback`, true);
650
+ }
651
+ return this.deliverInAppRaw(userId, title, message, resourceConfiguration, contextUser);
652
+ }
653
+ /** Raw `MJ: User Notifications` insert — the minimal, always-available in-app path. */
654
+ async deliverInAppRaw(userId, title, message, resourceConfiguration, contextUser, provider) {
655
+ try {
656
+ const md = (provider ?? new Metadata()); // global-provider-ok: server-global scheduled task
657
+ const notification = await md.GetEntityObject('MJ: User Notifications', contextUser);
658
+ notification.NewRecord();
659
+ notification.UserID = userId;
660
+ notification.Title = title;
661
+ notification.Message = message;
662
+ notification.Unread = true;
663
+ notification.ResourceConfiguration = JSON.stringify(resourceConfiguration);
664
+ const saved = await notification.Save();
665
+ if (!saved) {
666
+ this.logError(`Raw in-app notification save failed for user ${userId}: ${notification.LatestResult?.CompleteMessage ?? 'unknown'}`);
667
+ }
668
+ return saved;
669
+ }
670
+ catch (error) {
671
+ this.logError(`Raw in-app notification save threw for user ${userId}`, error);
672
+ return false;
673
+ }
674
+ }
675
+ /**
676
+ * TODO(email delivery): wire this through the Communication framework
677
+ * (`@memberjunction/communication-engine` — see NotificationEngine.sendEmail for the
678
+ * template + provider pattern). Email delivery needs a configured communication provider
679
+ * plus recipient-address resolution (UserID → user email, or the recipient row's Email),
680
+ * so it is deliberately left as a structured seam rather than half-implemented. In-app
681
+ * delivery is fully functional; this method only logs the gap.
682
+ */
683
+ queueEmailDelivery(userId, email, title, _message, routine) {
684
+ this.log(`[TODO] Email delivery not yet implemented — routine "${routine.Name}" wanted an email ` +
685
+ `notification ("${title}") for ${email ?? `user ${userId ?? 'unknown'}`}. ` +
686
+ `Deliver in-app or implement queueEmailDelivery via the Communication framework.`);
687
+ }
688
+ };
689
+ UserRoutineDispatcherDriver = __decorate([
690
+ RegisterClass(BaseScheduledJob, 'UserRoutineDispatcherDriver')
691
+ ], UserRoutineDispatcherDriver);
692
+ export { UserRoutineDispatcherDriver };
693
+ //# sourceMappingURL=UserRoutineDispatcherDriver.js.map