@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.
- package/CHANGELOG +15 -0
- package/dist/index.js +52 -46
- package/dist/index.js.map +1 -1
- package/dist/types/Components/APIEndpoint.class.d.ts +2 -8
- package/dist/types/Components/Component.class.d.ts +9 -0
- package/dist/types/Components/Triggers/Gmail.trigger.d.ts +0 -17
- package/dist/types/Components/Triggers/JobScheduler.trigger.d.ts +10 -0
- package/dist/types/Components/Triggers/Trigger.class.d.ts +11 -0
- package/dist/types/Components/index.d.ts +6 -0
- package/dist/types/Core/Connector.class.d.ts +1 -0
- package/dist/types/Core/ConnectorsService.d.ts +2 -0
- package/dist/types/Core/HookService.d.ts +1 -1
- package/dist/types/helpers/Conversation.helper.d.ts +2 -0
- package/dist/types/helpers/Crypto.helper.d.ts +8 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/subsystems/AgentManager/Agent.class.d.ts +4 -2
- package/dist/types/subsystems/AgentManager/AgentData.service/AgentDataConnector.d.ts +13 -0
- package/dist/types/subsystems/AgentManager/AgentData.service/connectors/NullAgentData.class.d.ts +1 -4
- package/dist/types/subsystems/AgentManager/Scheduler.service/Job.class.d.ts +29 -6
- package/dist/types/subsystems/AgentManager/Scheduler.service/SchedulerConnector.d.ts +11 -3
- package/dist/types/subsystems/AgentManager/Scheduler.service/connectors/LocalScheduler.class.d.ts +31 -7
- package/dist/types/subsystems/LLMManager/LLM.service/connectors/Perplexity.class.d.ts +2 -5
- package/dist/types/subsystems/LLMManager/LLM.service/connectors/openai/OpenAIConnector.class.d.ts +3 -6
- package/dist/types/subsystems/LLMManager/LLM.service/connectors/openai/apiInterfaces/ResponsesApiInterface.d.ts +7 -0
- package/dist/types/subsystems/LLMManager/LLM.service/connectors/xAI.class.d.ts +2 -5
- package/dist/types/types/Agent.types.d.ts +1 -0
- package/dist/types/types/LLM.types.d.ts +2 -5
- package/dist/types/types/SRE.types.d.ts +4 -1
- package/package.json +6 -2
- package/src/Components/APICall/OAuth.helper.ts +30 -35
- package/src/Components/APIEndpoint.class.ts +25 -6
- package/src/Components/Component.class.ts +11 -0
- package/src/Components/Triggers/Gmail.trigger.ts +282 -0
- package/src/Components/Triggers/JobScheduler.trigger.ts +45 -0
- package/src/Components/Triggers/README.md +3 -0
- package/src/Components/Triggers/Trigger.class.ts +101 -0
- package/src/Components/Triggers/WhatsApp.trigger.ts +219 -0
- package/src/Components/index.ts +8 -0
- package/src/Core/AgentProcess.helper.ts +4 -6
- package/src/Core/Connector.class.ts +11 -3
- package/src/Core/ConnectorsService.ts +5 -0
- package/src/Core/ExternalEventsReceiver.ts +317 -0
- package/src/Core/HookService.ts +20 -6
- package/src/Core/SmythRuntime.class.ts +7 -0
- package/src/Core/SystemEvents.ts +17 -0
- package/src/Core/boot.ts +2 -0
- package/src/helpers/Conversation.helper.ts +35 -11
- package/src/helpers/Crypto.helper.ts +28 -0
- package/src/index.ts +208 -195
- package/src/index.ts.bak +208 -195
- package/src/subsystems/AGENTS.md +594 -0
- package/src/subsystems/AgentManager/Agent.class.ts +71 -21
- package/src/subsystems/AgentManager/AgentData.service/AgentDataConnector.ts +24 -1
- package/src/subsystems/AgentManager/AgentData.service/connectors/NullAgentData.class.ts +2 -2
- package/src/subsystems/AgentManager/AgentRuntime.class.ts +34 -5
- package/src/subsystems/AgentManager/Scheduler.service/Job.class.ts +414 -0
- package/src/subsystems/AgentManager/Scheduler.service/Schedule.class.ts +200 -0
- package/src/subsystems/AgentManager/Scheduler.service/SchedulerConnector.ts +200 -0
- package/src/subsystems/AgentManager/Scheduler.service/connectors/LocalScheduler.class.ts +767 -0
- package/src/subsystems/AgentManager/Scheduler.service/index.ts +11 -0
- package/src/subsystems/IO/VectorDB.service/connectors/MilvusVectorDB.class.ts +1 -1
- package/src/subsystems/LLMManager/LLM.service/LLMCredentials.helper.ts +61 -2
- package/src/subsystems/LLMManager/LLM.service/connectors/Anthropic.class.ts +3 -0
- package/src/subsystems/LLMManager/LLM.service/connectors/Bedrock.class.ts +3 -1
- package/src/subsystems/LLMManager/LLM.service/connectors/Echo.class.ts +5 -1
- package/src/subsystems/LLMManager/LLM.service/connectors/GoogleAI.class.ts +247 -56
- package/src/subsystems/LLMManager/LLM.service/connectors/Groq.class.ts +3 -0
- package/src/subsystems/LLMManager/LLM.service/connectors/Ollama.class.ts +28 -21
- package/src/subsystems/LLMManager/LLM.service/connectors/Perplexity.class.ts +3 -0
- package/src/subsystems/LLMManager/LLM.service/connectors/VertexAI.class.ts +121 -33
- package/src/subsystems/LLMManager/LLM.service/connectors/openai/OpenAIConnector.class.ts +38 -27
- package/src/subsystems/LLMManager/LLM.service/connectors/openai/apiInterfaces/ResponsesApiInterface.ts +115 -18
- package/src/subsystems/LLMManager/LLM.service/connectors/xAI.class.ts +3 -0
- package/src/subsystems/LLMManager/ModelsProvider.service/ModelsProviderConnector.ts +1 -6
- package/src/subsystems/MemoryManager/LLMContext.ts +3 -8
- package/src/subsystems/MemoryManager/RuntimeContext.ts +10 -9
- package/src/subsystems/Security/Credentials/Credentials.class.ts +1 -0
- package/src/subsystems/Security/Credentials/ManagedOAuth2Credentials.class.ts +106 -0
- package/src/types/Agent.types.ts +1 -0
- package/src/types/LLM.types.ts +2 -2
- 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
|
+
}
|