@memberjunction/scheduling-engine 5.44.0 → 5.45.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/dist/BaseScheduledJob.d.ts +8 -0
- package/dist/BaseScheduledJob.d.ts.map +1 -1
- package/dist/BaseScheduledJob.js +10 -0
- package/dist/BaseScheduledJob.js.map +1 -1
- package/dist/ScheduledJobEngine.d.ts +40 -0
- package/dist/ScheduledJobEngine.d.ts.map +1 -1
- package/dist/ScheduledJobEngine.js +80 -1
- package/dist/ScheduledJobEngine.js.map +1 -1
- package/dist/UserRoutineProcessor.d.ts +105 -0
- package/dist/UserRoutineProcessor.d.ts.map +1 -0
- package/dist/UserRoutineProcessor.js +169 -0
- package/dist/UserRoutineProcessor.js.map +1 -0
- package/dist/drivers/UserRoutineDispatcherDriver.d.ts +164 -0
- package/dist/drivers/UserRoutineDispatcherDriver.d.ts.map +1 -0
- package/dist/drivers/UserRoutineDispatcherDriver.js +693 -0
- package/dist/drivers/UserRoutineDispatcherDriver.js.map +1 -0
- package/dist/drivers/index.d.ts +1 -0
- package/dist/drivers/index.d.ts.map +1 -1
- package/dist/drivers/index.js +1 -0
- package/dist/drivers/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +17 -13
|
@@ -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
|