@skelm/scheduler 0.4.2 → 0.4.4

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @skelm/scheduler
2
2
 
3
- > Long-running trigger management for [skelm](https://github.com/scottgl9/skelm) pipelines — cron, interval, webhook, poll, and queue triggers with deduplication and overlap policies.
3
+ > Long-running trigger management for [skelm](https://github.com/scottgl9/skelm) pipelines — cron, interval, and webhook triggers with deduplication and overlap policies.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@skelm/scheduler)](https://www.npmjs.com/package/@skelm/scheduler)
6
6
 
@@ -38,13 +38,11 @@ await scheduler.start()
38
38
 
39
39
  ## Trigger types
40
40
 
41
- | Builder | When it fires |
42
- | ------------------------ | -------------------------------------------------------------------------- |
43
- | `createCronTrigger` | On a cron schedule (`'0 9 * * 1-5'`) |
44
- | `createIntervalTrigger` | Every N milliseconds |
45
- | `createWebhookTrigger` | When the gateway receives a request at the trigger's path |
46
- | `createPollTrigger` | When a polling function returns new items |
47
- | `createQueueTrigger` | When a message arrives on a connected queue (in-memory or external broker) |
41
+ | Builder | When it fires |
42
+ | ------------------------ | --------------------------------------------------------- |
43
+ | `createCronTrigger` | On a cron schedule (`'0 9 * * 1-5'`) |
44
+ | `createIntervalTrigger` | Every N milliseconds |
45
+ | `createWebhookTrigger` | When the gateway receives a request at the trigger's path |
48
46
 
49
47
  ## Policies
50
48
 
@@ -57,13 +55,12 @@ await scheduler.start()
57
55
  ```ts
58
56
  export { Scheduler } from './scheduler.js'
59
57
  export {
60
- createCronTrigger, createIntervalTrigger,
61
- createWebhookTrigger, createPollTrigger, createQueueTrigger,
58
+ createCronTrigger, createIntervalTrigger, createWebhookTrigger,
62
59
  } from './builders.js'
63
60
  export type {
64
61
  SchedulerConfig, Trigger, TriggerRegistration, TriggerContext, TriggerType,
65
62
  DedupePolicy, OverlapPolicy, TriggerBase,
66
- CronTrigger, IntervalTrigger, WebhookTrigger, PollTrigger, QueueTrigger,
63
+ CronTrigger, IntervalTrigger, WebhookTrigger,
67
64
  TriggerOptions,
68
65
  } from './types.js'
69
66
  ```
@@ -1,4 +1,4 @@
1
- import type { CronTrigger, DedupePolicy, IntervalTrigger, OverlapPolicy, PollTrigger, QueueTrigger, WebhookTrigger } from './types.js';
1
+ import type { CronTrigger, DedupePolicy, IntervalTrigger, OverlapPolicy, WebhookTrigger } from './types.js';
2
2
  /** Base trigger options */
3
3
  export interface TriggerOptions {
4
4
  description?: string;
@@ -20,15 +20,3 @@ export declare function createWebhookTrigger(id: string, pipelineId: string, pat
20
20
  secret?: string;
21
21
  transformPayload?: (payload: unknown) => unknown;
22
22
  }): WebhookTrigger;
23
- /** Create a poll trigger */
24
- export declare function createPollTrigger(id: string, pipelineId: string, url: string, intervalMs: number, options?: TriggerOptions & {
25
- headers?: Record<string, string>;
26
- detectNew?: (previous: unknown, current: unknown) => boolean;
27
- extractInput?: (data: unknown) => unknown | null;
28
- }): PollTrigger;
29
- /** Create a queue trigger */
30
- export declare function createQueueTrigger(id: string, pipelineId: string, queueName: string, options?: TriggerOptions & {
31
- batchSize?: number;
32
- visibilityTimeoutMs?: number;
33
- extractInput?: (message: unknown) => unknown | null;
34
- }): QueueTrigger;
package/dist/builders.js CHANGED
@@ -87,68 +87,3 @@ export function createWebhookTrigger(id, pipelineId, path, options = {}) {
87
87
  }
88
88
  return result;
89
89
  }
90
- /** Create a poll trigger */
91
- export function createPollTrigger(id, pipelineId, url, intervalMs, options = {}) {
92
- const result = {
93
- id,
94
- type: 'poll',
95
- url,
96
- intervalMs,
97
- pipelineId,
98
- enabled: options.enabled ?? true,
99
- dedupe: options.dedupe ?? 'skip',
100
- overlap: options.overlap ?? 'wait',
101
- };
102
- if (options.description !== undefined) {
103
- result.description = options.description;
104
- }
105
- if (options.maxConcurrent !== undefined) {
106
- result.maxConcurrent = options.maxConcurrent;
107
- }
108
- if (options.headers !== undefined) {
109
- result.headers = options.headers;
110
- }
111
- if (options.detectNew !== undefined) {
112
- result.detectNew = options.detectNew;
113
- }
114
- if (options.extractInput !== undefined) {
115
- result.extractInput = options.extractInput;
116
- }
117
- if (options.inputTemplate !== undefined) {
118
- result.inputTemplate = options.inputTemplate;
119
- }
120
- if (options.metadata !== undefined) {
121
- result.metadata = options.metadata;
122
- }
123
- return result;
124
- }
125
- /** Create a queue trigger */
126
- export function createQueueTrigger(id, pipelineId, queueName, options = {}) {
127
- const result = {
128
- id,
129
- type: 'queue',
130
- queueName,
131
- pipelineId,
132
- enabled: options.enabled ?? true,
133
- dedupe: options.dedupe ?? 'skip',
134
- overlap: options.overlap ?? 'run-concurrent',
135
- maxConcurrent: options.maxConcurrent ?? 5,
136
- batchSize: options.batchSize ?? 1,
137
- };
138
- if (options.description !== undefined) {
139
- result.description = options.description;
140
- }
141
- if (options.visibilityTimeoutMs !== undefined) {
142
- result.visibilityTimeoutMs = options.visibilityTimeoutMs;
143
- }
144
- if (options.extractInput !== undefined) {
145
- result.extractInput = options.extractInput;
146
- }
147
- if (options.inputTemplate !== undefined) {
148
- result.inputTemplate = options.inputTemplate;
149
- }
150
- if (options.metadata !== undefined) {
151
- result.metadata = options.metadata;
152
- }
153
- return result;
154
- }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * with deduplication and overlap policies.
6
6
  */
7
7
  export { Scheduler } from './scheduler.js';
8
- export { createCronTrigger, createIntervalTrigger, createWebhookTrigger, createPollTrigger, createQueueTrigger, } from './builders.js';
9
- export type { SchedulerConfig, Trigger, TriggerRegistration, TriggerContext, TriggerType, DedupePolicy, OverlapPolicy, TriggerBase, CronTrigger, IntervalTrigger, WebhookTrigger, PollTrigger, QueueTrigger, } from './types.js';
8
+ export type { SchedulerRunStore, SchedulerPipelineLoader, SchedulerPipelineExecutor, } from './scheduler.js';
9
+ export { createCronTrigger, createIntervalTrigger, createWebhookTrigger, } from './builders.js';
10
+ export type { SchedulerConfig, Trigger, TriggerRegistration, TriggerContext, TriggerType, DedupePolicy, OverlapPolicy, TriggerBase, CronTrigger, IntervalTrigger, WebhookTrigger, } from './types.js';
10
11
  export type { TriggerOptions } from './builders.js';
package/dist/index.js CHANGED
@@ -5,4 +5,4 @@
5
5
  * with deduplication and overlap policies.
6
6
  */
7
7
  export { Scheduler } from './scheduler.js';
8
- export { createCronTrigger, createIntervalTrigger, createWebhookTrigger, createPollTrigger, createQueueTrigger, } from './builders.js';
8
+ export { createCronTrigger, createIntervalTrigger, createWebhookTrigger, } from './builders.js';
@@ -1,8 +1,20 @@
1
- import type { SchedulerConfig, Trigger, TriggerRegistration } from './types.js';
1
+ import type { Pipeline, Run, RunStore } from '@skelm/core';
2
+ import type { SchedulerConfig, Trigger, TriggerContext, TriggerRegistration } from './types.js';
3
+ /** The slice of `RunStore` the scheduler depends on for recording triggered runs. */
4
+ export type SchedulerRunStore = Pick<RunStore, 'putRun'>;
5
+ /** Resolves a registered pipeline ID to the executable pipeline, or `null` if unknown. */
6
+ export type SchedulerPipelineLoader = (pipelineId: string) => Promise<Pipeline | null>;
7
+ /**
8
+ * Executes a loaded pipeline for a trigger fire. Must return the terminal
9
+ * Run record (with status set to one of `completed`, `failed`, or
10
+ * `cancelled` and `completedAt` populated) so the scheduler can persist a
11
+ * complete entry. When omitted, fires register the trigger metadata but
12
+ * do not produce Run records — see Scheduler.executeTrigger.
13
+ */
14
+ export type SchedulerPipelineExecutor = (pipeline: Pipeline, input: unknown, ctx: TriggerContext) => Promise<Run>;
2
15
  /**
3
16
  * Scheduler manages long-running triggers for pipelines.
4
- * Handles cron, interval, webhook, poll, and queue-based triggers
5
- * with deduplication and overlap policies.
17
+ * Handles cron, interval, and webhook triggers with overlap policies.
6
18
  */
7
19
  export declare class Scheduler {
8
20
  private readonly config;
@@ -10,17 +22,18 @@ export declare class Scheduler {
10
22
  private readonly cronJobs;
11
23
  private readonly intervalJobs;
12
24
  private webhookServer;
13
- private pollJobs;
14
- private queueJobs;
15
25
  private readonly inFlight;
26
+ private readonly runningCount;
27
+ private readonly lastRun;
16
28
  private isRunning;
17
29
  private readonly runStore;
18
30
  private readonly pipelineLoader;
31
+ private readonly pipelineExecutor;
32
+ private readonly noExecutorWarned;
19
33
  constructor(config: SchedulerConfig, deps: {
20
- runStore: {
21
- putRun: (run: unknown) => Promise<void>;
22
- };
23
- pipelineLoader: (pipelineId: string) => Promise<unknown>;
34
+ runStore: SchedulerRunStore;
35
+ pipelineLoader: SchedulerPipelineLoader;
36
+ pipelineExecutor?: SchedulerPipelineExecutor;
24
37
  });
25
38
  /** Register a new trigger */
26
39
  register(trigger: Trigger): Promise<TriggerRegistration>;
@@ -41,9 +54,9 @@ export declare class Scheduler {
41
54
  /** Start all triggers */
42
55
  start(): Promise<void>;
43
56
  /**
44
- * Stop all triggers. Clears the interval timers, then waits up to 30s
45
- * for any in-flight executeTrigger callbacks to settle so a SIGTERM
46
- * does not leave fire-and-forget executions racing the process exit.
57
+ * Stop all triggers. Clears the timers, then waits up to 30s for any
58
+ * in-flight executeTrigger callbacks to settle so a SIGTERM does not
59
+ * leave fire-and-forget executions racing the process exit.
47
60
  *
48
61
  * Runs unconditionally — `register()` arms timers immediately without
49
62
  * setting isRunning, so a stop() that gated on isRunning would leak
@@ -54,15 +67,16 @@ export declare class Scheduler {
54
67
  private track;
55
68
  private drainInFlight;
56
69
  private startCronTrigger;
70
+ private scheduleNextCron;
57
71
  private stopCronTrigger;
58
72
  private startIntervalTrigger;
59
73
  private stopIntervalTrigger;
60
- private startPollTrigger;
61
- private stopPollTrigger;
62
- private startQueueTrigger;
63
- private stopQueueTrigger;
74
+ /**
75
+ * Apply the overlap policy and schedule the execution.
76
+ * - fail-fast: skip if a previous run is in progress.
77
+ * - wait: chain after the previous run (next() resolves before we start).
78
+ * - run-concurrent: start immediately regardless.
79
+ */
80
+ private fire;
64
81
  private executeTrigger;
65
- private executePollTrigger;
66
- private executeQueueTrigger;
67
- private parseCronToInterval;
68
82
  }
package/dist/scheduler.js CHANGED
@@ -1,7 +1,7 @@
1
+ import parser from 'cron-parser';
1
2
  /**
2
3
  * Scheduler manages long-running triggers for pipelines.
3
- * Handles cron, interval, webhook, poll, and queue-based triggers
4
- * with deduplication and overlap policies.
4
+ * Handles cron, interval, and webhook triggers with overlap policies.
5
5
  */
6
6
  export class Scheduler {
7
7
  config;
@@ -9,12 +9,14 @@ export class Scheduler {
9
9
  cronJobs = new Map();
10
10
  intervalJobs = new Map();
11
11
  webhookServer = null;
12
- pollJobs = new Map();
13
- queueJobs = new Map();
14
12
  inFlight = new Set();
13
+ runningCount = new Map();
14
+ lastRun = new Map();
15
15
  isRunning = false;
16
16
  runStore;
17
17
  pipelineLoader;
18
+ pipelineExecutor;
19
+ noExecutorWarned = new Set();
18
20
  constructor(config, deps) {
19
21
  const cfg = {
20
22
  webhookPort: config.webhookPort ?? 3001,
@@ -28,6 +30,7 @@ export class Scheduler {
28
30
  this.config = cfg;
29
31
  this.runStore = deps.runStore;
30
32
  this.pipelineLoader = deps.pipelineLoader;
33
+ this.pipelineExecutor = deps.pipelineExecutor;
31
34
  }
32
35
  /** Register a new trigger */
33
36
  async register(trigger) {
@@ -40,7 +43,6 @@ export class Scheduler {
40
43
  status: 'active',
41
44
  };
42
45
  this.triggers.set(trigger.id, registration);
43
- // Start the trigger based on type
44
46
  switch (trigger.type) {
45
47
  case 'cron':
46
48
  this.startCronTrigger(trigger);
@@ -51,12 +53,6 @@ export class Scheduler {
51
53
  case 'webhook':
52
54
  // Webhook server starts separately via startWebhookServer()
53
55
  break;
54
- case 'poll':
55
- this.startPollTrigger(trigger);
56
- break;
57
- case 'queue':
58
- this.startQueueTrigger(trigger);
59
- break;
60
56
  }
61
57
  return registration;
62
58
  }
@@ -65,11 +61,10 @@ export class Scheduler {
65
61
  const registration = this.triggers.get(triggerId);
66
62
  if (!registration)
67
63
  return;
68
- // Stop any running jobs
69
64
  this.stopCronTrigger(triggerId);
70
65
  this.stopIntervalTrigger(triggerId);
71
- this.stopPollTrigger(triggerId);
72
- this.stopQueueTrigger(triggerId);
66
+ this.lastRun.delete(triggerId);
67
+ this.runningCount.delete(triggerId);
73
68
  this.triggers.delete(triggerId);
74
69
  }
75
70
  /** Pause a trigger */
@@ -79,11 +74,8 @@ export class Scheduler {
79
74
  return;
80
75
  registration.status = 'paused';
81
76
  registration.updatedAt = Date.now();
82
- // Stop jobs for time-based triggers
83
77
  this.stopCronTrigger(triggerId);
84
78
  this.stopIntervalTrigger(triggerId);
85
- this.stopPollTrigger(triggerId);
86
- this.stopQueueTrigger(triggerId);
87
79
  }
88
80
  /** Resume a paused trigger */
89
81
  async resume(triggerId) {
@@ -95,7 +87,6 @@ export class Scheduler {
95
87
  registration.status = 'active';
96
88
  registration.updatedAt = Date.now();
97
89
  const trigger = registration.trigger;
98
- // Restart jobs based on type
99
90
  switch (trigger.type) {
100
91
  case 'cron':
101
92
  this.startCronTrigger(trigger);
@@ -103,12 +94,6 @@ export class Scheduler {
103
94
  case 'interval':
104
95
  this.startIntervalTrigger(trigger);
105
96
  break;
106
- case 'poll':
107
- this.startPollTrigger(trigger);
108
- break;
109
- case 'queue':
110
- this.startQueueTrigger(trigger);
111
- break;
112
97
  }
113
98
  }
114
99
  /** List all registered triggers */
@@ -121,13 +106,10 @@ export class Scheduler {
121
106
  }
122
107
  /** Start the webhook server */
123
108
  async startWebhookServer() {
124
- // Webhook server implementation would go here
125
- // Uses h3 or similar for HTTP handling
126
109
  console.log(`Webhook server would start on ${this.config.webhookHost}:${this.config.webhookPort}`);
127
110
  }
128
111
  /** Stop the webhook server */
129
112
  async stopWebhookServer() {
130
- // Stop webhook server
131
113
  this.webhookServer = null;
132
114
  }
133
115
  /** Start all triggers */
@@ -145,20 +127,14 @@ export class Scheduler {
145
127
  case 'interval':
146
128
  this.startIntervalTrigger(trigger);
147
129
  break;
148
- case 'poll':
149
- this.startPollTrigger(trigger);
150
- break;
151
- case 'queue':
152
- this.startQueueTrigger(trigger);
153
- break;
154
130
  }
155
131
  }
156
132
  }
157
133
  }
158
134
  /**
159
- * Stop all triggers. Clears the interval timers, then waits up to 30s
160
- * for any in-flight executeTrigger callbacks to settle so a SIGTERM
161
- * does not leave fire-and-forget executions racing the process exit.
135
+ * Stop all triggers. Clears the timers, then waits up to 30s for any
136
+ * in-flight executeTrigger callbacks to settle so a SIGTERM does not
137
+ * leave fire-and-forget executions racing the process exit.
162
138
  *
163
139
  * Runs unconditionally — `register()` arms timers immediately without
164
140
  * setting isRunning, so a stop() that gated on isRunning would leak
@@ -166,23 +142,14 @@ export class Scheduler {
166
142
  */
167
143
  async stop() {
168
144
  this.isRunning = false;
169
- // Clear all jobs
170
145
  for (const [id, job] of this.cronJobs) {
171
- clearInterval(job);
146
+ clearTimeout(job);
172
147
  this.cronJobs.delete(id);
173
148
  }
174
149
  for (const [id, job] of this.intervalJobs) {
175
150
  clearInterval(job);
176
151
  this.intervalJobs.delete(id);
177
152
  }
178
- for (const [id, job] of this.pollJobs) {
179
- clearInterval(job);
180
- this.pollJobs.delete(id);
181
- }
182
- for (const [id, job] of this.queueJobs) {
183
- clearInterval(job);
184
- this.queueJobs.delete(id);
185
- }
186
153
  await this.drainInFlight(30_000);
187
154
  }
188
155
  /** Track a fire-and-forget execution so stop() can drain it. */
@@ -199,29 +166,51 @@ export class Scheduler {
199
166
  ]);
200
167
  }
201
168
  startCronTrigger(trigger) {
202
- // Simple cron implementation using setInterval
203
- // In production, use 'cron' package for proper cron expression parsing
204
- const intervalMs = this.parseCronToInterval(trigger.schedule);
205
- const job = setInterval(() => {
206
- if (trigger.enabled && this.triggers.get(trigger.id)?.status === 'active') {
207
- this.track(this.executeTrigger(trigger));
169
+ this.scheduleNextCron(trigger);
170
+ }
171
+ scheduleNextCron(trigger) {
172
+ let delay;
173
+ try {
174
+ const opts = { currentDate: new Date() };
175
+ if (trigger.timezone !== undefined)
176
+ opts.tz = trigger.timezone;
177
+ const next = parser.parseExpression(trigger.schedule, opts).next().getTime();
178
+ delay = Math.max(0, next - Date.now());
179
+ }
180
+ catch (err) {
181
+ const registration = this.triggers.get(trigger.id);
182
+ if (registration) {
183
+ registration.status = 'error';
184
+ registration.lastError = `Invalid cron expression "${trigger.schedule}": ${err.message}`;
208
185
  }
209
- }, intervalMs);
210
- this.cronJobs.set(trigger.id, job);
186
+ console.error(`Trigger ${trigger.id} disabled: invalid cron "${trigger.schedule}"`);
187
+ return;
188
+ }
189
+ const handle = setTimeout(() => {
190
+ const reg = this.triggers.get(trigger.id);
191
+ if (!reg || reg.status !== 'active' || !trigger.enabled)
192
+ return;
193
+ // Reschedule the next firing first so a slow run doesn't drift the schedule.
194
+ this.scheduleNextCron(trigger);
195
+ this.fire(trigger);
196
+ }, delay);
197
+ handle.unref?.();
198
+ this.cronJobs.set(trigger.id, handle);
211
199
  }
212
200
  stopCronTrigger(triggerId) {
213
201
  const job = this.cronJobs.get(triggerId);
214
202
  if (job) {
215
- clearInterval(job);
203
+ clearTimeout(job);
216
204
  this.cronJobs.delete(triggerId);
217
205
  }
218
206
  }
219
207
  startIntervalTrigger(trigger) {
220
208
  const job = setInterval(() => {
221
209
  if (trigger.enabled && this.triggers.get(trigger.id)?.status === 'active') {
222
- this.track(this.executeTrigger(trigger));
210
+ this.fire(trigger);
223
211
  }
224
212
  }, trigger.intervalMs);
213
+ job.unref?.();
225
214
  this.intervalJobs.set(trigger.id, job);
226
215
  }
227
216
  stopIntervalTrigger(triggerId) {
@@ -231,42 +220,29 @@ export class Scheduler {
231
220
  this.intervalJobs.delete(triggerId);
232
221
  }
233
222
  }
234
- startPollTrigger(trigger) {
235
- const job = setInterval(() => {
236
- if (trigger.enabled && this.triggers.get(trigger.id)?.status === 'active') {
237
- this.track(this.executePollTrigger(trigger));
238
- }
239
- }, trigger.intervalMs);
240
- this.pollJobs.set(trigger.id, job);
241
- }
242
- stopPollTrigger(triggerId) {
243
- const job = this.pollJobs.get(triggerId);
244
- if (job) {
245
- clearInterval(job);
246
- this.pollJobs.delete(triggerId);
247
- }
248
- }
249
- startQueueTrigger(trigger) {
250
- const job = setInterval(() => {
251
- if (trigger.enabled && this.triggers.get(trigger.id)?.status === 'active') {
252
- this.track(this.executeQueueTrigger(trigger));
253
- }
254
- }, this.config.queuePollIntervalMs);
255
- this.queueJobs.set(trigger.id, job);
256
- }
257
- stopQueueTrigger(triggerId) {
258
- const job = this.queueJobs.get(triggerId);
259
- if (job) {
260
- clearInterval(job);
261
- this.queueJobs.delete(triggerId);
262
- }
223
+ /**
224
+ * Apply the overlap policy and schedule the execution.
225
+ * - fail-fast: skip if a previous run is in progress.
226
+ * - wait: chain after the previous run (next() resolves before we start).
227
+ * - run-concurrent: start immediately regardless.
228
+ */
229
+ fire(trigger) {
230
+ const previous = this.lastRun.get(trigger.id);
231
+ const isRunning = (this.runningCount.get(trigger.id) ?? 0) > 0;
232
+ const policy = trigger.overlap ?? 'wait';
233
+ if (isRunning && policy === 'fail-fast')
234
+ return;
235
+ const start = isRunning && policy === 'wait' && previous ? previous : Promise.resolve();
236
+ const promise = start.then(() => this.executeTrigger(trigger));
237
+ this.lastRun.set(trigger.id, promise);
238
+ this.track(promise);
263
239
  }
264
240
  async executeTrigger(trigger) {
265
241
  const registration = this.triggers.get(trigger.id);
266
242
  if (!registration)
267
243
  return;
244
+ this.runningCount.set(trigger.id, (this.runningCount.get(trigger.id) ?? 0) + 1);
268
245
  try {
269
- // Check deduplication
270
246
  const ctx = {
271
247
  triggerId: trigger.id,
272
248
  runId: `trigger-${trigger.id}-${Date.now()}`,
@@ -275,47 +251,35 @@ export class Scheduler {
275
251
  deduped: false,
276
252
  overlapHandled: false,
277
253
  };
278
- // Handle overlap policy - check if already running
279
- if (registration.status === 'active') {
280
- // Check for concurrent run tracking (simplified)
281
- const hasActiveRun = false; // Would track active runs in production
282
- if (hasActiveRun) {
283
- ctx.overlapHandled = true;
284
- switch (trigger.overlap ?? 'wait') {
285
- case 'fail-fast':
286
- console.log(`Trigger ${trigger.id} skipped: overlap policy is fail-fast`);
287
- return;
288
- case 'run-concurrent':
289
- // Allow concurrent runs
290
- break;
291
- default:
292
- console.log(`Trigger ${trigger.id} waiting for previous run to complete`);
293
- return;
294
- }
295
- }
296
- }
297
- // Execute the pipeline
298
254
  const pipeline = await this.pipelineLoader(trigger.pipelineId);
299
255
  if (!pipeline) {
300
256
  throw new Error(`Pipeline ${trigger.pipelineId} not found`);
301
257
  }
302
- // Create and store the run
303
- const run = {
304
- runId: ctx.runId,
305
- pipelineId: trigger.pipelineId,
306
- input: trigger.inputTemplate ?? {},
307
- status: 'running',
308
- steps: {},
309
- output: undefined,
310
- error: undefined,
311
- startedAt: Date.now(),
312
- completedAt: undefined,
313
- };
314
- await this.runStore.putRun(run);
315
- registration.runCount++;
316
- registration.lastRunAt = Date.now();
317
- registration.status = 'active';
318
- console.log(`Trigger ${trigger.id} executed run ${ctx.runId}`);
258
+ if (this.pipelineExecutor === undefined) {
259
+ // No executor wired: register the fire but do NOT persist a
260
+ // status='running' Run. Persisting one without anything to
261
+ // finalize it leaves an orphan that crash-recovery cannot
262
+ // distinguish from a real interrupted run. Production callers
263
+ // wire pipelineExecutor (typically the gateway TriggerCoordinator,
264
+ // not this class). Warn once per trigger so the misconfig is loud.
265
+ if (!this.noExecutorWarned.has(trigger.id)) {
266
+ this.noExecutorWarned.add(trigger.id);
267
+ console.warn(`Scheduler: trigger ${trigger.id} fired but no pipelineExecutor is configured; Run will not be persisted. Wire deps.pipelineExecutor to execute pipelines.`);
268
+ }
269
+ registration.runCount++;
270
+ registration.lastRunAt = Date.now();
271
+ registration.status = 'active';
272
+ }
273
+ else {
274
+ const input = trigger.inputTemplate ?? {};
275
+ const run = await this.pipelineExecutor(pipeline, input, ctx);
276
+ await this.runStore.putRun(run);
277
+ registration.runCount++;
278
+ registration.lastRunAt = Date.now();
279
+ registration.status = run.status === 'failed' ? 'error' : 'active';
280
+ if (run.error?.message)
281
+ registration.lastError = run.error.message;
282
+ }
319
283
  }
320
284
  catch (err) {
321
285
  registration.errorCount++;
@@ -323,39 +287,14 @@ export class Scheduler {
323
287
  registration.lastError = err.message;
324
288
  console.error(`Trigger ${trigger.id} error:`, err);
325
289
  }
326
- }
327
- async executePollTrigger(trigger) {
328
- // Poll implementation would fetch from URL and check for new items
329
- console.log(`Polling ${trigger.url} for trigger ${trigger.id}`);
330
- await this.executeTrigger(trigger);
331
- }
332
- async executeQueueTrigger(trigger) {
333
- // Queue implementation would poll message queue and process batches
334
- console.log(`Checking queue ${trigger.queueName} for trigger ${trigger.id}`);
335
- await this.executeTrigger(trigger);
336
- }
337
- parseCronToInterval(cron) {
338
- // Simplified cron-to-interval conversion
339
- // In production, use a proper cron library
340
- if (!cron || typeof cron !== 'string') {
341
- return 5 * 60 * 1000; // Default to 5 minutes
342
- }
343
- const parts = cron.split(' ');
344
- if (parts.length !== 5) {
345
- // Default to 5 minutes if invalid
346
- return 5 * 60 * 1000;
347
- }
348
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
349
- // Simple heuristic for common patterns
350
- if (minute === '*')
351
- return 60 * 1000; // Every minute
352
- if (hour === '*')
353
- return 60 * 60 * 1000; // Every hour
354
- if (dayOfMonth === '*' && dayOfWeek === '*') {
355
- const minVal = Number.parseInt(minute || '0', 10) || 0;
356
- return minVal * 60 * 1000;
290
+ finally {
291
+ const n = (this.runningCount.get(trigger.id) ?? 1) - 1;
292
+ if (n <= 0) {
293
+ this.runningCount.delete(trigger.id);
294
+ }
295
+ else {
296
+ this.runningCount.set(trigger.id, n);
297
+ }
357
298
  }
358
- // Default to 5 minutes
359
- return 5 * 60 * 1000;
360
299
  }
361
300
  }
package/dist/types.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * with dedupe and overlap policies.
6
6
  */
7
7
  /** Trigger types */
8
- export type TriggerType = 'cron' | 'interval' | 'webhook' | 'poll' | 'queue';
8
+ export type TriggerType = 'cron' | 'interval' | 'webhook';
9
9
  /** Trigger deduplication strategies */
10
10
  export type DedupePolicy = 'skip' | 'overwrite' | 'queue';
11
11
  /** Trigger overlap strategies */
@@ -42,24 +42,7 @@ export interface WebhookTrigger extends TriggerBase {
42
42
  secret?: string;
43
43
  transformPayload?: (payload: unknown) => unknown;
44
44
  }
45
- /** Poll trigger - check external source periodically */
46
- export interface PollTrigger extends TriggerBase {
47
- type: 'poll';
48
- url: string;
49
- intervalMs: number;
50
- headers?: Record<string, string>;
51
- detectNew?: (previous: unknown, current: unknown) => boolean;
52
- extractInput?: (data: unknown) => unknown | null;
53
- }
54
- /** Queue trigger - message queue based */
55
- export interface QueueTrigger extends TriggerBase {
56
- type: 'queue';
57
- queueName: string;
58
- batchSize?: number;
59
- visibilityTimeoutMs?: number;
60
- extractInput?: (message: unknown) => unknown | null;
61
- }
62
- export type Trigger = CronTrigger | IntervalTrigger | WebhookTrigger | PollTrigger | QueueTrigger;
45
+ export type Trigger = CronTrigger | IntervalTrigger | WebhookTrigger;
63
46
  /** Trigger registration */
64
47
  export interface TriggerRegistration {
65
48
  trigger: Trigger;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skelm/scheduler",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "license": "MIT",
5
5
  "author": "Scott Glover <scottgl@gmail.com>",
6
6
  "homepage": "https://skelm.dev/",
@@ -44,7 +44,8 @@
44
44
  "test": "vitest run"
45
45
  },
46
46
  "dependencies": {
47
- "@skelm/core": "^0.4.2"
47
+ "@skelm/core": "^0.4.4",
48
+ "cron-parser": "^4.9.0"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@types/node": "^22.15.0",