@renseiai/agentfactory 0.8.7 → 0.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/config/index.d.ts +1 -1
- package/dist/src/config/index.d.ts.map +1 -1
- package/dist/src/config/index.js +1 -1
- package/dist/src/config/repository-config.d.ts +37 -0
- package/dist/src/config/repository-config.d.ts.map +1 -1
- package/dist/src/config/repository-config.js +47 -0
- package/dist/src/config/repository-config.test.js +140 -1
- package/dist/src/governor/decision-engine.d.ts +3 -0
- package/dist/src/governor/decision-engine.d.ts.map +1 -1
- package/dist/src/governor/decision-engine.js +11 -0
- package/dist/src/governor/decision-engine.test.js +33 -0
- package/dist/src/governor/event-types.d.ts +18 -1
- package/dist/src/governor/event-types.d.ts.map +1 -1
- package/dist/src/governor/event-types.js +4 -0
- package/dist/src/governor/governor-types.d.ts +1 -1
- package/dist/src/governor/governor-types.d.ts.map +1 -1
- package/dist/src/governor/governor.d.ts +17 -1
- package/dist/src/governor/governor.d.ts.map +1 -1
- package/dist/src/governor/governor.js +112 -1
- package/dist/src/governor/governor.test.js +155 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/merge-queue/adapters/github-native.d.ts +22 -0
- package/dist/src/merge-queue/adapters/github-native.d.ts.map +1 -0
- package/dist/src/merge-queue/adapters/github-native.js +243 -0
- package/dist/src/merge-queue/adapters/github-native.test.d.ts +2 -0
- package/dist/src/merge-queue/adapters/github-native.test.d.ts.map +1 -0
- package/dist/src/merge-queue/adapters/github-native.test.js +384 -0
- package/dist/src/merge-queue/index.d.ts +18 -0
- package/dist/src/merge-queue/index.d.ts.map +1 -0
- package/dist/src/merge-queue/index.js +28 -0
- package/dist/src/merge-queue/merge-queue.integration.test.d.ts +2 -0
- package/dist/src/merge-queue/merge-queue.integration.test.d.ts.map +1 -0
- package/dist/src/merge-queue/merge-queue.integration.test.js +128 -0
- package/dist/src/merge-queue/types.d.ts +48 -0
- package/dist/src/merge-queue/types.d.ts.map +1 -0
- package/dist/src/merge-queue/types.js +8 -0
- package/dist/src/orchestrator/artifact-tracker.d.ts +93 -0
- package/dist/src/orchestrator/artifact-tracker.d.ts.map +1 -0
- package/dist/src/orchestrator/artifact-tracker.js +235 -0
- package/dist/src/orchestrator/artifact-tracker.test.d.ts +2 -0
- package/dist/src/orchestrator/artifact-tracker.test.d.ts.map +1 -0
- package/dist/src/orchestrator/artifact-tracker.test.js +189 -0
- package/dist/src/orchestrator/context-manager.d.ts +72 -0
- package/dist/src/orchestrator/context-manager.d.ts.map +1 -0
- package/dist/src/orchestrator/context-manager.js +120 -0
- package/dist/src/orchestrator/context-manager.test.d.ts +2 -0
- package/dist/src/orchestrator/context-manager.test.d.ts.map +1 -0
- package/dist/src/orchestrator/context-manager.test.js +137 -0
- package/dist/src/orchestrator/index.d.ts +8 -2
- package/dist/src/orchestrator/index.d.ts.map +1 -1
- package/dist/src/orchestrator/index.js +8 -1
- package/dist/src/orchestrator/issue-tracker-client.d.ts +4 -0
- package/dist/src/orchestrator/issue-tracker-client.d.ts.map +1 -1
- package/dist/src/orchestrator/orchestrator.d.ts +12 -0
- package/dist/src/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/src/orchestrator/orchestrator.js +282 -2
- package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -1
- package/dist/src/orchestrator/parse-work-result.js +6 -0
- package/dist/src/orchestrator/parse-work-result.test.js +19 -0
- package/dist/src/orchestrator/state-recovery.d.ts +21 -2
- package/dist/src/orchestrator/state-recovery.d.ts.map +1 -1
- package/dist/src/orchestrator/state-recovery.js +54 -2
- package/dist/src/orchestrator/state-recovery.test.js +106 -2
- package/dist/src/orchestrator/state-types.d.ts +62 -0
- package/dist/src/orchestrator/state-types.d.ts.map +1 -1
- package/dist/src/orchestrator/state-types.js +5 -1
- package/dist/src/orchestrator/summary-builder.d.ts +47 -0
- package/dist/src/orchestrator/summary-builder.d.ts.map +1 -0
- package/dist/src/orchestrator/summary-builder.js +240 -0
- package/dist/src/orchestrator/summary-builder.test.d.ts +2 -0
- package/dist/src/orchestrator/summary-builder.test.d.ts.map +1 -0
- package/dist/src/orchestrator/summary-builder.test.js +236 -0
- package/dist/src/orchestrator/types.d.ts +2 -0
- package/dist/src/orchestrator/types.d.ts.map +1 -1
- package/dist/src/orchestrator/work-types.d.ts +1 -1
- package/dist/src/orchestrator/work-types.d.ts.map +1 -1
- package/dist/src/providers/index.d.ts +64 -1
- package/dist/src/providers/index.d.ts.map +1 -1
- package/dist/src/providers/index.js +132 -1
- package/dist/src/providers/index.test.js +340 -2
- package/dist/src/routing/index.d.ts +7 -0
- package/dist/src/routing/index.d.ts.map +1 -0
- package/dist/src/routing/index.js +6 -0
- package/dist/src/routing/observation-recorder.d.ts +19 -0
- package/dist/src/routing/observation-recorder.d.ts.map +1 -0
- package/dist/src/routing/observation-recorder.js +73 -0
- package/dist/src/routing/observation-recorder.test.d.ts +2 -0
- package/dist/src/routing/observation-recorder.test.d.ts.map +1 -0
- package/dist/src/routing/observation-recorder.test.js +322 -0
- package/dist/src/routing/observation-store.d.ts +40 -0
- package/dist/src/routing/observation-store.d.ts.map +1 -0
- package/dist/src/routing/observation-store.js +1 -0
- package/dist/src/routing/observation-store.test.d.ts +2 -0
- package/dist/src/routing/observation-store.test.d.ts.map +1 -0
- package/dist/src/routing/observation-store.test.js +138 -0
- package/dist/src/routing/posterior-store.d.ts +12 -0
- package/dist/src/routing/posterior-store.d.ts.map +1 -0
- package/dist/src/routing/posterior-store.js +13 -0
- package/dist/src/routing/posterior-store.test.d.ts +2 -0
- package/dist/src/routing/posterior-store.test.d.ts.map +1 -0
- package/dist/src/routing/posterior-store.test.js +37 -0
- package/dist/src/routing/reward.d.ts +16 -0
- package/dist/src/routing/reward.d.ts.map +1 -0
- package/dist/src/routing/reward.js +29 -0
- package/dist/src/routing/reward.test.d.ts +2 -0
- package/dist/src/routing/reward.test.d.ts.map +1 -0
- package/dist/src/routing/reward.test.js +210 -0
- package/dist/src/routing/routing-engine.d.ts +20 -0
- package/dist/src/routing/routing-engine.d.ts.map +1 -0
- package/dist/src/routing/routing-engine.js +113 -0
- package/dist/src/routing/routing-engine.test.d.ts +2 -0
- package/dist/src/routing/routing-engine.test.d.ts.map +1 -0
- package/dist/src/routing/routing-engine.test.js +310 -0
- package/dist/src/routing/types.d.ts +157 -0
- package/dist/src/routing/types.d.ts.map +1 -0
- package/dist/src/routing/types.js +68 -0
- package/dist/src/routing/types.test.d.ts +2 -0
- package/dist/src/routing/types.test.d.ts.map +1 -0
- package/dist/src/routing/types.test.js +184 -0
- package/dist/src/templates/registry.test.js +2 -2
- package/dist/src/templates/types.d.ts +5 -0
- package/dist/src/templates/types.d.ts.map +1 -1
- package/dist/src/templates/types.js +3 -0
- package/dist/src/workflow/agent-cancellation.d.ts +37 -0
- package/dist/src/workflow/agent-cancellation.d.ts.map +1 -0
- package/dist/src/workflow/agent-cancellation.js +41 -0
- package/dist/src/workflow/agent-cancellation.test.d.ts +2 -0
- package/dist/src/workflow/agent-cancellation.test.d.ts.map +1 -0
- package/dist/src/workflow/agent-cancellation.test.js +86 -0
- package/dist/src/workflow/branching-router.d.ts +38 -0
- package/dist/src/workflow/branching-router.d.ts.map +1 -0
- package/dist/src/workflow/branching-router.js +52 -0
- package/dist/src/workflow/branching-router.test.d.ts +2 -0
- package/dist/src/workflow/branching-router.test.d.ts.map +1 -0
- package/dist/src/workflow/branching-router.test.js +209 -0
- package/dist/src/workflow/concurrency-semaphore.d.ts +21 -0
- package/dist/src/workflow/concurrency-semaphore.d.ts.map +1 -0
- package/dist/src/workflow/concurrency-semaphore.js +46 -0
- package/dist/src/workflow/concurrency-semaphore.test.d.ts +2 -0
- package/dist/src/workflow/concurrency-semaphore.test.d.ts.map +1 -0
- package/dist/src/workflow/concurrency-semaphore.test.js +183 -0
- package/dist/src/workflow/duration.d.ts +28 -0
- package/dist/src/workflow/duration.d.ts.map +1 -0
- package/dist/src/workflow/duration.js +57 -0
- package/dist/src/workflow/duration.test.d.ts +2 -0
- package/dist/src/workflow/duration.test.d.ts.map +1 -0
- package/dist/src/workflow/duration.test.js +74 -0
- package/dist/src/workflow/expression/ast.d.ts +53 -0
- package/dist/src/workflow/expression/ast.d.ts.map +1 -0
- package/dist/src/workflow/expression/ast.js +8 -0
- package/dist/src/workflow/expression/context.d.ts +40 -0
- package/dist/src/workflow/expression/context.d.ts.map +1 -0
- package/dist/src/workflow/expression/context.js +37 -0
- package/dist/src/workflow/expression/evaluator.d.ts +28 -0
- package/dist/src/workflow/expression/evaluator.d.ts.map +1 -0
- package/dist/src/workflow/expression/evaluator.js +165 -0
- package/dist/src/workflow/expression/evaluator.test.d.ts +2 -0
- package/dist/src/workflow/expression/evaluator.test.d.ts.map +1 -0
- package/dist/src/workflow/expression/evaluator.test.js +792 -0
- package/dist/src/workflow/expression/expression.test.d.ts +2 -0
- package/dist/src/workflow/expression/expression.test.d.ts.map +1 -0
- package/dist/src/workflow/expression/expression.test.js +516 -0
- package/dist/src/workflow/expression/helpers.d.ts +21 -0
- package/dist/src/workflow/expression/helpers.d.ts.map +1 -0
- package/dist/src/workflow/expression/helpers.js +56 -0
- package/dist/src/workflow/expression/index.d.ts +55 -0
- package/dist/src/workflow/expression/index.d.ts.map +1 -0
- package/dist/src/workflow/expression/index.js +71 -0
- package/dist/src/workflow/expression/lexer.d.ts +37 -0
- package/dist/src/workflow/expression/lexer.d.ts.map +1 -0
- package/dist/src/workflow/expression/lexer.js +166 -0
- package/dist/src/workflow/expression/parser.d.ts +23 -0
- package/dist/src/workflow/expression/parser.d.ts.map +1 -0
- package/dist/src/workflow/expression/parser.js +181 -0
- package/dist/src/workflow/gate-state.d.ts +115 -0
- package/dist/src/workflow/gate-state.d.ts.map +1 -0
- package/dist/src/workflow/gate-state.js +185 -0
- package/dist/src/workflow/gate-state.test.d.ts +2 -0
- package/dist/src/workflow/gate-state.test.d.ts.map +1 -0
- package/dist/src/workflow/gate-state.test.js +251 -0
- package/dist/src/workflow/gates/gate-evaluator.d.ts +119 -0
- package/dist/src/workflow/gates/gate-evaluator.d.ts.map +1 -0
- package/dist/src/workflow/gates/gate-evaluator.js +243 -0
- package/dist/src/workflow/gates/gate-evaluator.test.d.ts +2 -0
- package/dist/src/workflow/gates/gate-evaluator.test.d.ts.map +1 -0
- package/dist/src/workflow/gates/gate-evaluator.test.js +240 -0
- package/dist/src/workflow/gates/signal-gate.d.ts +114 -0
- package/dist/src/workflow/gates/signal-gate.d.ts.map +1 -0
- package/dist/src/workflow/gates/signal-gate.js +216 -0
- package/dist/src/workflow/gates/signal-gate.test.d.ts +2 -0
- package/dist/src/workflow/gates/signal-gate.test.d.ts.map +1 -0
- package/dist/src/workflow/gates/signal-gate.test.js +199 -0
- package/dist/src/workflow/gates/timeout-engine.d.ts +96 -0
- package/dist/src/workflow/gates/timeout-engine.d.ts.map +1 -0
- package/dist/src/workflow/gates/timeout-engine.js +162 -0
- package/dist/src/workflow/gates/timeout-engine.test.d.ts +2 -0
- package/dist/src/workflow/gates/timeout-engine.test.d.ts.map +1 -0
- package/dist/src/workflow/gates/timeout-engine.test.js +186 -0
- package/dist/src/workflow/gates/timer-gate.d.ts +125 -0
- package/dist/src/workflow/gates/timer-gate.d.ts.map +1 -0
- package/dist/src/workflow/gates/timer-gate.js +381 -0
- package/dist/src/workflow/gates/timer-gate.test.d.ts +2 -0
- package/dist/src/workflow/gates/timer-gate.test.d.ts.map +1 -0
- package/dist/src/workflow/gates/timer-gate.test.js +211 -0
- package/dist/src/workflow/gates/webhook-gate.d.ts +132 -0
- package/dist/src/workflow/gates/webhook-gate.d.ts.map +1 -0
- package/dist/src/workflow/gates/webhook-gate.js +216 -0
- package/dist/src/workflow/gates/webhook-gate.test.d.ts +2 -0
- package/dist/src/workflow/gates/webhook-gate.test.d.ts.map +1 -0
- package/dist/src/workflow/gates/webhook-gate.test.js +182 -0
- package/dist/src/workflow/index.d.ts +31 -3
- package/dist/src/workflow/index.d.ts.map +1 -1
- package/dist/src/workflow/index.js +20 -1
- package/dist/src/workflow/parallelism-executor.d.ts +25 -0
- package/dist/src/workflow/parallelism-executor.d.ts.map +1 -0
- package/dist/src/workflow/parallelism-executor.js +53 -0
- package/dist/src/workflow/parallelism-executor.test.d.ts +2 -0
- package/dist/src/workflow/parallelism-executor.test.d.ts.map +1 -0
- package/dist/src/workflow/parallelism-executor.test.js +191 -0
- package/dist/src/workflow/parallelism-types.d.ts +80 -0
- package/dist/src/workflow/parallelism-types.d.ts.map +1 -0
- package/dist/src/workflow/parallelism-types.js +8 -0
- package/dist/src/workflow/phase-context-injector.d.ts +29 -0
- package/dist/src/workflow/phase-context-injector.d.ts.map +1 -0
- package/dist/src/workflow/phase-context-injector.js +43 -0
- package/dist/src/workflow/phase-context-injector.test.d.ts +2 -0
- package/dist/src/workflow/phase-context-injector.test.d.ts.map +1 -0
- package/dist/src/workflow/phase-context-injector.test.js +123 -0
- package/dist/src/workflow/phase-output-collector.d.ts +39 -0
- package/dist/src/workflow/phase-output-collector.d.ts.map +1 -0
- package/dist/src/workflow/phase-output-collector.js +141 -0
- package/dist/src/workflow/phase-output-collector.test.d.ts +2 -0
- package/dist/src/workflow/phase-output-collector.test.d.ts.map +1 -0
- package/dist/src/workflow/phase-output-collector.test.js +179 -0
- package/dist/src/workflow/retry-resolver.d.ts +51 -0
- package/dist/src/workflow/retry-resolver.d.ts.map +1 -0
- package/dist/src/workflow/retry-resolver.js +70 -0
- package/dist/src/workflow/retry-resolver.test.d.ts +2 -0
- package/dist/src/workflow/retry-resolver.test.d.ts.map +1 -0
- package/dist/src/workflow/retry-resolver.test.js +149 -0
- package/dist/src/workflow/strategies/fan-in-strategy.d.ts +21 -0
- package/dist/src/workflow/strategies/fan-in-strategy.d.ts.map +1 -0
- package/dist/src/workflow/strategies/fan-in-strategy.js +92 -0
- package/dist/src/workflow/strategies/fan-in-strategy.test.d.ts +2 -0
- package/dist/src/workflow/strategies/fan-in-strategy.test.d.ts.map +1 -0
- package/dist/src/workflow/strategies/fan-in-strategy.test.js +182 -0
- package/dist/src/workflow/strategies/fan-out-strategy.d.ts +16 -0
- package/dist/src/workflow/strategies/fan-out-strategy.d.ts.map +1 -0
- package/dist/src/workflow/strategies/fan-out-strategy.js +47 -0
- package/dist/src/workflow/strategies/fan-out-strategy.test.d.ts +2 -0
- package/dist/src/workflow/strategies/fan-out-strategy.test.d.ts.map +1 -0
- package/dist/src/workflow/strategies/fan-out-strategy.test.js +97 -0
- package/dist/src/workflow/strategies/index.d.ts +4 -0
- package/dist/src/workflow/strategies/index.d.ts.map +1 -0
- package/dist/src/workflow/strategies/index.js +3 -0
- package/dist/src/workflow/strategies/race-strategy.d.ts +19 -0
- package/dist/src/workflow/strategies/race-strategy.d.ts.map +1 -0
- package/dist/src/workflow/strategies/race-strategy.js +92 -0
- package/dist/src/workflow/strategies/race-strategy.test.d.ts +2 -0
- package/dist/src/workflow/strategies/race-strategy.test.d.ts.map +1 -0
- package/dist/src/workflow/strategies/race-strategy.test.js +318 -0
- package/dist/src/workflow/transition-engine.d.ts +3 -1
- package/dist/src/workflow/transition-engine.d.ts.map +1 -1
- package/dist/src/workflow/transition-engine.js +26 -7
- package/dist/src/workflow/transition-engine.test.js +215 -11
- package/dist/src/workflow/workflow-registry.d.ts +46 -1
- package/dist/src/workflow/workflow-registry.d.ts.map +1 -1
- package/dist/src/workflow/workflow-registry.js +74 -0
- package/dist/src/workflow/workflow-registry.test.js +54 -0
- package/dist/src/workflow/workflow-types.d.ts +330 -12
- package/dist/src/workflow/workflow-types.d.ts.map +1 -1
- package/dist/src/workflow/workflow-types.js +100 -5
- package/dist/src/workflow/workflow-types.test.js +293 -2
- package/package.json +2 -2
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timer Gate Executor
|
|
3
|
+
*
|
|
4
|
+
* Pure-function executor for cron-based timer gates. Evaluates whether a
|
|
5
|
+
* timer gate's cron schedule has fired, and computes the next fire time.
|
|
6
|
+
*
|
|
7
|
+
* Implements a from-scratch 5-field cron parser supporting:
|
|
8
|
+
* - Exact values: 5, 10
|
|
9
|
+
* - Wildcards: *
|
|
10
|
+
* - Ranges: 1-5
|
|
11
|
+
* - Step values: *\/15, 1-30/5
|
|
12
|
+
* - Lists: 1,3,5
|
|
13
|
+
*
|
|
14
|
+
* No external dependencies are used.
|
|
15
|
+
*/
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Cron Field Parser
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/**
|
|
20
|
+
* Parse a single cron field into a set of valid integer values.
|
|
21
|
+
*
|
|
22
|
+
* Supported syntax:
|
|
23
|
+
* - `*` — all values in [min, max]
|
|
24
|
+
* - `5` — exact value
|
|
25
|
+
* - `1-5` — inclusive range
|
|
26
|
+
* - `*\/15` — step from min
|
|
27
|
+
* - `1-30/5` — step within a range
|
|
28
|
+
* - `1,3,5` — list of values (each element can be a range or step)
|
|
29
|
+
*
|
|
30
|
+
* @param field - The raw cron field string
|
|
31
|
+
* @param min - Minimum valid value for this field (inclusive)
|
|
32
|
+
* @param max - Maximum valid value for this field (inclusive)
|
|
33
|
+
* @returns A sorted array of unique integers that the field expands to
|
|
34
|
+
* @throws Error if the field contains invalid syntax
|
|
35
|
+
*/
|
|
36
|
+
export function parseCronField(field, min, max) {
|
|
37
|
+
const result = new Set();
|
|
38
|
+
const parts = field.split(',');
|
|
39
|
+
for (const part of parts) {
|
|
40
|
+
const trimmed = part.trim();
|
|
41
|
+
if (trimmed === '') {
|
|
42
|
+
throw new Error(`Invalid cron field: empty segment in "${field}"`);
|
|
43
|
+
}
|
|
44
|
+
// Check for step value (e.g., */15 or 1-30/5)
|
|
45
|
+
const stepParts = trimmed.split('/');
|
|
46
|
+
if (stepParts.length > 2) {
|
|
47
|
+
throw new Error(`Invalid cron field: multiple '/' in "${trimmed}"`);
|
|
48
|
+
}
|
|
49
|
+
let rangeStart;
|
|
50
|
+
let rangeEnd;
|
|
51
|
+
let step = 1;
|
|
52
|
+
if (stepParts.length === 2) {
|
|
53
|
+
step = parseInt(stepParts[1], 10);
|
|
54
|
+
if (isNaN(step) || step <= 0) {
|
|
55
|
+
throw new Error(`Invalid cron step value: "${stepParts[1]}" in "${trimmed}"`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const base = stepParts[0];
|
|
59
|
+
if (base === '*') {
|
|
60
|
+
rangeStart = min;
|
|
61
|
+
rangeEnd = max;
|
|
62
|
+
}
|
|
63
|
+
else if (base.includes('-')) {
|
|
64
|
+
const rangeParts = base.split('-');
|
|
65
|
+
if (rangeParts.length !== 2) {
|
|
66
|
+
throw new Error(`Invalid cron range: "${base}" in "${trimmed}"`);
|
|
67
|
+
}
|
|
68
|
+
rangeStart = parseInt(rangeParts[0], 10);
|
|
69
|
+
rangeEnd = parseInt(rangeParts[1], 10);
|
|
70
|
+
if (isNaN(rangeStart) || isNaN(rangeEnd)) {
|
|
71
|
+
throw new Error(`Invalid cron range values: "${base}" in "${trimmed}"`);
|
|
72
|
+
}
|
|
73
|
+
if (rangeStart < min || rangeEnd > max || rangeStart > rangeEnd) {
|
|
74
|
+
throw new Error(`Cron range out of bounds: ${rangeStart}-${rangeEnd} (valid: ${min}-${max})`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const value = parseInt(base, 10);
|
|
79
|
+
if (isNaN(value)) {
|
|
80
|
+
throw new Error(`Invalid cron value: "${base}" in "${trimmed}"`);
|
|
81
|
+
}
|
|
82
|
+
if (value < min || value > max) {
|
|
83
|
+
throw new Error(`Cron value out of bounds: ${value} (valid: ${min}-${max})`);
|
|
84
|
+
}
|
|
85
|
+
rangeStart = value;
|
|
86
|
+
rangeEnd = value;
|
|
87
|
+
}
|
|
88
|
+
for (let i = rangeStart; i <= rangeEnd; i += step) {
|
|
89
|
+
result.add(i);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return Array.from(result).sort((a, b) => a - b);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Parse a 5-field cron expression string into structured arrays of valid values.
|
|
96
|
+
*
|
|
97
|
+
* @param cronExpression - Standard 5-field cron string (e.g., "0 9 * * 1-5")
|
|
98
|
+
* @returns Parsed cron with expanded field values
|
|
99
|
+
* @throws Error if the expression doesn't have exactly 5 fields
|
|
100
|
+
*/
|
|
101
|
+
export function parseCronExpression(cronExpression) {
|
|
102
|
+
const fields = cronExpression.trim().split(/\s+/);
|
|
103
|
+
if (fields.length !== 5) {
|
|
104
|
+
throw new Error(`Invalid cron expression: expected 5 fields, got ${fields.length} in "${cronExpression}"`);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
minutes: parseCronField(fields[0], 0, 59),
|
|
108
|
+
hours: parseCronField(fields[1], 0, 23),
|
|
109
|
+
daysOfMonth: parseCronField(fields[2], 1, 31),
|
|
110
|
+
months: parseCronField(fields[3], 1, 12),
|
|
111
|
+
// Day of week: 0-7 where both 0 and 7 mean Sunday
|
|
112
|
+
daysOfWeek: normalizeDaysOfWeek(parseCronField(fields[4], 0, 7)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Normalize day-of-week values so that 7 (Sunday) maps to 0.
|
|
117
|
+
* Returns a deduplicated sorted array.
|
|
118
|
+
*/
|
|
119
|
+
function normalizeDaysOfWeek(days) {
|
|
120
|
+
const normalized = new Set();
|
|
121
|
+
for (const d of days) {
|
|
122
|
+
normalized.add(d === 7 ? 0 : d);
|
|
123
|
+
}
|
|
124
|
+
return Array.from(normalized).sort((a, b) => a - b);
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Next Cron Fire Time Computation
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
/**
|
|
130
|
+
* Compute the next cron fire time strictly after a given timestamp.
|
|
131
|
+
*
|
|
132
|
+
* Algorithm:
|
|
133
|
+
* 1. Start from the minute after the `after` timestamp
|
|
134
|
+
* 2. Check month, day-of-month, day-of-week, hour, minute in sequence
|
|
135
|
+
* 3. If a field doesn't match, advance to the next valid value and reset
|
|
136
|
+
* all lower-order fields
|
|
137
|
+
* 4. Guard against infinite loops with a maximum iteration count
|
|
138
|
+
*
|
|
139
|
+
* @param cronExpression - Standard 5-field cron expression
|
|
140
|
+
* @param after - Epoch milliseconds; the next fire time is strictly after this
|
|
141
|
+
* @returns Epoch milliseconds of the next matching cron time
|
|
142
|
+
* @throws Error if no valid fire time is found within the search window
|
|
143
|
+
*/
|
|
144
|
+
export function computeNextCronFireTime(cronExpression, after) {
|
|
145
|
+
const cron = parseCronExpression(cronExpression);
|
|
146
|
+
// Start from one minute after the given timestamp, zeroing out seconds/ms
|
|
147
|
+
const start = new Date(after);
|
|
148
|
+
start.setSeconds(0, 0);
|
|
149
|
+
start.setMinutes(start.getMinutes() + 1);
|
|
150
|
+
let year = start.getFullYear();
|
|
151
|
+
let month = start.getMonth() + 1; // 1-based
|
|
152
|
+
let day = start.getDate();
|
|
153
|
+
let hour = start.getHours();
|
|
154
|
+
let minute = start.getMinutes();
|
|
155
|
+
// Safety limit to prevent infinite loops (4 years of minutes should be plenty)
|
|
156
|
+
const MAX_ITERATIONS = 4 * 366 * 24 * 60;
|
|
157
|
+
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
158
|
+
// --- Month ---
|
|
159
|
+
if (!cron.months.includes(month)) {
|
|
160
|
+
const nextMonth = findNextValue(cron.months, month);
|
|
161
|
+
if (nextMonth === null || nextMonth < month) {
|
|
162
|
+
// Wrap to next year
|
|
163
|
+
year++;
|
|
164
|
+
month = cron.months[0];
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
month = nextMonth;
|
|
168
|
+
}
|
|
169
|
+
day = 1;
|
|
170
|
+
hour = 0;
|
|
171
|
+
minute = 0;
|
|
172
|
+
}
|
|
173
|
+
// --- Day of month ---
|
|
174
|
+
const maxDay = daysInMonth(year, month);
|
|
175
|
+
// Filter valid days for the actual month length
|
|
176
|
+
const validDays = cron.daysOfMonth.filter(d => d <= maxDay);
|
|
177
|
+
if (validDays.length === 0) {
|
|
178
|
+
// No valid day in this month; advance to next month
|
|
179
|
+
month++;
|
|
180
|
+
if (month > 12) {
|
|
181
|
+
month = 1;
|
|
182
|
+
year++;
|
|
183
|
+
}
|
|
184
|
+
day = 1;
|
|
185
|
+
hour = 0;
|
|
186
|
+
minute = 0;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!validDays.includes(day)) {
|
|
190
|
+
const nextDay = findNextValue(validDays, day);
|
|
191
|
+
if (nextDay === null || nextDay < day) {
|
|
192
|
+
// Wrap to next month
|
|
193
|
+
month++;
|
|
194
|
+
if (month > 12) {
|
|
195
|
+
month = 1;
|
|
196
|
+
year++;
|
|
197
|
+
}
|
|
198
|
+
day = 1;
|
|
199
|
+
hour = 0;
|
|
200
|
+
minute = 0;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
day = nextDay;
|
|
204
|
+
hour = 0;
|
|
205
|
+
minute = 0;
|
|
206
|
+
}
|
|
207
|
+
// --- Day of week ---
|
|
208
|
+
const candidateDate = new Date(year, month - 1, day);
|
|
209
|
+
const dow = candidateDate.getDay(); // 0=Sunday
|
|
210
|
+
if (!cron.daysOfWeek.includes(dow)) {
|
|
211
|
+
// Advance to the next day
|
|
212
|
+
day++;
|
|
213
|
+
if (day > maxDay) {
|
|
214
|
+
month++;
|
|
215
|
+
if (month > 12) {
|
|
216
|
+
month = 1;
|
|
217
|
+
year++;
|
|
218
|
+
}
|
|
219
|
+
day = 1;
|
|
220
|
+
}
|
|
221
|
+
hour = 0;
|
|
222
|
+
minute = 0;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// --- Hour ---
|
|
226
|
+
if (!cron.hours.includes(hour)) {
|
|
227
|
+
const nextHour = findNextValue(cron.hours, hour);
|
|
228
|
+
if (nextHour === null || nextHour < hour) {
|
|
229
|
+
// Wrap to next day
|
|
230
|
+
day++;
|
|
231
|
+
if (day > maxDay) {
|
|
232
|
+
month++;
|
|
233
|
+
if (month > 12) {
|
|
234
|
+
month = 1;
|
|
235
|
+
year++;
|
|
236
|
+
}
|
|
237
|
+
day = 1;
|
|
238
|
+
}
|
|
239
|
+
hour = 0;
|
|
240
|
+
minute = 0;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
hour = nextHour;
|
|
244
|
+
minute = 0;
|
|
245
|
+
}
|
|
246
|
+
// --- Minute ---
|
|
247
|
+
if (!cron.minutes.includes(minute)) {
|
|
248
|
+
const nextMinute = findNextValue(cron.minutes, minute);
|
|
249
|
+
if (nextMinute === null || nextMinute < minute) {
|
|
250
|
+
// Wrap to next hour
|
|
251
|
+
hour++;
|
|
252
|
+
if (hour > 23) {
|
|
253
|
+
day++;
|
|
254
|
+
if (day > maxDay) {
|
|
255
|
+
month++;
|
|
256
|
+
if (month > 12) {
|
|
257
|
+
month = 1;
|
|
258
|
+
year++;
|
|
259
|
+
}
|
|
260
|
+
day = 1;
|
|
261
|
+
}
|
|
262
|
+
hour = 0;
|
|
263
|
+
}
|
|
264
|
+
minute = 0;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
minute = nextMinute;
|
|
268
|
+
}
|
|
269
|
+
// All fields match! Build the result date.
|
|
270
|
+
const result = new Date(year, month - 1, day, hour, minute, 0, 0);
|
|
271
|
+
return result.getTime();
|
|
272
|
+
}
|
|
273
|
+
throw new Error(`Could not find next cron fire time for "${cronExpression}" after ${new Date(after).toISOString()} within search window`);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Find the next value >= target in a sorted array of integers.
|
|
277
|
+
* Returns null if no such value exists.
|
|
278
|
+
*/
|
|
279
|
+
function findNextValue(sortedValues, target) {
|
|
280
|
+
for (const v of sortedValues) {
|
|
281
|
+
if (v >= target)
|
|
282
|
+
return v;
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Get the number of days in a given month (1-based) for a given year.
|
|
288
|
+
* Accounts for leap years.
|
|
289
|
+
*/
|
|
290
|
+
function daysInMonth(year, month) {
|
|
291
|
+
// Day 0 of the next month gives the last day of the current month
|
|
292
|
+
return new Date(year, month, 0).getDate();
|
|
293
|
+
}
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Type Guard
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
/**
|
|
298
|
+
* Type guard that validates whether a trigger object has the shape
|
|
299
|
+
* expected for a timer gate (i.e., contains a `cron` string property).
|
|
300
|
+
*
|
|
301
|
+
* @param trigger - The trigger record to validate
|
|
302
|
+
* @returns True if the trigger is a valid TimerGateTrigger
|
|
303
|
+
*/
|
|
304
|
+
export function isTimerGateTrigger(trigger) {
|
|
305
|
+
return (typeof trigger === 'object' &&
|
|
306
|
+
trigger !== null &&
|
|
307
|
+
'cron' in trigger &&
|
|
308
|
+
typeof trigger.cron === 'string' &&
|
|
309
|
+
trigger.cron.trim().length > 0);
|
|
310
|
+
}
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Gate Evaluation
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
/**
|
|
315
|
+
* Evaluate whether a cron-based timer gate should fire.
|
|
316
|
+
*
|
|
317
|
+
* This is a pure function (no I/O). It parses the gate's `trigger.cron`
|
|
318
|
+
* expression, computes the next fire time from the gate's activation time
|
|
319
|
+
* (or from epoch 0 if no activation context), and checks whether the
|
|
320
|
+
* current time has reached or passed that fire time.
|
|
321
|
+
*
|
|
322
|
+
* @param gate - The gate definition with type "timer" and a trigger containing a cron field
|
|
323
|
+
* @param now - Current time in epoch milliseconds (defaults to Date.now() for testability)
|
|
324
|
+
* @returns TimerGateResult with fired status and next fire time
|
|
325
|
+
* @throws Error if the gate is not a timer gate or has an invalid trigger
|
|
326
|
+
*/
|
|
327
|
+
export function evaluateTimerGate(gate, now) {
|
|
328
|
+
const currentTime = now ?? Date.now();
|
|
329
|
+
if (gate.type !== 'timer') {
|
|
330
|
+
throw new Error(`evaluateTimerGate called with non-timer gate: type="${gate.type}"`);
|
|
331
|
+
}
|
|
332
|
+
if (!isTimerGateTrigger(gate.trigger)) {
|
|
333
|
+
throw new Error(`Timer gate "${gate.name}" has invalid trigger: missing or empty "cron" field`);
|
|
334
|
+
}
|
|
335
|
+
const cronExpression = gate.trigger.cron;
|
|
336
|
+
// Compute the next fire time relative to one "cycle" before now,
|
|
337
|
+
// so we can detect whether we're currently in a fire window.
|
|
338
|
+
// We look for the next fire time after (now - 60 seconds) to catch
|
|
339
|
+
// the current minute's match, and compare against the current time.
|
|
340
|
+
const lookbackTime = currentTime - 60_000;
|
|
341
|
+
const nextFireTime = computeNextCronFireTime(cronExpression, lookbackTime);
|
|
342
|
+
// The gate has fired if the next fire time (computed from the lookback)
|
|
343
|
+
// falls at or before the current time
|
|
344
|
+
const fired = nextFireTime <= currentTime;
|
|
345
|
+
// Compute the actual next fire time from the current moment for the result
|
|
346
|
+
const upcomingFireTime = fired
|
|
347
|
+
? computeNextCronFireTime(cronExpression, currentTime)
|
|
348
|
+
: nextFireTime;
|
|
349
|
+
return {
|
|
350
|
+
fired,
|
|
351
|
+
nextFireTime: upcomingFireTime,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Gate Filtering
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
/**
|
|
358
|
+
* Get all timer gates from a workflow definition that apply to a given phase.
|
|
359
|
+
*
|
|
360
|
+
* A gate applies to a phase if:
|
|
361
|
+
* - The gate's `appliesTo` array includes the phase name, OR
|
|
362
|
+
* - The gate has no `appliesTo` array (applies to all phases)
|
|
363
|
+
*
|
|
364
|
+
* Only gates with `type: "timer"` are returned.
|
|
365
|
+
*
|
|
366
|
+
* @param workflow - The workflow definition to search
|
|
367
|
+
* @param phase - The phase name to filter by
|
|
368
|
+
* @returns Array of GateDefinition objects for matching timer gates
|
|
369
|
+
*/
|
|
370
|
+
export function getApplicableTimerGates(workflow, phase) {
|
|
371
|
+
if (!workflow.gates) {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
return workflow.gates.filter(gate => {
|
|
375
|
+
if (gate.type !== 'timer')
|
|
376
|
+
return false;
|
|
377
|
+
if (!gate.appliesTo || gate.appliesTo.length === 0)
|
|
378
|
+
return true;
|
|
379
|
+
return gate.appliesTo.includes(phase);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timer-gate.test.d.ts","sourceRoot":"","sources":["../../../../src/workflow/gates/timer-gate.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { parseCronField, parseCronExpression, computeNextCronFireTime, evaluateTimerGate, getApplicableTimerGates, } from './timer-gate.js';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
function makeGateDefinition(overrides = {}) {
|
|
7
|
+
return {
|
|
8
|
+
name: 'test-gate',
|
|
9
|
+
type: 'timer',
|
|
10
|
+
trigger: { cron: '0 9 * * *' },
|
|
11
|
+
...overrides,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function makeWorkflow(gates = []) {
|
|
15
|
+
return {
|
|
16
|
+
apiVersion: 'v1.1',
|
|
17
|
+
kind: 'WorkflowDefinition',
|
|
18
|
+
metadata: { name: 'test-workflow' },
|
|
19
|
+
phases: [{ name: 'development', template: 'dev' }],
|
|
20
|
+
transitions: [{ from: 'Backlog', to: 'development' }],
|
|
21
|
+
gates,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// parseCronField
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
describe('parseCronField', () => {
|
|
28
|
+
it('parses wildcard (*)', () => {
|
|
29
|
+
const result = parseCronField('*', 0, 5);
|
|
30
|
+
expect(result).toEqual([0, 1, 2, 3, 4, 5]);
|
|
31
|
+
});
|
|
32
|
+
it('parses exact value', () => {
|
|
33
|
+
const result = parseCronField('5', 0, 59);
|
|
34
|
+
expect(result).toEqual([5]);
|
|
35
|
+
});
|
|
36
|
+
it('parses range (1-5)', () => {
|
|
37
|
+
const result = parseCronField('1-5', 0, 59);
|
|
38
|
+
expect(result).toEqual([1, 2, 3, 4, 5]);
|
|
39
|
+
});
|
|
40
|
+
it('parses step value (*/15)', () => {
|
|
41
|
+
const result = parseCronField('*/15', 0, 59);
|
|
42
|
+
expect(result).toEqual([0, 15, 30, 45]);
|
|
43
|
+
});
|
|
44
|
+
it('parses step within range (1-30/5)', () => {
|
|
45
|
+
const result = parseCronField('1-30/5', 0, 59);
|
|
46
|
+
expect(result).toEqual([1, 6, 11, 16, 21, 26]);
|
|
47
|
+
});
|
|
48
|
+
it('parses list (1,3,5)', () => {
|
|
49
|
+
const result = parseCronField('1,3,5', 0, 59);
|
|
50
|
+
expect(result).toEqual([1, 3, 5]);
|
|
51
|
+
});
|
|
52
|
+
it('throws for out-of-bounds value', () => {
|
|
53
|
+
expect(() => parseCronField('60', 0, 59)).toThrow('out of bounds');
|
|
54
|
+
});
|
|
55
|
+
it('throws for out-of-bounds range', () => {
|
|
56
|
+
expect(() => parseCronField('0-60', 0, 59)).toThrow('out of bounds');
|
|
57
|
+
});
|
|
58
|
+
it('throws for invalid value', () => {
|
|
59
|
+
expect(() => parseCronField('abc', 0, 59)).toThrow('Invalid cron value');
|
|
60
|
+
});
|
|
61
|
+
it('throws for empty segment in list', () => {
|
|
62
|
+
expect(() => parseCronField('1,,3', 0, 59)).toThrow('empty segment');
|
|
63
|
+
});
|
|
64
|
+
it('throws for multiple slashes', () => {
|
|
65
|
+
expect(() => parseCronField('*/5/2', 0, 59)).toThrow("multiple '/'");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// parseCronExpression
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
describe('parseCronExpression', () => {
|
|
72
|
+
it('parses valid 5-field cron expression', () => {
|
|
73
|
+
const result = parseCronExpression('0 9 * * *');
|
|
74
|
+
expect(result.minutes).toEqual([0]);
|
|
75
|
+
expect(result.hours).toEqual([9]);
|
|
76
|
+
expect(result.daysOfMonth).toHaveLength(31);
|
|
77
|
+
expect(result.months).toHaveLength(12);
|
|
78
|
+
expect(result.daysOfWeek).toHaveLength(7);
|
|
79
|
+
});
|
|
80
|
+
it('throws for invalid field count (3 fields)', () => {
|
|
81
|
+
expect(() => parseCronExpression('0 9 *')).toThrow('expected 5 fields');
|
|
82
|
+
});
|
|
83
|
+
it('throws for invalid field count (6 fields)', () => {
|
|
84
|
+
expect(() => parseCronExpression('0 9 * * * *')).toThrow('expected 5 fields');
|
|
85
|
+
});
|
|
86
|
+
it('normalizes day-of-week 7 to 0 (Sunday)', () => {
|
|
87
|
+
const result = parseCronExpression('0 0 * * 7');
|
|
88
|
+
expect(result.daysOfWeek).toContain(0);
|
|
89
|
+
expect(result.daysOfWeek).not.toContain(7);
|
|
90
|
+
});
|
|
91
|
+
it('deduplicates when both 0 and 7 are present', () => {
|
|
92
|
+
const result = parseCronExpression('0 0 * * 0,7');
|
|
93
|
+
expect(result.daysOfWeek).toEqual([0]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// computeNextCronFireTime
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
describe('computeNextCronFireTime', () => {
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
vi.useFakeTimers();
|
|
102
|
+
});
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
vi.useRealTimers();
|
|
105
|
+
});
|
|
106
|
+
it('computes next fire time for "0 9 * * *" (daily at 9am)', () => {
|
|
107
|
+
// Set time to June 1, 2025, 08:00 UTC
|
|
108
|
+
const baseTime = new Date('2025-06-01T08:00:00Z').getTime();
|
|
109
|
+
const nextFire = computeNextCronFireTime('0 9 * * *', baseTime);
|
|
110
|
+
const fireDate = new Date(nextFire);
|
|
111
|
+
// Should fire at 9:00 on the same day (local time)
|
|
112
|
+
expect(fireDate.getHours()).toBe(9);
|
|
113
|
+
expect(fireDate.getMinutes()).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
it('computes next fire time for weekday-only "0 9 * * 1-5"', () => {
|
|
116
|
+
// June 1, 2025 is a Sunday (day 0)
|
|
117
|
+
// Set to Sunday 10:00 — next weekday 9:00 should be Monday
|
|
118
|
+
const sunday = new Date(2025, 5, 1, 10, 0, 0, 0); // June 1, 2025, Sunday
|
|
119
|
+
vi.setSystemTime(sunday);
|
|
120
|
+
const nextFire = computeNextCronFireTime('0 9 * * 1-5', sunday.getTime());
|
|
121
|
+
const fireDate = new Date(nextFire);
|
|
122
|
+
// Next valid fire should be Monday (day 1) at 9:00
|
|
123
|
+
expect(fireDate.getDay()).toBeGreaterThanOrEqual(1);
|
|
124
|
+
expect(fireDate.getDay()).toBeLessThanOrEqual(5);
|
|
125
|
+
expect(fireDate.getHours()).toBe(9);
|
|
126
|
+
expect(fireDate.getMinutes()).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
it('computes next fire time for "*/15 * * * *" (every 15 minutes)', () => {
|
|
129
|
+
const baseTime = new Date(2025, 5, 1, 10, 3, 0, 0).getTime(); // 10:03
|
|
130
|
+
const nextFire = computeNextCronFireTime('*/15 * * * *', baseTime);
|
|
131
|
+
const fireDate = new Date(nextFire);
|
|
132
|
+
// Next 15-minute mark after 10:03 should be 10:15
|
|
133
|
+
expect(fireDate.getMinutes()).toBe(15);
|
|
134
|
+
expect(fireDate.getHours()).toBe(10);
|
|
135
|
+
});
|
|
136
|
+
it('returns a time strictly after the given timestamp', () => {
|
|
137
|
+
const baseTime = new Date(2025, 5, 1, 9, 0, 0, 0).getTime(); // exactly 9:00
|
|
138
|
+
const nextFire = computeNextCronFireTime('0 9 * * *', baseTime);
|
|
139
|
+
expect(nextFire).toBeGreaterThan(baseTime);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// evaluateTimerGate
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
describe('evaluateTimerGate', () => {
|
|
146
|
+
it('fires when the current time matches the cron schedule', () => {
|
|
147
|
+
// Set now to 9:00, and use a cron that fires at 9:00 every day
|
|
148
|
+
const now = new Date(2025, 5, 1, 9, 0, 30, 0).getTime(); // 9:00:30
|
|
149
|
+
const gate = makeGateDefinition({ trigger: { cron: '0 9 * * *' } });
|
|
150
|
+
const result = evaluateTimerGate(gate, now);
|
|
151
|
+
expect(result.fired).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
it('does not fire before the scheduled time', () => {
|
|
154
|
+
const now = new Date(2025, 5, 1, 8, 30, 0, 0).getTime(); // 8:30
|
|
155
|
+
const gate = makeGateDefinition({ trigger: { cron: '0 9 * * *' } });
|
|
156
|
+
const result = evaluateTimerGate(gate, now);
|
|
157
|
+
expect(result.fired).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
it('throws for non-timer gate', () => {
|
|
160
|
+
const gate = makeGateDefinition({ type: 'signal' });
|
|
161
|
+
expect(() => evaluateTimerGate(gate)).toThrow('non-timer gate');
|
|
162
|
+
});
|
|
163
|
+
it('throws for timer gate with missing cron field', () => {
|
|
164
|
+
const gate = makeGateDefinition({ trigger: {} });
|
|
165
|
+
expect(() => evaluateTimerGate(gate)).toThrow('missing or empty "cron" field');
|
|
166
|
+
});
|
|
167
|
+
it('returns a nextFireTime property', () => {
|
|
168
|
+
const now = new Date(2025, 5, 1, 8, 30, 0, 0).getTime();
|
|
169
|
+
const gate = makeGateDefinition({ trigger: { cron: '0 9 * * *' } });
|
|
170
|
+
const result = evaluateTimerGate(gate, now);
|
|
171
|
+
expect(result.nextFireTime).toBeGreaterThan(0);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// getApplicableTimerGates
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
describe('getApplicableTimerGates', () => {
|
|
178
|
+
it('filters by type=timer and appliesTo', () => {
|
|
179
|
+
const gates = [
|
|
180
|
+
makeGateDefinition({ name: 'timer-1', type: 'timer', appliesTo: ['development'] }),
|
|
181
|
+
makeGateDefinition({ name: 'signal-1', type: 'signal', appliesTo: ['development'] }),
|
|
182
|
+
makeGateDefinition({ name: 'timer-2', type: 'timer', appliesTo: ['qa'] }),
|
|
183
|
+
];
|
|
184
|
+
const workflow = makeWorkflow(gates);
|
|
185
|
+
const result = getApplicableTimerGates(workflow, 'development');
|
|
186
|
+
expect(result).toHaveLength(1);
|
|
187
|
+
expect(result[0].name).toBe('timer-1');
|
|
188
|
+
});
|
|
189
|
+
it('returns gates with no appliesTo restriction', () => {
|
|
190
|
+
const gates = [
|
|
191
|
+
makeGateDefinition({ name: 'global-timer', type: 'timer' }),
|
|
192
|
+
];
|
|
193
|
+
const workflow = makeWorkflow(gates);
|
|
194
|
+
const result = getApplicableTimerGates(workflow, 'any-phase');
|
|
195
|
+
expect(result).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
it('returns empty array when no gates defined', () => {
|
|
198
|
+
const workflow = makeWorkflow();
|
|
199
|
+
delete workflow.gates;
|
|
200
|
+
const result = getApplicableTimerGates(workflow, 'development');
|
|
201
|
+
expect(result).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
it('returns empty array when no timer gates match phase', () => {
|
|
204
|
+
const gates = [
|
|
205
|
+
makeGateDefinition({ name: 'timer-qa', type: 'timer', appliesTo: ['qa'] }),
|
|
206
|
+
];
|
|
207
|
+
const workflow = makeWorkflow(gates);
|
|
208
|
+
const result = getApplicableTimerGates(workflow, 'development');
|
|
209
|
+
expect(result).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
});
|