@smythos/sre 1.6.14 → 1.7.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.
Files changed (81) hide show
  1. package/CHANGELOG +15 -0
  2. package/dist/index.js +52 -46
  3. package/dist/index.js.map +1 -1
  4. package/dist/types/Components/APIEndpoint.class.d.ts +2 -8
  5. package/dist/types/Components/Component.class.d.ts +9 -0
  6. package/dist/types/Components/Triggers/Gmail.trigger.d.ts +0 -17
  7. package/dist/types/Components/Triggers/JobScheduler.trigger.d.ts +10 -0
  8. package/dist/types/Components/Triggers/Trigger.class.d.ts +11 -0
  9. package/dist/types/Components/index.d.ts +6 -0
  10. package/dist/types/Core/Connector.class.d.ts +1 -0
  11. package/dist/types/Core/ConnectorsService.d.ts +2 -0
  12. package/dist/types/Core/HookService.d.ts +1 -1
  13. package/dist/types/helpers/Conversation.helper.d.ts +2 -0
  14. package/dist/types/helpers/Crypto.helper.d.ts +8 -0
  15. package/dist/types/index.d.ts +13 -0
  16. package/dist/types/subsystems/AgentManager/Agent.class.d.ts +4 -2
  17. package/dist/types/subsystems/AgentManager/AgentData.service/AgentDataConnector.d.ts +13 -0
  18. package/dist/types/subsystems/AgentManager/AgentData.service/connectors/NullAgentData.class.d.ts +1 -4
  19. package/dist/types/subsystems/AgentManager/Scheduler.service/Job.class.d.ts +29 -6
  20. package/dist/types/subsystems/AgentManager/Scheduler.service/SchedulerConnector.d.ts +11 -3
  21. package/dist/types/subsystems/AgentManager/Scheduler.service/connectors/LocalScheduler.class.d.ts +31 -7
  22. package/dist/types/subsystems/LLMManager/LLM.service/connectors/Perplexity.class.d.ts +2 -5
  23. package/dist/types/subsystems/LLMManager/LLM.service/connectors/openai/OpenAIConnector.class.d.ts +3 -6
  24. package/dist/types/subsystems/LLMManager/LLM.service/connectors/openai/apiInterfaces/ResponsesApiInterface.d.ts +7 -0
  25. package/dist/types/subsystems/LLMManager/LLM.service/connectors/xAI.class.d.ts +2 -5
  26. package/dist/types/types/Agent.types.d.ts +1 -0
  27. package/dist/types/types/LLM.types.d.ts +2 -5
  28. package/dist/types/types/SRE.types.d.ts +4 -1
  29. package/package.json +6 -2
  30. package/src/Components/APICall/OAuth.helper.ts +30 -35
  31. package/src/Components/APIEndpoint.class.ts +25 -6
  32. package/src/Components/Component.class.ts +11 -0
  33. package/src/Components/Triggers/Gmail.trigger.ts +282 -0
  34. package/src/Components/Triggers/JobScheduler.trigger.ts +45 -0
  35. package/src/Components/Triggers/README.md +3 -0
  36. package/src/Components/Triggers/Trigger.class.ts +101 -0
  37. package/src/Components/Triggers/WhatsApp.trigger.ts +219 -0
  38. package/src/Components/index.ts +8 -0
  39. package/src/Core/AgentProcess.helper.ts +4 -6
  40. package/src/Core/Connector.class.ts +11 -3
  41. package/src/Core/ConnectorsService.ts +5 -0
  42. package/src/Core/ExternalEventsReceiver.ts +317 -0
  43. package/src/Core/HookService.ts +20 -6
  44. package/src/Core/SmythRuntime.class.ts +7 -0
  45. package/src/Core/SystemEvents.ts +17 -0
  46. package/src/Core/boot.ts +2 -0
  47. package/src/helpers/Conversation.helper.ts +35 -11
  48. package/src/helpers/Crypto.helper.ts +28 -0
  49. package/src/index.ts +208 -195
  50. package/src/index.ts.bak +208 -195
  51. package/src/subsystems/AGENTS.md +594 -0
  52. package/src/subsystems/AgentManager/Agent.class.ts +71 -21
  53. package/src/subsystems/AgentManager/AgentData.service/AgentDataConnector.ts +24 -1
  54. package/src/subsystems/AgentManager/AgentData.service/connectors/NullAgentData.class.ts +2 -2
  55. package/src/subsystems/AgentManager/AgentRuntime.class.ts +34 -5
  56. package/src/subsystems/AgentManager/Scheduler.service/Job.class.ts +414 -0
  57. package/src/subsystems/AgentManager/Scheduler.service/Schedule.class.ts +200 -0
  58. package/src/subsystems/AgentManager/Scheduler.service/SchedulerConnector.ts +200 -0
  59. package/src/subsystems/AgentManager/Scheduler.service/connectors/LocalScheduler.class.ts +767 -0
  60. package/src/subsystems/AgentManager/Scheduler.service/index.ts +11 -0
  61. package/src/subsystems/IO/VectorDB.service/connectors/MilvusVectorDB.class.ts +1 -1
  62. package/src/subsystems/LLMManager/LLM.service/LLMCredentials.helper.ts +61 -2
  63. package/src/subsystems/LLMManager/LLM.service/connectors/Anthropic.class.ts +3 -0
  64. package/src/subsystems/LLMManager/LLM.service/connectors/Bedrock.class.ts +3 -1
  65. package/src/subsystems/LLMManager/LLM.service/connectors/Echo.class.ts +5 -1
  66. package/src/subsystems/LLMManager/LLM.service/connectors/GoogleAI.class.ts +247 -56
  67. package/src/subsystems/LLMManager/LLM.service/connectors/Groq.class.ts +3 -0
  68. package/src/subsystems/LLMManager/LLM.service/connectors/Ollama.class.ts +28 -21
  69. package/src/subsystems/LLMManager/LLM.service/connectors/Perplexity.class.ts +3 -0
  70. package/src/subsystems/LLMManager/LLM.service/connectors/VertexAI.class.ts +121 -33
  71. package/src/subsystems/LLMManager/LLM.service/connectors/openai/OpenAIConnector.class.ts +38 -27
  72. package/src/subsystems/LLMManager/LLM.service/connectors/openai/apiInterfaces/ResponsesApiInterface.ts +115 -18
  73. package/src/subsystems/LLMManager/LLM.service/connectors/xAI.class.ts +3 -0
  74. package/src/subsystems/LLMManager/ModelsProvider.service/ModelsProviderConnector.ts +1 -6
  75. package/src/subsystems/MemoryManager/LLMContext.ts +3 -8
  76. package/src/subsystems/MemoryManager/RuntimeContext.ts +10 -9
  77. package/src/subsystems/Security/Credentials/Credentials.class.ts +1 -0
  78. package/src/subsystems/Security/Credentials/ManagedOAuth2Credentials.class.ts +106 -0
  79. package/src/types/Agent.types.ts +1 -0
  80. package/src/types/LLM.types.ts +2 -2
  81. package/src/types/SRE.types.ts +3 -0
