sails-hook-quest 0.0.5 → 0.0.6
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.md +27 -0
- package/CONTRACT.md +97 -0
- package/lib/core/child-result.js +80 -0
- package/lib/core/execution-child.js +14 -0
- package/lib/core/executor.js +295 -181
- package/lib/core/job-control.js +69 -8
- package/lib/core/loader.js +9 -3
- package/lib/core/result.js +91 -0
- package/lib/core/runtime.js +240 -0
- package/lib/core/scheduler.js +83 -44
- package/lib/index.js +52 -11
- package/lib/types.js +75 -5
- package/package.json +10 -1
- package/.claude/settings.local.json +0 -7
- package/.commitlintrc.js +0 -1
- package/.github/FUNDING.yml +0 -1
- package/.github/workflows/prettier.yml +0 -22
- package/.husky/pre-commit +0 -1
- package/.prettierrc.js +0 -5
- package/jsconfig.json +0 -17
- package/test/executor.test.js +0 -95
- package/typecheck/public-api-smoke.js +0 -84
package/lib/core/job-control.js
CHANGED
|
@@ -25,8 +25,9 @@ const MAX_SAFE_TIMEOUT = 2_147_483_647 // 2^31 - 1, ~24.8 days
|
|
|
25
25
|
* Schedule a job to run at its next scheduled time
|
|
26
26
|
* @param {String} name - Job name
|
|
27
27
|
* @param {QuestScheduleContext} [context] - Context with jobs, timers maps and helper functions
|
|
28
|
+
* @param {Date} [targetTime] - Original target for a long-delay intermediate timer
|
|
28
29
|
*/
|
|
29
|
-
function scheduleJob(name, context = {}) {
|
|
30
|
+
function scheduleJob(name, context = {}, targetTime) {
|
|
30
31
|
const { jobs = new Map(), timers = new Map() } = context
|
|
31
32
|
const getNextRunTime =
|
|
32
33
|
/** @type {NonNullable<QuestScheduleContext['getNextRunTime']>} */ (
|
|
@@ -47,10 +48,33 @@ function scheduleJob(name, context = {}) {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
// Clear any existing timer
|
|
50
|
-
stopJob(name,
|
|
51
|
+
stopJob(name, context)
|
|
51
52
|
|
|
52
53
|
// Get the next run time
|
|
53
|
-
const
|
|
54
|
+
const states = context.scheduleStates
|
|
55
|
+
const update = (registration) =>
|
|
56
|
+
states?.set(name, {
|
|
57
|
+
validation: 'not_checked',
|
|
58
|
+
validationErrors: [],
|
|
59
|
+
reason: null,
|
|
60
|
+
...states.get(name),
|
|
61
|
+
registration
|
|
62
|
+
})
|
|
63
|
+
if (states && !targetTime)
|
|
64
|
+
states.set(name, {
|
|
65
|
+
validation: 'not_checked',
|
|
66
|
+
validationErrors: [],
|
|
67
|
+
reason: null,
|
|
68
|
+
lastAttemptAt: new Date().toISOString(),
|
|
69
|
+
registration: 'not_registered'
|
|
70
|
+
})
|
|
71
|
+
let nextRun
|
|
72
|
+
try {
|
|
73
|
+
nextRun = targetTime || getNextRunTime(job)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
update('failed')
|
|
76
|
+
throw err
|
|
77
|
+
}
|
|
54
78
|
if (!nextRun) {
|
|
55
79
|
const sails = getGlobalSails()
|
|
56
80
|
if (sails) {
|
|
@@ -61,11 +85,30 @@ function scheduleJob(name, context = {}) {
|
|
|
61
85
|
return
|
|
62
86
|
}
|
|
63
87
|
|
|
88
|
+
if (!Number.isFinite(nextRun.getTime())) {
|
|
89
|
+
states?.set(name, {
|
|
90
|
+
...states.get(name),
|
|
91
|
+
registration: 'failed',
|
|
92
|
+
validation: 'invalid',
|
|
93
|
+
reason: null,
|
|
94
|
+
validationErrors: [
|
|
95
|
+
{
|
|
96
|
+
code: 'E_SCHEDULE_TARGET',
|
|
97
|
+
message: 'Schedule target must be a finite date.'
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
})
|
|
101
|
+
return
|
|
102
|
+
}
|
|
64
103
|
const delay = nextRun.getTime() - Date.now()
|
|
104
|
+
context.dueTimes?.set(name, nextRun)
|
|
65
105
|
|
|
66
106
|
// If delay is negative (past time), run immediately
|
|
67
107
|
if (delay <= 0) {
|
|
68
|
-
|
|
108
|
+
timers.delete(name)
|
|
109
|
+
context.dueTimes?.delete(name)
|
|
110
|
+
update('consumed')
|
|
111
|
+
executeJob(name, undefined, 'scheduled').catch((err) => {
|
|
69
112
|
const sails = getGlobalSails()
|
|
70
113
|
if (sails) {
|
|
71
114
|
sails.log.error(`Error running job "${name}":`, err)
|
|
@@ -83,10 +126,11 @@ function scheduleJob(name, context = {}) {
|
|
|
83
126
|
// that rechecks when we're closer to the target time
|
|
84
127
|
if (delay > MAX_SAFE_TIMEOUT) {
|
|
85
128
|
const timer = setTimeout(() => {
|
|
86
|
-
scheduleJob(name, context)
|
|
129
|
+
scheduleJob(name, context, nextRun)
|
|
87
130
|
}, MAX_SAFE_TIMEOUT)
|
|
88
131
|
|
|
89
132
|
timers.set(name, timer)
|
|
133
|
+
update('registered')
|
|
90
134
|
|
|
91
135
|
const sails = getGlobalSails()
|
|
92
136
|
if (sails) {
|
|
@@ -99,7 +143,10 @@ function scheduleJob(name, context = {}) {
|
|
|
99
143
|
|
|
100
144
|
// Set timer for the next execution
|
|
101
145
|
const timer = setTimeout(() => {
|
|
102
|
-
|
|
146
|
+
timers.delete(name)
|
|
147
|
+
context.dueTimes?.delete(name)
|
|
148
|
+
update('consumed')
|
|
149
|
+
executeJob(name, undefined, 'scheduled').catch((err) => {
|
|
103
150
|
const sails = getGlobalSails()
|
|
104
151
|
if (sails) {
|
|
105
152
|
sails.log.error(`Error running job "${name}":`, err)
|
|
@@ -113,6 +160,7 @@ function scheduleJob(name, context = {}) {
|
|
|
113
160
|
}, delay)
|
|
114
161
|
|
|
115
162
|
timers.set(name, timer)
|
|
163
|
+
update('registered')
|
|
116
164
|
|
|
117
165
|
const sails = getGlobalSails()
|
|
118
166
|
if (sails) {
|
|
@@ -128,6 +176,19 @@ function scheduleJob(name, context = {}) {
|
|
|
128
176
|
function stopJob(name, context = {}) {
|
|
129
177
|
const { timers = new Map() } = context
|
|
130
178
|
|
|
179
|
+
context.dueTimes?.delete(name)
|
|
180
|
+
if (
|
|
181
|
+
context.scheduleStates &&
|
|
182
|
+
(context.scheduleStates.has(name) || context.jobs?.has(name))
|
|
183
|
+
)
|
|
184
|
+
context.scheduleStates.set(name, {
|
|
185
|
+
validation: 'not_checked',
|
|
186
|
+
validationErrors: [],
|
|
187
|
+
reason: null,
|
|
188
|
+
lastAttemptAt: null,
|
|
189
|
+
...context.scheduleStates.get(name),
|
|
190
|
+
registration: 'stopped'
|
|
191
|
+
})
|
|
131
192
|
const timer = timers.get(name)
|
|
132
193
|
if (timer) {
|
|
133
194
|
clearTimeout(timer)
|
|
@@ -180,7 +241,7 @@ function stopJobs(jobNames, context = {}) {
|
|
|
180
241
|
: [jobNames]
|
|
181
242
|
|
|
182
243
|
for (const name of names) {
|
|
183
|
-
stopJob(name,
|
|
244
|
+
stopJob(name, context)
|
|
184
245
|
}
|
|
185
246
|
}
|
|
186
247
|
|
|
@@ -203,7 +264,7 @@ async function runJobs(jobNames, inputs, context = {}) {
|
|
|
203
264
|
? jobNames
|
|
204
265
|
: [jobNames]
|
|
205
266
|
|
|
206
|
-
const promises = names.map((name) => executeJob(name, inputs))
|
|
267
|
+
const promises = names.map((name) => executeJob(name, inputs, 'manual'))
|
|
207
268
|
return Promise.all(promises)
|
|
208
269
|
}
|
|
209
270
|
|
package/lib/core/loader.js
CHANGED
|
@@ -75,7 +75,7 @@ async function loadJobs(config, jobs = new Map()) {
|
|
|
75
75
|
for (const jobDef of config.jobs) {
|
|
76
76
|
// Handle string shorthand (just job name)
|
|
77
77
|
/** @type {QuestJobDefinition} */
|
|
78
|
-
const job = typeof jobDef === 'string' ? { name: jobDef } : jobDef
|
|
78
|
+
const job = typeof jobDef === 'string' ? { name: jobDef } : { ...jobDef }
|
|
79
79
|
const jobName = job.name
|
|
80
80
|
|
|
81
81
|
// Check for duplicate job names in config
|
|
@@ -89,11 +89,13 @@ async function loadJobs(config, jobs = new Map()) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
// Try to get script inputs if the script exists
|
|
92
|
-
const scriptDef = jobName ? scripts[jobName] : undefined
|
|
92
|
+
const scriptDef = jobName ? scripts[job.script || jobName] : undefined
|
|
93
93
|
if (scriptDef && !job.scriptInputs) {
|
|
94
94
|
job.scriptInputs = extractScriptInputDefaults(scriptDef.inputs)
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
job.script = job.script || jobName
|
|
98
|
+
job.inputSchema = scriptDef ? scriptDef.inputs || {} : undefined
|
|
97
99
|
addJobDefinition(job, jobs, config)
|
|
98
100
|
}
|
|
99
101
|
}
|
|
@@ -122,7 +124,9 @@ async function loadJobs(config, jobs = new Map()) {
|
|
|
122
124
|
withoutOverlapping: existingJob?.withoutOverlapping,
|
|
123
125
|
inputs: { ...(existingJob?.inputs || {}), ...(questConfig.inputs || {}) },
|
|
124
126
|
...questConfig,
|
|
125
|
-
scriptInputs
|
|
127
|
+
scriptInputs,
|
|
128
|
+
script: scriptName,
|
|
129
|
+
inputSchema: scriptDef.inputs || {}
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
addJobDefinition(jobDef, jobs, config)
|
|
@@ -152,6 +156,8 @@ function addJobDefinition(jobDef, jobs = new Map(), config = {}) {
|
|
|
152
156
|
/** @type {QuestJob} */
|
|
153
157
|
const job = {
|
|
154
158
|
name,
|
|
159
|
+
script: jobDef.script || name,
|
|
160
|
+
inputSchema: jobDef.inputSchema,
|
|
155
161
|
friendlyName: jobDef.friendlyName || name,
|
|
156
162
|
description: jobDef.description,
|
|
157
163
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const DEFAULT_RESULT_BYTES = 64 * 1024
|
|
4
|
+
const MAX_RESULT_BYTES = 1024 * 1024
|
|
5
|
+
|
|
6
|
+
/** @param {number} [value] */
|
|
7
|
+
function byteLimit(value) {
|
|
8
|
+
return Number.isFinite(value) && value > 0
|
|
9
|
+
? Math.min(MAX_RESULT_BYTES, Math.floor(value))
|
|
10
|
+
: DEFAULT_RESULT_BYTES
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Encode plain JSON without executing getters/toJSON or allocating an unbounded
|
|
15
|
+
* intermediate JSON string. Depth/node limits bound even tiny nested values.
|
|
16
|
+
* @param {any} value
|
|
17
|
+
* @param {number} [requestedBytes]
|
|
18
|
+
* @returns {{status: string, value?: any, json?: string, exit?: string}}
|
|
19
|
+
*/
|
|
20
|
+
function encodeResult(value, requestedBytes) {
|
|
21
|
+
if (value === undefined) return { status: 'undefined' }
|
|
22
|
+
const limit = byteLimit(requestedBytes)
|
|
23
|
+
const parts = []
|
|
24
|
+
const ancestors = new Set()
|
|
25
|
+
let bytes = 0
|
|
26
|
+
let nodes = 0
|
|
27
|
+
function append(text) {
|
|
28
|
+
bytes += Buffer.byteLength(text)
|
|
29
|
+
if (bytes > limit) throw new RangeError('too_large')
|
|
30
|
+
parts.push(text)
|
|
31
|
+
}
|
|
32
|
+
function visit(item, depth) {
|
|
33
|
+
if (++nodes > 10000 || depth > 64) throw new RangeError('too_large')
|
|
34
|
+
if (item === null) return append('null')
|
|
35
|
+
if (typeof item === 'boolean') return append(String(item))
|
|
36
|
+
if (typeof item === 'number' && Number.isFinite(item))
|
|
37
|
+
return append(String(item))
|
|
38
|
+
if (typeof item === 'string') {
|
|
39
|
+
if (
|
|
40
|
+
item.length > limit - bytes ||
|
|
41
|
+
Buffer.byteLength(item) > limit - bytes
|
|
42
|
+
)
|
|
43
|
+
throw new RangeError('too_large')
|
|
44
|
+
return append(JSON.stringify(item))
|
|
45
|
+
}
|
|
46
|
+
if (typeof item !== 'object') throw new TypeError('non_json')
|
|
47
|
+
if (ancestors.has(item)) throw new TypeError('circular')
|
|
48
|
+
const array = Array.isArray(item)
|
|
49
|
+
if (
|
|
50
|
+
!array &&
|
|
51
|
+
Object.getPrototypeOf(item) !== Object.prototype &&
|
|
52
|
+
Object.getPrototypeOf(item) !== null
|
|
53
|
+
) {
|
|
54
|
+
throw new TypeError('non_plain_object')
|
|
55
|
+
}
|
|
56
|
+
ancestors.add(item)
|
|
57
|
+
append(array ? '[' : '{')
|
|
58
|
+
const keys = array ? null : Object.keys(item)
|
|
59
|
+
const count = array ? item.length : keys.length
|
|
60
|
+
if (count > 10000) throw new RangeError('too_large')
|
|
61
|
+
for (let i = 0; i < count; i++) {
|
|
62
|
+
if (i) append(',')
|
|
63
|
+
const key = array ? String(i) : keys[i]
|
|
64
|
+
if (!array) {
|
|
65
|
+
if (
|
|
66
|
+
key.length > limit - bytes ||
|
|
67
|
+
Buffer.byteLength(key) > limit - bytes
|
|
68
|
+
)
|
|
69
|
+
throw new RangeError('too_large')
|
|
70
|
+
append(JSON.stringify(key) + ':')
|
|
71
|
+
}
|
|
72
|
+
const descriptor = Object.getOwnPropertyDescriptor(item, key)
|
|
73
|
+
if (!descriptor || !('value' in descriptor))
|
|
74
|
+
throw new TypeError('non_json_property')
|
|
75
|
+
visit(descriptor.value, depth + 1)
|
|
76
|
+
}
|
|
77
|
+
append(array ? ']' : '}')
|
|
78
|
+
ancestors.delete(item)
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
visit(value, 0)
|
|
82
|
+
const json = parts.join('')
|
|
83
|
+
return { status: 'available', value: JSON.parse(json), json }
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return {
|
|
86
|
+
status: error instanceof RangeError ? 'too_large' : 'serialization_error'
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { encodeResult, byteLimit }
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
const { randomUUID } = require('node:crypto')
|
|
3
|
+
const { encodeResult } = require('./result')
|
|
4
|
+
const { effectiveTimezone } = require('./scheduler')
|
|
5
|
+
|
|
6
|
+
function createRuntime() {
|
|
7
|
+
return { runtimeId: randomUUID(), sequence: 0, active: new Map() }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** @param {import('../types').QuestContext} context */
|
|
11
|
+
function runtimeInfo(context) {
|
|
12
|
+
return {
|
|
13
|
+
contractVersion: 1,
|
|
14
|
+
runtimeId: context.runtime.runtimeId,
|
|
15
|
+
scope: 'process',
|
|
16
|
+
capabilities: {
|
|
17
|
+
inputMetadata: true,
|
|
18
|
+
runIdentity: true,
|
|
19
|
+
businessResults: true,
|
|
20
|
+
residentState: true,
|
|
21
|
+
childSchedulerSuppression: true,
|
|
22
|
+
triggerProvenance: true,
|
|
23
|
+
terminalExitCode: true,
|
|
24
|
+
terminalSignal: true,
|
|
25
|
+
scheduleDiagnostics: true,
|
|
26
|
+
cancellation: false,
|
|
27
|
+
durableHistory: false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fields = [
|
|
33
|
+
'type',
|
|
34
|
+
'description',
|
|
35
|
+
'friendlyName',
|
|
36
|
+
'required',
|
|
37
|
+
'allowNull',
|
|
38
|
+
'isIn',
|
|
39
|
+
'min',
|
|
40
|
+
'max',
|
|
41
|
+
'minLength',
|
|
42
|
+
'maxLength',
|
|
43
|
+
'isEmail',
|
|
44
|
+
'isURL',
|
|
45
|
+
'isInteger',
|
|
46
|
+
'regex',
|
|
47
|
+
'example'
|
|
48
|
+
]
|
|
49
|
+
/** @param {import('../types').QuestMachineInputs} [schema] */
|
|
50
|
+
function inputMetadata(schema = {}) {
|
|
51
|
+
const result = Object.create(null)
|
|
52
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
53
|
+
if (!def || typeof def !== 'object') continue
|
|
54
|
+
const field = Object.create(null)
|
|
55
|
+
field.sensitive = Boolean(
|
|
56
|
+
def.protect ||
|
|
57
|
+
def.secret ||
|
|
58
|
+
def.sensitive ||
|
|
59
|
+
/password|secret|token|credential/i.test(name)
|
|
60
|
+
)
|
|
61
|
+
field.customValidation = typeof def.custom === 'function'
|
|
62
|
+
for (const key of fields) {
|
|
63
|
+
if (field.sensitive && key === 'example') continue
|
|
64
|
+
if (Object.prototype.hasOwnProperty.call(def, key)) {
|
|
65
|
+
const encoded = encodeResult(def[key], 4096)
|
|
66
|
+
if (encoded.status === 'available') field[key] = encoded.value
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
!field.sensitive &&
|
|
71
|
+
Object.prototype.hasOwnProperty.call(def, 'defaultsTo')
|
|
72
|
+
) {
|
|
73
|
+
const encoded = encodeResult(def.defaultsTo, 4096)
|
|
74
|
+
if (encoded.status === 'available') field.defaultsTo = encoded.value
|
|
75
|
+
}
|
|
76
|
+
result[name] = field
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Describe the raw effective scheduled values; validation is still Sails' authority.
|
|
82
|
+
* @param {import('../types').QuestJob} job
|
|
83
|
+
*/
|
|
84
|
+
function scheduledInputs(job) {
|
|
85
|
+
const schema = job.inputSchema || {}
|
|
86
|
+
const safeSchema = inputMetadata(schema)
|
|
87
|
+
const configured = job.inputs || {}
|
|
88
|
+
const scriptInputs = job.scriptInputs || {}
|
|
89
|
+
const names = new Set([
|
|
90
|
+
...Object.keys(schema),
|
|
91
|
+
...Object.keys(configured),
|
|
92
|
+
...Object.keys(scriptInputs)
|
|
93
|
+
])
|
|
94
|
+
const values = Object.create(null)
|
|
95
|
+
const fields = Object.create(null)
|
|
96
|
+
let remaining = 64 * 1024
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
const own = (object) => Object.prototype.hasOwnProperty.call(object, name)
|
|
99
|
+
const source = own(scriptInputs)
|
|
100
|
+
? 'script_input'
|
|
101
|
+
: own(configured)
|
|
102
|
+
? 'job_input'
|
|
103
|
+
: schema[name]?.defaultsTo !== undefined
|
|
104
|
+
? 'schema_default'
|
|
105
|
+
: 'omitted'
|
|
106
|
+
const value =
|
|
107
|
+
source === 'script_input'
|
|
108
|
+
? scriptInputs[name]
|
|
109
|
+
: source === 'job_input'
|
|
110
|
+
? configured[name]
|
|
111
|
+
: source === 'schema_default'
|
|
112
|
+
? schema[name].defaultsTo
|
|
113
|
+
: undefined
|
|
114
|
+
const sensitive =
|
|
115
|
+
safeSchema[name]?.sensitive ||
|
|
116
|
+
/password|secret|token|credential/i.test(name)
|
|
117
|
+
/** @type {import('../types').AnyRecord} */
|
|
118
|
+
const field = {
|
|
119
|
+
source,
|
|
120
|
+
sensitive: Boolean(sensitive),
|
|
121
|
+
available: false,
|
|
122
|
+
missingRequired: Boolean(schema[name]?.required && value === undefined)
|
|
123
|
+
}
|
|
124
|
+
if (value !== undefined && !sensitive) {
|
|
125
|
+
const encoded = encodeResult(
|
|
126
|
+
value,
|
|
127
|
+
Math.max(1, Math.min(4096, remaining))
|
|
128
|
+
)
|
|
129
|
+
if (
|
|
130
|
+
encoded.status === 'available' &&
|
|
131
|
+
Buffer.byteLength(encoded.json) <= remaining
|
|
132
|
+
) {
|
|
133
|
+
values[name] = encoded.value
|
|
134
|
+
field.available = true
|
|
135
|
+
remaining -= Buffer.byteLength(encoded.json)
|
|
136
|
+
} else
|
|
137
|
+
field.reason =
|
|
138
|
+
encoded.status === 'available' ? 'metadata_limit' : encoded.status
|
|
139
|
+
} else if (sensitive) field.reason = 'sensitive'
|
|
140
|
+
fields[name] = field
|
|
141
|
+
}
|
|
142
|
+
return { values, fields, validation: 'not_checked', limitBytes: 64 * 1024 }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Mirror parser precedence; unused lower-priority fields do not change restart semantics. */
|
|
146
|
+
function restartSemantics(job) {
|
|
147
|
+
let timing = 'none'
|
|
148
|
+
let oneShot = false
|
|
149
|
+
if (job.cron) timing = 'wall_clock'
|
|
150
|
+
else if (job.interval !== undefined && job.interval !== false)
|
|
151
|
+
timing =
|
|
152
|
+
typeof job.interval === 'string' && /at|on the/.test(job.interval)
|
|
153
|
+
? 'wall_clock'
|
|
154
|
+
: 'relative_to_registration'
|
|
155
|
+
else if (job.timeout !== undefined && job.timeout !== false) {
|
|
156
|
+
timing =
|
|
157
|
+
typeof job.timeout === 'string' && job.timeout.startsWith('at ')
|
|
158
|
+
? 'wall_clock'
|
|
159
|
+
: 'relative_to_registration'
|
|
160
|
+
oneShot = true
|
|
161
|
+
} else if (job.date !== undefined && job.date !== false) {
|
|
162
|
+
timing = 'wall_clock'
|
|
163
|
+
oneShot = true
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
persistence: 'memory_only',
|
|
167
|
+
timing,
|
|
168
|
+
oneShot,
|
|
169
|
+
missedRuns: 'not_replayed'
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @param {import('../types').QuestContext} context @param {string} [name] */
|
|
174
|
+
function metadata(context, name) {
|
|
175
|
+
const describe = (job) => {
|
|
176
|
+
const schedule = {}
|
|
177
|
+
for (const key of [
|
|
178
|
+
'interval',
|
|
179
|
+
'cron',
|
|
180
|
+
'cronOptions',
|
|
181
|
+
'timeout',
|
|
182
|
+
'date',
|
|
183
|
+
'timezone'
|
|
184
|
+
]) {
|
|
185
|
+
if (job[key] !== undefined) {
|
|
186
|
+
const encoded = encodeResult(
|
|
187
|
+
job[key] instanceof Date
|
|
188
|
+
? Number.isFinite(job[key].getTime())
|
|
189
|
+
? job[key].toISOString()
|
|
190
|
+
: null
|
|
191
|
+
: job[key],
|
|
192
|
+
4096
|
|
193
|
+
)
|
|
194
|
+
if (encoded.status === 'available') schedule[key] = encoded.value
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
schedule.timezone = effectiveTimezone(job, context.config || {})
|
|
198
|
+
return {
|
|
199
|
+
name: job.name,
|
|
200
|
+
script: job.script || job.name,
|
|
201
|
+
friendlyName: job.friendlyName,
|
|
202
|
+
description: job.description,
|
|
203
|
+
inputMetadataAvailable: Boolean(job.inputSchema),
|
|
204
|
+
inputs: inputMetadata(job.inputSchema),
|
|
205
|
+
scheduledInputs: scheduledInputs(job),
|
|
206
|
+
schedule,
|
|
207
|
+
scheduleState: {
|
|
208
|
+
registration: 'not_attempted',
|
|
209
|
+
validation: 'not_checked',
|
|
210
|
+
reason: null,
|
|
211
|
+
lastAttemptAt: null,
|
|
212
|
+
...context.scheduleStates?.get(job.name),
|
|
213
|
+
validationErrors: (
|
|
214
|
+
context.scheduleStates?.get(job.name)?.validationErrors || []
|
|
215
|
+
).map(({ code, message }) => ({ code, message })),
|
|
216
|
+
restart: restartSemantics(job)
|
|
217
|
+
},
|
|
218
|
+
paused: job.paused,
|
|
219
|
+
withoutOverlapping: job.withoutOverlapping,
|
|
220
|
+
runningCount: context.runtime.active.get(job.name)?.size || 0,
|
|
221
|
+
scheduled: context.timers.has(job.name),
|
|
222
|
+
nextRunAt: context.timers.has(job.name)
|
|
223
|
+
? context.dueTimes.get(job.name)?.toISOString() || null
|
|
224
|
+
: null,
|
|
225
|
+
runtimeId: context.runtime.runtimeId
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (name !== undefined) {
|
|
229
|
+
const job = context.jobs.get(name)
|
|
230
|
+
return job ? describe(job) : undefined
|
|
231
|
+
}
|
|
232
|
+
return Array.from(context.jobs.values(), describe)
|
|
233
|
+
}
|
|
234
|
+
module.exports = {
|
|
235
|
+
createRuntime,
|
|
236
|
+
runtimeInfo,
|
|
237
|
+
inputMetadata,
|
|
238
|
+
scheduledInputs,
|
|
239
|
+
metadata
|
|
240
|
+
}
|
package/lib/core/scheduler.js
CHANGED
|
@@ -19,72 +19,110 @@ const { getGlobalSails } = require('./global-sails')
|
|
|
19
19
|
* Parse various schedule formats and return next run time
|
|
20
20
|
* @param {QuestJobDefinition} job - Job configuration
|
|
21
21
|
* @param {QuestConfig} [config] - Quest configuration
|
|
22
|
+
* @param {(assessment: import('../types').AnyRecord) => void} [report] Bounded parser assessment
|
|
22
23
|
* @returns {Date|null} Next run time or null if invalid
|
|
23
24
|
*/
|
|
24
|
-
function getNextRunTime(job, config = {}) {
|
|
25
|
+
function getNextRunTime(job, config = {}, report = () => {}) {
|
|
25
26
|
const now = new Date()
|
|
26
|
-
const
|
|
27
|
+
const assess = (nextRun, validation, reason = null, code = null) => {
|
|
28
|
+
report({
|
|
29
|
+
validation,
|
|
30
|
+
reason,
|
|
31
|
+
validationErrors: code
|
|
32
|
+
? [{ code, message: scheduleErrorMessages[code] }]
|
|
33
|
+
: []
|
|
34
|
+
})
|
|
35
|
+
return nextRun
|
|
36
|
+
}
|
|
37
|
+
const invalid = (code) => assess(null, 'invalid', null, code)
|
|
27
38
|
|
|
28
|
-
//
|
|
39
|
+
// Preserve the existing conflict exception, but retain its assessment first.
|
|
29
40
|
if (job.date && job.timeout !== undefined && job.timeout !== false) {
|
|
41
|
+
invalid('E_SCHEDULE_CONFLICT')
|
|
30
42
|
throw new Error(
|
|
31
43
|
`Job "${job.name}": Cannot combine 'date' and 'timeout'. Use one or the other.`
|
|
32
44
|
)
|
|
33
45
|
}
|
|
34
|
-
|
|
35
|
-
// Handle cron expressions
|
|
36
46
|
if (job.cron) {
|
|
47
|
+
let interval
|
|
37
48
|
try {
|
|
38
|
-
const options = { tz:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
Object.assign(options, job.cronOptions)
|
|
42
|
-
}
|
|
43
|
-
const interval = CronExpressionParser.parse(job.cron, options)
|
|
44
|
-
return interval.next().toDate()
|
|
49
|
+
const options = { tz: effectiveTimezone(job, config) }
|
|
50
|
+
if (job.cronOptions) Object.assign(options, job.cronOptions)
|
|
51
|
+
interval = CronExpressionParser.parse(job.cron, options)
|
|
45
52
|
} catch (err) {
|
|
46
53
|
const sails = getGlobalSails()
|
|
47
|
-
if (sails) {
|
|
48
|
-
|
|
49
|
-
`Invalid cron expression for job "${job.name}": ${job.cron}`,
|
|
50
|
-
err instanceof Error ? err.message : err
|
|
51
|
-
)
|
|
52
|
-
}
|
|
53
|
-
return null
|
|
54
|
+
if (sails) sails.log.error(`Invalid cron schedule for job "${job.name}"`)
|
|
55
|
+
return invalid('E_SCHEDULE_CRON')
|
|
54
56
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (nextTime) return nextTime
|
|
61
|
-
|
|
62
|
-
const sails = getGlobalSails()
|
|
63
|
-
if (sails) {
|
|
64
|
-
sails.log.error(`Invalid interval for job "${job.name}": ${job.interval}`)
|
|
57
|
+
try {
|
|
58
|
+
return assess(interval.next().toDate(), 'valid')
|
|
59
|
+
} catch (err) {
|
|
60
|
+
// A parsed cron can be exhausted by its endDate; that is not invalid.
|
|
61
|
+
return assess(null, 'valid', 'no_future_run')
|
|
65
62
|
}
|
|
66
|
-
return null
|
|
67
63
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
if (job.interval !== undefined && job.interval !== false) {
|
|
65
|
+
let nextTime = null
|
|
66
|
+
if (typeof job.interval === 'string')
|
|
67
|
+
nextTime = parseInterval(job.interval, now)
|
|
68
|
+
if (
|
|
69
|
+
typeof job.interval === 'number' &&
|
|
70
|
+
Number.isFinite(job.interval) &&
|
|
71
|
+
job.interval > 0
|
|
72
|
+
)
|
|
73
|
+
nextTime = new Date(now.getTime() + job.interval)
|
|
74
|
+
if (!nextTime || !Number.isFinite(nextTime.getTime()) || nextTime <= now)
|
|
75
|
+
return invalid('E_SCHEDULE_INTERVAL')
|
|
76
|
+
return assess(nextTime, 'valid')
|
|
72
77
|
}
|
|
73
|
-
|
|
74
|
-
// Handle timeout (one-time delay)
|
|
75
78
|
if (job.timeout !== undefined && job.timeout !== false) {
|
|
76
|
-
|
|
79
|
+
if (
|
|
80
|
+
typeof job.timeout === 'number' &&
|
|
81
|
+
(!Number.isFinite(job.timeout) || job.timeout < 0)
|
|
82
|
+
)
|
|
83
|
+
return invalid('E_SCHEDULE_TIMEOUT')
|
|
84
|
+
const nextTime = parseTimeout(job.timeout, now)
|
|
85
|
+
if (!nextTime || !Number.isFinite(nextTime.getTime()) || nextTime < now)
|
|
86
|
+
return invalid('E_SCHEDULE_TIMEOUT')
|
|
87
|
+
return assess(nextTime, 'valid')
|
|
77
88
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
if (job.date !== undefined && job.date !== false) {
|
|
90
|
+
if (
|
|
91
|
+
!(job.date instanceof Date) &&
|
|
92
|
+
typeof job.date !== 'string' &&
|
|
93
|
+
typeof job.date !== 'number'
|
|
94
|
+
)
|
|
95
|
+
return invalid('E_SCHEDULE_DATE')
|
|
81
96
|
const date = new Date(job.date)
|
|
82
|
-
if (date
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
if (!Number.isFinite(date.getTime())) return invalid('E_SCHEDULE_DATE')
|
|
98
|
+
return assess(
|
|
99
|
+
date > now ? date : null,
|
|
100
|
+
'valid',
|
|
101
|
+
date > now ? null : 'no_future_run'
|
|
102
|
+
)
|
|
85
103
|
}
|
|
104
|
+
return assess(null, 'not_checked', 'no_schedule')
|
|
105
|
+
}
|
|
86
106
|
|
|
87
|
-
|
|
107
|
+
const scheduleErrorMessages = {
|
|
108
|
+
E_SCHEDULE_CONFLICT: 'Date and timeout cannot be combined.',
|
|
109
|
+
E_SCHEDULE_CRON: 'Cron expression, timezone, or parser options are invalid.',
|
|
110
|
+
E_SCHEDULE_INTERVAL:
|
|
111
|
+
'Interval must produce a finite future time; numeric intervals must be positive.',
|
|
112
|
+
E_SCHEDULE_TIMEOUT:
|
|
113
|
+
'Timeout must produce a finite time; numeric timeouts must be nonnegative.',
|
|
114
|
+
E_SCHEDULE_DATE: 'Date must be a supported finite date value.'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Use the same precedence as cron-parser registration, including explicit tz overrides.
|
|
118
|
+
* @param {QuestJobDefinition} job @param {QuestConfig} [config]
|
|
119
|
+
*/
|
|
120
|
+
function effectiveTimezone(job, config = {}) {
|
|
121
|
+
return job.cron &&
|
|
122
|
+
job.cronOptions &&
|
|
123
|
+
Object.prototype.hasOwnProperty.call(job.cronOptions, 'tz')
|
|
124
|
+
? job.cronOptions.tz || null
|
|
125
|
+
: job.timezone || config.timezone || null
|
|
88
126
|
}
|
|
89
127
|
|
|
90
128
|
/**
|
|
@@ -207,6 +245,7 @@ function convertShorthand(str) {
|
|
|
207
245
|
|
|
208
246
|
module.exports = {
|
|
209
247
|
getNextRunTime,
|
|
248
|
+
effectiveTimezone,
|
|
210
249
|
parseInterval,
|
|
211
250
|
parseTimeout,
|
|
212
251
|
convertShorthand
|