@@ -0,0 +1,767 @@
1
+ //==[ SRE: LocalScheduler ]======================
2
+
3
+ import { Logger } from '@sre/helpers/Log.helper';
4
+ import { SchedulerConnector, IScheduledJob, IJobExecution } from '../SchedulerConnector';
5
+ import { Schedule, IScheduleData } from '../Schedule.class';
6
+ import { Job, IJobConfig } from '../Job.class';
7
+ import { ACL } from '@sre/Security/AccessControl/ACL.class';
8
+ import { IAccessCandidate, IACL, TAccessLevel } from '@sre/types/ACL.types';
9
+ import { AccessRequest } from '@sre/Security/AccessControl/AccessRequest.class';
10
+ import { SecureConnector } from '@sre/Security/SecureConnector.class';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { ConnectorService } from '@sre/Core/ConnectorsService';
14
+ import { findSmythPath } from '@sre/helpers/Sysconfig.helper';
15
+ import { AccessCandidate } from '../../../Security/AccessControl/AccessCandidate.class';
16
+ const logger = Logger('LocalScheduler');
17
+
18
+ export type LocalSchedulerConfig = {
19
+ /**
20
+ * The folder to use for scheduler storage.
21
+ * Defaults to ~/.smyth/scheduler
22
+ */
23
+ folder?: string;
24
+
25
+ /**
26
+ * Enable job execution
27
+ * If true: loads jobs at start and executes them on schedule
28
+ * If false: allows job management but skips all execution (for multi-instance setups)
29
+ * Defaults to true
30
+ */
31
+ runJobs?: boolean;
32
+
33
+ /**
34
+ * Keep execution history
35
+ * Defaults to true
36
+ */
37
+ persistExecutionHistory?: boolean;
38
+
39
+ /**
40
+ * Maximum execution history entries to keep per job
41
+ * Defaults to 100
42
+ */
43
+ maxHistoryEntries?: number;
44
+ };
45
+
46
+ /**
47
+ * Runtime job data structure
48
+ */
49
+ interface ScheduledJobRuntime extends IScheduledJob {
50
+ candidateRole: string; // Owner's role (not serialized, implicit from folder)
51
+ candidateId: string; // Owner's ID (not serialized, implicit from folder)
52
+ }
53
+
54
+ /**
55
+ * LocalScheduler - Disk-based scheduler implementation
56
+ *
57
+ * Stores jobs in JSON files under ~/.smyth/scheduler/ (or configured folder).
58
+ * Loads and schedules jobs on initialization.
59
+ * Provides full ACL-based access control and candidate isolation.
60
+ *
61
+ * **Multi-Instance Safety**: Uses static storage to prevent race conditions when
62
+ * multiple scheduler instances are running. Job timers are shared across all instances,
63
+ * ensuring each job runs only once even if scheduled by multiple instances.
64
+ *
65
+ * **Multi-Instance Setup**: Use the `runJobs` config to control execution:
66
+ * - Set `runJobs: true` on ONE instance to execute jobs
67
+ * - Set `runJobs: false` on other instances to only manage jobs (add/delete/pause/resume)
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * // Execution instance (runs jobs)
72
+ * const scheduler = new LocalScheduler({ folder: '~/.smyth/scheduler', runJobs: true });
73
+ *
74
+ * // Management-only instance (no execution)
75
+ * const managerScheduler = new LocalScheduler({ folder: '~/.smyth/scheduler', runJobs: false });
76
+ *
77
+ * const candidate = new AccessCandidate(TAccessRole.User, 'user123');
78
+ * const requester = scheduler.requester(candidate);
79
+ *
80
+ * const schedule = Schedule.every('10m');
81
+ * const job = new Job(async () => {
82
+ * console.log('Running scheduled task');
83
+ * }, { name: 'My Periodic Job' });
84
+ *
85
+ * await requester.add('job1', schedule, job);
86
+ * ```
87
+ */
88
+ export class LocalScheduler extends SchedulerConnector {
89
+ public name = 'LocalScheduler';
90
+ public id = 'local';
91
+
92
+ // ===[ Static shared storage across all instances ]===
93
+ // This prevents race conditions when multiple scheduler instances are running
94
+ private static jobs: Map<string, ScheduledJobRuntime> = new Map();
95
+ private static timers: Map<string, NodeJS.Timeout> = new Map();
96
+
97
+ // ===[ Instance-specific properties ]===
98
+ private folder: string;
99
+ private jobsPrefix = 'jobs'; // Job configurations
100
+ private runtimePrefix = '.jobs.runtime'; // Execution history and runtime data
101
+ private isInitialized = false;
102
+ private config: Required<LocalSchedulerConfig>;
103
+
104
+ constructor(protected _settings?: LocalSchedulerConfig) {
105
+ super(_settings);
106
+
107
+ this.config = {
108
+ folder: _settings?.folder || '',
109
+ runJobs: _settings?.runJobs !== false,
110
+ persistExecutionHistory: _settings?.persistExecutionHistory !== false,
111
+ maxHistoryEntries: _settings?.maxHistoryEntries || 100,
112
+ };
113
+
114
+ this.folder = this.findSchedulerFolder(this.config.folder);
115
+ this.initialize();
116
+
117
+ if (!fs.existsSync(this.folder)) {
118
+ logger.warn(`Invalid folder provided: ${this.folder}`);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Find or create the scheduler storage folder
124
+ */
125
+ private findSchedulerFolder(folder?: string): string {
126
+ let _schedulerFolder = folder;
127
+
128
+ if (_schedulerFolder && fs.existsSync(_schedulerFolder)) {
129
+ return _schedulerFolder;
130
+ }
131
+
132
+ _schedulerFolder = findSmythPath('scheduler');
133
+
134
+ if (fs.existsSync(_schedulerFolder)) {
135
+ logger.warn('Using alternative scheduler folder found in : ', _schedulerFolder);
136
+ return _schedulerFolder;
137
+ }
138
+
139
+ logger.warn('!!! All attempts to find an existing scheduler folder failed !!!');
140
+ logger.warn('!!! I will use this folder: ', _schedulerFolder);
141
+ return _schedulerFolder;
142
+ }
143
+
144
+ /**
145
+ * Initialize the scheduler
146
+ */
147
+ private async initialize() {
148
+ //TODO : watch the folder with chokidar and reload jobs data
149
+
150
+ // Create jobs folder if not exists
151
+ const jobsFolderPath = path.join(this.folder, this.jobsPrefix);
152
+ if (!fs.existsSync(jobsFolderPath)) {
153
+ fs.mkdirSync(jobsFolderPath, { recursive: true });
154
+ fs.writeFileSync(
155
+ path.join(jobsFolderPath, 'README_IMPORTANT.txt'),
156
+ 'This folder contains scheduler job configurations. Do not delete it.'
157
+ );
158
+ }
159
+
160
+ // Create runtime folder if not exists
161
+ const runtimeFolderPath = path.join(this.folder, this.runtimePrefix);
162
+ if (!fs.existsSync(runtimeFolderPath)) {
163
+ fs.mkdirSync(runtimeFolderPath, { recursive: true });
164
+ fs.writeFileSync(
165
+ path.join(runtimeFolderPath, 'README_IMPORTANT.txt'),
166
+ 'This folder contains scheduler runtime data and execution history. Safe to delete if needed.'
167
+ );
168
+ }
169
+
170
+ // Load existing jobs if runJobs is enabled
171
+ if (this.config.runJobs) {
172
+ await this.loadJobsFromDisk();
173
+ }
174
+
175
+ this.isInitialized = true;
176
+ logger.info(`LocalScheduler initialized (runJobs: ${this.config.runJobs})`);
177
+ }
178
+
179
+ /**
180
+ * Get the candidate folder name based on role and id
181
+ * Examples: "john.user", "developers.team", "bot1.agent"
182
+ */
183
+ private getCandidateFolderName(candidate: IAccessCandidate): string {
184
+ return `${candidate.id}.${candidate.role}`;
185
+ }
186
+
187
+ /**
188
+ * Get the job configuration file path
189
+ * Format: scheduler/jobs/<username>.user/<role_id_jobId>.json
190
+ */
191
+ private getJobFilePath(candidate: IAccessCandidate, jobId: string, createFoldersIfNotExists: boolean = false): string {
192
+ const candidateFolder = this.getCandidateFolderName(candidate);
193
+ const jobFilename = jobId;
194
+ const fullPath = path.join(this.folder, this.jobsPrefix, candidateFolder, `${jobFilename}.json`);
195
+
196
+ if (createFoldersIfNotExists) {
197
+ const folder = path.dirname(fullPath);
198
+ if (!fs.existsSync(folder)) {
199
+ fs.mkdirSync(folder, { recursive: true });
200
+ }
201
+ }
202
+
203
+ return fullPath;
204
+ }
205
+
206
+ /**
207
+ * Get the runtime data file path
208
+ * Format: scheduler/.jobs.runtime/<username>.user/<role_id_jobId>.json
209
+ */
210
+ private getRuntimeFilePath(candidate: IAccessCandidate, jobId: string, createFoldersIfNotExists: boolean = false): string {
211
+ const candidateFolder = this.getCandidateFolderName(candidate);
212
+ const jobFilename = jobId;
213
+ const fullPath = path.join(this.folder, this.runtimePrefix, candidateFolder, `${jobFilename}.json`);
214
+
215
+ if (createFoldersIfNotExists) {
216
+ const folder = path.dirname(fullPath);
217
+ if (!fs.existsSync(folder)) {
218
+ fs.mkdirSync(folder, { recursive: true });
219
+ }
220
+ }
221
+
222
+ return fullPath;
223
+ }
224
+
225
+ /**
226
+ * Load all jobs from disk and schedule active ones
227
+ */
228
+ private async loadJobsFromDisk() {
229
+ try {
230
+ const jobsFolderPath = path.join(this.folder, this.jobsPrefix);
231
+ if (!fs.existsSync(jobsFolderPath)) {
232
+ return;
233
+ }
234
+
235
+ // Iterate through candidate folders (format: "<id>.<role>")
236
+ const candidateFolders = fs.readdirSync(jobsFolderPath).filter((f) => {
237
+ const fullPath = path.join(jobsFolderPath, f);
238
+ return fs.statSync(fullPath).isDirectory();
239
+ });
240
+
241
+ for (const candidateFolder of candidateFolders) {
242
+ // Parse candidate info from folder name: "username.user" -> {id: "username", role: "user"}
243
+ const lastDotIndex = candidateFolder.lastIndexOf('.');
244
+ if (lastDotIndex === -1) {
245
+ logger.warn(`Invalid candidate folder format: ${candidateFolder}`);
246
+ continue;
247
+ }
248
+
249
+ const candidateId = candidateFolder.substring(0, lastDotIndex);
250
+ const candidateRole = candidateFolder.substring(lastDotIndex + 1);
251
+
252
+ const candidate: IAccessCandidate = {
253
+ id: candidateId,
254
+ role: candidateRole as any, // Role is validated by folder structure
255
+ };
256
+
257
+ const candidatePath = path.join(jobsFolderPath, candidateFolder);
258
+ const jobFiles = fs.readdirSync(candidatePath).filter((f) => f.endsWith('.json'));
259
+
260
+ for (const file of jobFiles) {
261
+ try {
262
+ const filePath = path.join(candidatePath, file);
263
+ const data = fs.readFileSync(filePath, 'utf-8');
264
+ const jobConfig: Partial<ScheduledJobRuntime> = JSON.parse(data);
265
+
266
+ // Load runtime data if it exists
267
+ const runtimeData = await this.loadRuntimeDataFromDisk(candidate, jobConfig.id!);
268
+
269
+ // Merge config and runtime data
270
+ const jobData: ScheduledJobRuntime = {
271
+ ...jobConfig,
272
+ ...runtimeData,
273
+ candidateRole: candidate.role,
274
+ candidateId: candidate.id,
275
+ } as ScheduledJobRuntime;
276
+
277
+ // Construct job key based on candidate and job id
278
+ const jobKey = this.constructJobKey(candidate, jobData.id);
279
+
280
+ // Store in static shared memory (thread-safe across instances)
281
+ LocalScheduler.jobs.set(jobKey, jobData);
282
+
283
+ // Schedule active jobs for execution
284
+ if (jobData.status === 'active') {
285
+ logger.info(`Job ${jobData.id} loaded from ${candidateFolder} and scheduled for execution`);
286
+ await this.scheduleJob(jobData);
287
+ }
288
+ } catch (error) {
289
+ logger.warn(`Error loading job file ${file} from ${candidateFolder}:`, error);
290
+ }
291
+ }
292
+ }
293
+
294
+ logger.info(`Loaded ${LocalScheduler.jobs.size} jobs from disk`);
295
+ } catch (error) {
296
+ logger.warn('Error loading jobs from disk', error);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Save job configuration to disk (without execution history or candidate info)
302
+ */
303
+ private async saveJobToDisk(candidate: IAccessCandidate, jobData: ScheduledJobRuntime): Promise<void> {
304
+ try {
305
+ const filePath = this.getJobFilePath(candidate, jobData.id, true);
306
+
307
+ // Don't serialize: execution history, candidate info (implicit from folder), createdBy (deprecated)
308
+ const { executionHistory, lastRun, nextRun, candidateRole, candidateId, createdBy, ...configData } = jobData;
309
+
310
+ fs.writeFileSync(filePath, JSON.stringify(configData, null, 2), 'utf-8');
311
+ } catch (error) {
312
+ logger.warn(`Error saving job ${jobData.id} to disk`, error);
313
+ throw error;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Save runtime data to disk (execution history, last run, next run)
319
+ */
320
+ private async saveRuntimeDataToDisk(candidate: IAccessCandidate, jobData: ScheduledJobRuntime): Promise<void> {
321
+ try {
322
+ const filePath = this.getRuntimeFilePath(candidate, jobData.id, true);
323
+
324
+ const runtimeData = {
325
+ executionHistory: jobData.executionHistory || [],
326
+ lastRun: jobData.lastRun,
327
+ nextRun: jobData.nextRun,
328
+ };
329
+
330
+ fs.writeFileSync(filePath, JSON.stringify(runtimeData, null, 2), 'utf-8');
331
+ } catch (error) {
332
+ logger.warn(`Error saving runtime data for job ${jobData.id}`, error);
333
+ //throw error;
334
+ return;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Load runtime data from disk
340
+ */
341
+ private async loadRuntimeDataFromDisk(candidate: IAccessCandidate, jobId: string): Promise<Partial<ScheduledJobRuntime>> {
342
+ try {
343
+ const filePath = this.getRuntimeFilePath(candidate, jobId);
344
+ if (!fs.existsSync(filePath)) {
345
+ return {};
346
+ }
347
+
348
+ const data = fs.readFileSync(filePath, 'utf-8');
349
+ return JSON.parse(data);
350
+ } catch (error) {
351
+ logger.warn(`Error loading runtime data for job ${jobId}`, error);
352
+ return {};
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Delete job files from disk (both config and runtime)
358
+ */
359
+ private async deleteJobFromDisk(candidate: IAccessCandidate, jobId: string): Promise<void> {
360
+ try {
361
+ // Delete job configuration
362
+ const jobFilePath = this.getJobFilePath(candidate, jobId);
363
+ if (fs.existsSync(jobFilePath)) {
364
+ fs.unlinkSync(jobFilePath);
365
+ }
366
+
367
+ // Delete runtime data
368
+ const runtimeFilePath = this.getRuntimeFilePath(candidate, jobId);
369
+ if (fs.existsSync(runtimeFilePath)) {
370
+ fs.unlinkSync(runtimeFilePath);
371
+ }
372
+ } catch (error) {
373
+ logger.warn(`Error deleting job ${jobId} from disk`, error);
374
+ return;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Schedule a job for execution
380
+ * Jobs are fully serializable and can be executed after restart
381
+ *
382
+ * **Multi-Instance Safety**: Before scheduling, checks if a timer already exists
383
+ * for this job (from another scheduler instance) and clears it. This ensures
384
+ * each job has exactly one active timer across all instances.
385
+ */
386
+ private async scheduleJob(jobData: ScheduledJobRuntime): Promise<void> {
387
+ // Only schedule if job is active
388
+ if (jobData.status !== 'active') {
389
+ return;
390
+ }
391
+
392
+ const schedule = Schedule.fromJSON(jobData.schedule);
393
+
394
+ // Validate schedule
395
+ const validation = schedule.validate();
396
+ if (!validation.valid) {
397
+ logger.warn(`Invalid schedule for job ${jobData.id}: ${validation.error}`);
398
+ throw new Error(`Invalid schedule for job ${jobData.id}: ${validation.error}`);
399
+ }
400
+
401
+ // Construct job key for timer lookup
402
+ const jobKey = this.constructJobKey({ role: jobData.candidateRole, id: jobData.candidateId } as IAccessCandidate, jobData.id);
403
+
404
+ // ===[ CRITICAL: Clear existing timer if present ]===
405
+ // This handles the case where multiple scheduler instances try to schedule the same job
406
+ // The job key is unique, so we can safely overwrite any existing timer
407
+ const existingTimer = LocalScheduler.timers.get(jobKey);
408
+ if (existingTimer) {
409
+ logger.info(`Clearing existing timer for job ${jobData.id} (overwriting duplicate schedule)`);
410
+ clearInterval(existingTimer);
411
+ LocalScheduler.timers.delete(jobKey);
412
+ }
413
+
414
+ // For interval-based scheduling
415
+ if (jobData.schedule.interval) {
416
+ const intervalMs = Schedule.parseInterval(jobData.schedule.interval);
417
+
418
+ // Schedule the job
419
+ const timer = setInterval(async () => {
420
+ await this.executeJob(jobData);
421
+ }, intervalMs);
422
+ timer.unref();
423
+
424
+ // Store timer reference in static shared storage
425
+ LocalScheduler.timers.set(jobKey, timer);
426
+
427
+ // Execute immediately if no last run
428
+ // if (!jobData.lastRun) {
429
+ // await this.executeJob(jobData);
430
+ // }
431
+ }
432
+
433
+ // For cron-based scheduling (would require node-cron)
434
+ if (jobData.schedule.cron) {
435
+ logger.warn(`Cron scheduling not yet implemented for job ${jobData.id}`);
436
+ throw new Error(`Cron scheduling not yet implemented for job ${jobData.id}`);
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Unschedule a job
442
+ * Uses the job key (not just jobId) to access the static timer storage
443
+ */
444
+ private async unscheduleJob(jobKey: string): Promise<void> {
445
+ const timer = LocalScheduler.timers.get(jobKey);
446
+ if (timer) {
447
+ clearInterval(timer);
448
+ LocalScheduler.timers.delete(jobKey);
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Execute a job
454
+ */
455
+ private async executeJob(jobData: ScheduledJobRuntime): Promise<void> {
456
+ if (!jobData.jobConfig) {
457
+ logger.warn(`Skipping execution of job ${jobData.id}: job configuration not available.`);
458
+ return;
459
+ }
460
+
461
+ const schedule = Schedule.fromJSON(jobData.schedule);
462
+
463
+ // Check if job should run (date range validation)
464
+ if (!schedule.shouldRun()) {
465
+ logger.info(`Job ${jobData.id} skipped - outside schedule window`);
466
+ return;
467
+ }
468
+
469
+ const job = Job.fromJSON(jobData.jobConfig);
470
+
471
+ logger.debug(`Executing job ${jobData.id} with metadata:`, JSON.stringify(jobData.jobConfig.metadata));
472
+
473
+ // Emit 'executing' event before running the job
474
+ const owner = jobData.createdBy;
475
+ this.emit('executing', { id: jobData.id, job, owner });
476
+
477
+ const result = await job.executeWithRetry();
478
+
479
+ // Emit 'executed' event after job completes with results
480
+ this.emit('executed', { id: jobData.id, job, owner, result });
481
+
482
+ // Update execution metadata (not job status - status is user-controlled only)
483
+ jobData.lastRun = new Date().toISOString();
484
+ const nextRun = schedule.calculateNextRun(new Date(jobData.lastRun));
485
+ jobData.nextRun = nextRun ? nextRun.toISOString() : undefined;
486
+
487
+ if (!result.success) {
488
+ logger.warn(`Job ${jobData.id} failed:`, result.error?.message);
489
+ // Note: We do NOT change status here - execution results are stored in history only
490
+ }
491
+
492
+ // Add to execution history
493
+ if (this.config.persistExecutionHistory) {
494
+ if (!jobData.executionHistory) {
495
+ jobData.executionHistory = [];
496
+ }
497
+
498
+ jobData.executionHistory.unshift({
499
+ timestamp: new Date().toISOString(),
500
+ success: result.success,
501
+ error: result.error?.message,
502
+ executionTime: result.executionTime,
503
+ retries: result.retries,
504
+ });
505
+
506
+ // Limit history size
507
+ if (jobData.executionHistory.length > this.config.maxHistoryEntries) {
508
+ jobData.executionHistory = jobData.executionHistory.slice(0, this.config.maxHistoryEntries);
509
+ }
510
+ }
511
+
512
+ // Construct candidate object for file operations
513
+ const candidate: IAccessCandidate = {
514
+ role: jobData.candidateRole as any,
515
+ id: jobData.candidateId,
516
+ };
517
+
518
+ // Save updated job configuration
519
+ await this.saveJobToDisk(candidate, jobData);
520
+
521
+ // Save runtime data (execution history)
522
+ if (this.config.persistExecutionHistory) {
523
+ await this.saveRuntimeDataToDisk(candidate, jobData);
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Get resource ACL for a job
529
+ */
530
+ public async getResourceACL(resourceId: string, candidate: IAccessCandidate): Promise<ACL> {
531
+ if (!this.isInitialized) {
532
+ await this.initialize();
533
+ }
534
+
535
+ const accountConnector = ConnectorService.getAccountConnector();
536
+ const teamId = await accountConnector.getCandidateTeam(candidate);
537
+ const jobKey = this.constructJobKey(candidate, resourceId);
538
+ const jobData = LocalScheduler.jobs.get(jobKey);
539
+ const jobAgentTeamId = jobData?.jobConfig?.agentId
540
+ ? await accountConnector.getCandidateTeam(AccessCandidate.agent(jobData?.jobConfig?.agentId))
541
+ : null;
542
+
543
+ if (jobAgentTeamId && teamId !== jobAgentTeamId) {
544
+ return new ACL(); //deny access
545
+ }
546
+
547
+ if (!jobData) {
548
+ // Resource doesn't exist, grant Owner access to allow creation
549
+ return new ACL().addAccess(candidate.role, candidate.id, TAccessLevel.Owner);
550
+ }
551
+
552
+ return ACL.from(jobData.acl);
553
+ }
554
+
555
+ @SecureConnector.AccessControl
556
+ protected async list(acRequest: AccessRequest): Promise<IScheduledJob[]> {
557
+ if (!this.isInitialized) {
558
+ await this.initialize();
559
+ }
560
+
561
+ const result: IScheduledJob[] = [];
562
+
563
+ // Filter jobs by candidate (using candidateRole and candidateId from folder structure)
564
+ for (const [key, jobData] of LocalScheduler.jobs) {
565
+ if (jobData.candidateRole === acRequest.candidate.role && jobData.candidateId === acRequest.candidate.id) {
566
+ // Don't include internal candidate fields in the result
567
+ const { candidateRole, candidateId, ...serializableData } = jobData;
568
+ result.push(serializableData);
569
+ }
570
+ }
571
+
572
+ return result;
573
+ }
574
+
575
+ @SecureConnector.AccessControl
576
+ protected async add(acRequest: AccessRequest, jobId: string, job: Job, schedule: Schedule): Promise<void> {
577
+ if (!this.isInitialized) {
578
+ await this.initialize();
579
+ }
580
+
581
+ // Validate schedule
582
+ const validation = schedule.validate();
583
+ if (!validation.valid) {
584
+ logger.warn(`Invalid schedule: ${validation.error}`);
585
+ return;
586
+ }
587
+
588
+ const jobKey = this.constructJobKey(acRequest.candidate, jobId);
589
+ const existingJob = LocalScheduler.jobs.get(jobKey);
590
+
591
+ // Create ACL
592
+ let acl: ACL;
593
+ if (existingJob) {
594
+ // Preserve existing ACL with owner access
595
+ acl = ACL.from(existingJob.acl).addAccess(acRequest.candidate.role, acRequest.candidate.id, TAccessLevel.Owner);
596
+ } else {
597
+ // New job - create ACL with owner access
598
+ acl = new ACL().addAccess(acRequest.candidate.role, acRequest.candidate.id, TAccessLevel.Owner);
599
+ }
600
+
601
+ // Calculate next run time
602
+ const nextRun = schedule.calculateNextRun();
603
+
604
+ const jobData: ScheduledJobRuntime = {
605
+ id: jobId, // Use original jobId, not jobKey
606
+ schedule: schedule.toJSON(),
607
+ jobConfig: job.toJSON(),
608
+ acl: acl.ACL,
609
+ status: 'active',
610
+ nextRun: nextRun ? nextRun.toISOString() : undefined,
611
+ createdBy: {
612
+ role: acRequest.candidate.role,
613
+ id: acRequest.candidate.id,
614
+ },
615
+ candidateRole: acRequest.candidate.role,
616
+ candidateId: acRequest.candidate.id,
617
+ executionHistory: existingJob?.executionHistory || [],
618
+ };
619
+
620
+ // Unschedule existing job if updating (this also clears any duplicate timers from other instances)
621
+ if (existingJob) {
622
+ await this.unscheduleJob(jobKey);
623
+ }
624
+
625
+ // Store in static shared memory
626
+ LocalScheduler.jobs.set(jobKey, jobData);
627
+
628
+ // Save to disk
629
+ await this.saveJobToDisk(acRequest.candidate, jobData);
630
+
631
+ // Schedule for execution only if runJobs is enabled (will clear any duplicate timers)
632
+ if (this.config.runJobs) {
633
+ await this.scheduleJob(jobData);
634
+ }
635
+
636
+ logger.info(`Job ${jobId} added successfully`);
637
+ }
638
+
639
+ @SecureConnector.AccessControl
640
+ protected async delete(acRequest: AccessRequest, jobId: string): Promise<void> {
641
+ if (!this.isInitialized) {
642
+ await this.initialize();
643
+ }
644
+
645
+ const jobKey = this.constructJobKey(acRequest.candidate, jobId);
646
+ const jobData = LocalScheduler.jobs.get(jobKey);
647
+
648
+ if (!jobData) {
649
+ //throw new Error(`Job ${jobId} not found`);
650
+ logger.warn(`Job ${jobId} not found`);
651
+ return;
652
+ }
653
+
654
+ // Unschedule (clears timer from static storage)
655
+ await this.unscheduleJob(jobKey);
656
+
657
+ // Remove from static shared memory
658
+ LocalScheduler.jobs.delete(jobKey);
659
+
660
+ // Delete from disk
661
+ await this.deleteJobFromDisk(acRequest.candidate, jobId);
662
+
663
+ logger.info(`Job ${jobId} deleted successfully`);
664
+ }
665
+
666
+ @SecureConnector.AccessControl
667
+ protected async get(acRequest: AccessRequest, jobId: string): Promise<IScheduledJob | undefined> {
668
+ if (!this.isInitialized) {
669
+ await this.initialize();
670
+ }
671
+
672
+ const jobKey = this.constructJobKey(acRequest.candidate, jobId);
673
+ const jobData = LocalScheduler.jobs.get(jobKey);
674
+
675
+ if (!jobData) {
676
+ return undefined;
677
+ }
678
+
679
+ // Don't include internal candidate fields in the result
680
+ const { candidateRole, candidateId, ...serializableData } = jobData;
681
+ return serializableData;
682
+ }
683
+
684
+ @SecureConnector.AccessControl
685
+ protected async pause(acRequest: AccessRequest, jobId: string): Promise<void> {
686
+ if (!this.isInitialized) {
687
+ await this.initialize();
688
+ }
689
+
690
+ const jobKey = this.constructJobKey(acRequest.candidate, jobId);
691
+ const jobData = LocalScheduler.jobs.get(jobKey);
692
+
693
+ if (!jobData) {
694
+ //throw new Error(`Job ${jobId} not found`);
695
+ logger.warn(`Job ${jobId} not found`);
696
+ return;
697
+ }
698
+
699
+ if (jobData.status === 'paused') {
700
+ return; // Already paused
701
+ }
702
+
703
+ // Unschedule (clears timer from static storage)
704
+ await this.unscheduleJob(jobKey);
705
+
706
+ // Update status
707
+ jobData.status = 'paused';
708
+
709
+ // Save to disk
710
+ await this.saveJobToDisk(acRequest.candidate, jobData);
711
+
712
+ logger.info(`Job ${jobId} paused`);
713
+ }
714
+
715
+ @SecureConnector.AccessControl
716
+ protected async resume(acRequest: AccessRequest, jobId: string): Promise<void> {
717
+ if (!this.isInitialized) {
718
+ await this.initialize();
719
+ }
720
+
721
+ const jobKey = this.constructJobKey(acRequest.candidate, jobId);
722
+ const jobData = LocalScheduler.jobs.get(jobKey);
723
+
724
+ if (!jobData) {
725
+ //throw new Error(`Job ${jobId} not found`);
726
+ logger.warn(`Job ${jobId} not found`);
727
+ return;
728
+ }
729
+
730
+ if (jobData.status !== 'paused') {
731
+ return; // Not paused
732
+ }
733
+
734
+ // Update status
735
+ jobData.status = 'active';
736
+
737
+ // Save to disk
738
+ await this.saveJobToDisk(acRequest.candidate, jobData);
739
+
740
+ // Schedule for execution only if runJobs is enabled (will clear any duplicate timers)
741
+ if (this.config.runJobs) {
742
+ await this.scheduleJob(jobData);
743
+ }
744
+
745
+ logger.info(`Job ${jobId} resumed`);
746
+ }
747
+
748
+ /**
749
+ * Cleanup - stop all scheduled jobs
750
+ *
751
+ * Note: This clears ALL timers in the static storage, affecting all instances.
752
+ * Use with caution in multi-instance environments.
753
+ */
754
+ public async shutdown(): Promise<void> {
755
+ logger.info('Shutting down LocalScheduler...');
756
+
757
+ const timerCount = LocalScheduler.timers.size;
758
+
759
+ // Clear all timers from static storage
760
+ for (const [jobKey, timer] of LocalScheduler.timers) {
761
+ clearInterval(timer);
762
+ }
763
+ LocalScheduler.timers.clear();
764
+
765
+ logger.info(`LocalScheduler shutdown complete (cleared ${timerCount} timers)`);
766
+ }
767
+ }