sails-hook-quest 0.0.4 → 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/README.md +14 -1
- package/lib/core/child-result.js +80 -0
- package/lib/core/execution-child.js +14 -0
- package/lib/core/executor.js +346 -143
- package/lib/core/global-sails.js +16 -0
- package/lib/core/job-control.js +130 -34
- package/lib/core/loader.js +60 -28
- package/lib/core/result.js +91 -0
- package/lib/core/runtime.js +240 -0
- package/lib/core/scheduler.js +95 -46
- package/lib/index.js +94 -17
- package/lib/types.js +301 -0
- package/package.json +16 -2
- 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/sails-hook-quest-0.0.0.tgz +0 -0
|
@@ -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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* core/scheduler.js
|
|
3
5
|
*
|
|
@@ -7,75 +9,120 @@
|
|
|
7
9
|
const later = require('@breejs/later')
|
|
8
10
|
const humanInterval = require('human-interval')
|
|
9
11
|
const { CronExpressionParser } = require('cron-parser')
|
|
12
|
+
const { getGlobalSails } = require('./global-sails')
|
|
13
|
+
|
|
14
|
+
/** @typedef {import('../types').QuestConfig} QuestConfig */
|
|
15
|
+
/** @typedef {import('../types').QuestJobDefinition} QuestJobDefinition */
|
|
16
|
+
/** @typedef {import('../types').QuestTimeout} QuestTimeout */
|
|
10
17
|
|
|
11
18
|
/**
|
|
12
19
|
* Parse various schedule formats and return next run time
|
|
13
|
-
* @param {
|
|
14
|
-
* @param {
|
|
20
|
+
* @param {QuestJobDefinition} job - Job configuration
|
|
21
|
+
* @param {QuestConfig} [config] - Quest configuration
|
|
22
|
+
* @param {(assessment: import('../types').AnyRecord) => void} [report] Bounded parser assessment
|
|
15
23
|
* @returns {Date|null} Next run time or null if invalid
|
|
16
24
|
*/
|
|
17
|
-
function getNextRunTime(job, config = {}) {
|
|
25
|
+
function getNextRunTime(job, config = {}, report = () => {}) {
|
|
18
26
|
const now = new Date()
|
|
19
|
-
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)
|
|
20
38
|
|
|
21
|
-
//
|
|
39
|
+
// Preserve the existing conflict exception, but retain its assessment first.
|
|
22
40
|
if (job.date && job.timeout !== undefined && job.timeout !== false) {
|
|
41
|
+
invalid('E_SCHEDULE_CONFLICT')
|
|
23
42
|
throw new Error(
|
|
24
43
|
`Job "${job.name}": Cannot combine 'date' and 'timeout'. Use one or the other.`
|
|
25
44
|
)
|
|
26
45
|
}
|
|
27
|
-
|
|
28
|
-
// Handle cron expressions
|
|
29
46
|
if (job.cron) {
|
|
47
|
+
let interval
|
|
30
48
|
try {
|
|
31
|
-
const options = { tz:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Object.assign(options, job.cronOptions)
|
|
35
|
-
}
|
|
36
|
-
const interval = CronExpressionParser.parse(job.cron, options)
|
|
37
|
-
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)
|
|
38
52
|
} catch (err) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
err.message
|
|
43
|
-
)
|
|
44
|
-
}
|
|
45
|
-
return null
|
|
53
|
+
const sails = getGlobalSails()
|
|
54
|
+
if (sails) sails.log.error(`Invalid cron schedule for job "${job.name}"`)
|
|
55
|
+
return invalid('E_SCHEDULE_CRON')
|
|
46
56
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (nextTime) return nextTime
|
|
53
|
-
|
|
54
|
-
if (global.sails) {
|
|
55
|
-
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')
|
|
56
62
|
}
|
|
57
|
-
return null
|
|
58
63
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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')
|
|
63
77
|
}
|
|
64
|
-
|
|
65
|
-
// Handle timeout (one-time delay)
|
|
66
78
|
if (job.timeout !== undefined && job.timeout !== false) {
|
|
67
|
-
|
|
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')
|
|
68
88
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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')
|
|
72
96
|
const date = new Date(job.date)
|
|
73
|
-
if (date
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
)
|
|
76
103
|
}
|
|
104
|
+
return assess(null, 'not_checked', 'no_schedule')
|
|
105
|
+
}
|
|
77
106
|
|
|
78
|
-
|
|
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
|
|
79
126
|
}
|
|
80
127
|
|
|
81
128
|
/**
|
|
@@ -95,6 +142,7 @@ function parseInterval(intervalStr, fromDate = new Date()) {
|
|
|
95
142
|
if (everyMatch) {
|
|
96
143
|
const amount = parseInt(everyMatch[1])
|
|
97
144
|
const unit = everyMatch[2].replace(/s$/, '') // Remove plural 's'
|
|
145
|
+
/** @type {Record<string, number>} */
|
|
98
146
|
const msMap = {
|
|
99
147
|
second: 1000,
|
|
100
148
|
minute: 60000,
|
|
@@ -138,7 +186,7 @@ function parseInterval(intervalStr, fromDate = new Date()) {
|
|
|
138
186
|
|
|
139
187
|
/**
|
|
140
188
|
* Parse a timeout value into a Date
|
|
141
|
-
* @param {
|
|
189
|
+
* @param {QuestTimeout} timeout - Timeout value
|
|
142
190
|
* @param {Date} fromDate - Calculate from this date
|
|
143
191
|
* @returns {Date|null} Next run time or null
|
|
144
192
|
*/
|
|
@@ -197,6 +245,7 @@ function convertShorthand(str) {
|
|
|
197
245
|
|
|
198
246
|
module.exports = {
|
|
199
247
|
getNextRunTime,
|
|
248
|
+
effectiveTimezone,
|
|
200
249
|
parseInterval,
|
|
201
250
|
parseTimeout,
|
|
202
251
|
convertShorthand
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* sails-hook-quest
|
|
3
5
|
*
|
|
@@ -9,17 +11,45 @@ const scheduler = require('./core/scheduler')
|
|
|
9
11
|
const executor = require('./core/executor')
|
|
10
12
|
const loader = require('./core/loader')
|
|
11
13
|
const jobControl = require('./core/job-control')
|
|
14
|
+
const resident = require('./core/runtime')
|
|
15
|
+
const { isExecutionChild } = require('./core/execution-child')
|
|
16
|
+
|
|
17
|
+
/** @typedef {import('./types').AnyRecord} AnyRecord */
|
|
18
|
+
/** @typedef {import('./types').QuestApi} QuestApi */
|
|
19
|
+
/** @typedef {import('./types').QuestConfig} QuestConfig */
|
|
20
|
+
/** @typedef {import('./types').QuestContext} QuestContext */
|
|
21
|
+
/** @typedef {import('./types').QuestExecutableJob} QuestExecutableJob */
|
|
22
|
+
/** @typedef {import('./types').QuestJobsMap} QuestJobsMap */
|
|
23
|
+
/** @typedef {import('./types').QuestNamedJobDefinition} QuestNamedJobDefinition */
|
|
24
|
+
/** @typedef {import('./types').QuestRunningMap} QuestRunningMap */
|
|
25
|
+
/** @typedef {import('./types').QuestSailsApp} QuestSailsApp */
|
|
26
|
+
/** @typedef {import('./types').QuestSailsHook} QuestSailsHook */
|
|
27
|
+
/** @typedef {import('./types').QuestTimersMap} QuestTimersMap */
|
|
12
28
|
|
|
13
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Sails hook factory.
|
|
31
|
+
*
|
|
32
|
+
* @param {QuestSailsApp} sails
|
|
33
|
+
* @returns {QuestSailsHook}
|
|
34
|
+
*/
|
|
35
|
+
function defineQuestHook(sails) {
|
|
36
|
+
/** @type {QuestJobsMap} */
|
|
14
37
|
const jobs = new Map()
|
|
38
|
+
/** @type {QuestTimersMap} */
|
|
15
39
|
const timers = new Map()
|
|
40
|
+
/** @type {QuestRunningMap} */
|
|
16
41
|
const running = new Map()
|
|
17
42
|
|
|
18
43
|
// Create context object that will be passed to modules
|
|
44
|
+
/** @type {QuestContext} */
|
|
19
45
|
const context = {
|
|
20
46
|
jobs,
|
|
21
47
|
timers,
|
|
22
48
|
running,
|
|
49
|
+
sails,
|
|
50
|
+
runtime: resident.createRuntime(),
|
|
51
|
+
dueTimes: new Map(),
|
|
52
|
+
scheduleStates: new Map(),
|
|
23
53
|
config: null, // Will be set after sails.config is available
|
|
24
54
|
scheduleJob: null, // Will be set after function is defined
|
|
25
55
|
executeJob: null, // Will be set after function is defined
|
|
@@ -47,6 +77,12 @@ module.exports = function defineQuestHook(sails) {
|
|
|
47
77
|
// Directory containing job scripts
|
|
48
78
|
scriptsDir: 'scripts',
|
|
49
79
|
|
|
80
|
+
// Maximum child output retained on failure (live output remains unbounded)
|
|
81
|
+
diagnosticTailBytes: 64 * 1024,
|
|
82
|
+
|
|
83
|
+
resultBytes: 64 * 1024,
|
|
84
|
+
inputBytes: 64 * 1024,
|
|
85
|
+
|
|
50
86
|
// Jobs defined in config
|
|
51
87
|
jobs: []
|
|
52
88
|
}
|
|
@@ -55,36 +91,64 @@ module.exports = function defineQuestHook(sails) {
|
|
|
55
91
|
initialize: async function () {
|
|
56
92
|
sails.log.info('Initializing Quest job scheduler')
|
|
57
93
|
|
|
58
|
-
|
|
94
|
+
// Register cleanup before awaiting dependencies so failed lift also stops timers.
|
|
95
|
+
sails.on('lower', async () => {
|
|
96
|
+
sails.log.info('Stopping Quest jobs...')
|
|
97
|
+
await jobControl.stopJobs(null, context)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const initializeAfterOrm = async () => {
|
|
101
|
+
// The owned CLI child needs the app's hooks/context, but must not start
|
|
102
|
+
// another copy of its resident schedules, even if source config enables it.
|
|
103
|
+
if (isExecutionChild()) sails.config.quest.autoStart = false
|
|
104
|
+
const questConfig = {
|
|
105
|
+
...sails.config.quest,
|
|
106
|
+
appPath:
|
|
107
|
+
sails.config.quest.appPath || sails.config.appPath || process.cwd()
|
|
108
|
+
}
|
|
109
|
+
|
|
59
110
|
// Set up context with config
|
|
60
|
-
context.config =
|
|
111
|
+
context.config = questConfig
|
|
61
112
|
context.getNextRunTime = (job) =>
|
|
62
|
-
scheduler.getNextRunTime(job,
|
|
113
|
+
scheduler.getNextRunTime(job, questConfig, (assessment) => {
|
|
114
|
+
context.scheduleStates.set(job.name, {
|
|
115
|
+
...context.scheduleStates.get(job.name),
|
|
116
|
+
...assessment
|
|
117
|
+
})
|
|
118
|
+
})
|
|
63
119
|
context.scheduleJob = (name) => jobControl.scheduleJob(name, context)
|
|
64
|
-
context.executeJob = (name, customInputs) => {
|
|
120
|
+
context.executeJob = (name, customInputs, trigger = 'manual') => {
|
|
65
121
|
const job = jobs.get(name)
|
|
66
122
|
if (!job) {
|
|
67
123
|
// Try to run as a regular script without quest config
|
|
124
|
+
/** @type {QuestExecutableJob} */
|
|
68
125
|
const minimalJob = {
|
|
69
126
|
name,
|
|
70
127
|
withoutOverlapping: false,
|
|
71
128
|
inputs: {}
|
|
72
129
|
}
|
|
73
|
-
return executor.executeJob(name, minimalJob, customInputs,
|
|
130
|
+
return executor.executeJob(name, minimalJob, customInputs, {
|
|
131
|
+
...context,
|
|
132
|
+
trigger
|
|
133
|
+
})
|
|
74
134
|
}
|
|
75
|
-
return executor.executeJob(name, job, customInputs,
|
|
135
|
+
return executor.executeJob(name, job, customInputs, {
|
|
136
|
+
...context,
|
|
137
|
+
trigger
|
|
138
|
+
})
|
|
76
139
|
}
|
|
77
140
|
|
|
78
141
|
// Load jobs from scripts and config
|
|
79
|
-
await loader.loadJobs(
|
|
142
|
+
await loader.loadJobs(questConfig, jobs)
|
|
80
143
|
|
|
81
144
|
// Start all jobs if autoStart is enabled
|
|
82
|
-
if (
|
|
145
|
+
if (questConfig.autoStart) {
|
|
83
146
|
await jobControl.startJobs(null, context)
|
|
84
147
|
}
|
|
85
148
|
|
|
86
149
|
// Expose the Quest API
|
|
87
|
-
|
|
150
|
+
/** @type {QuestApi} */
|
|
151
|
+
const questApi = {
|
|
88
152
|
// Core job control
|
|
89
153
|
start: (jobNames) => jobControl.startJobs(jobNames, context),
|
|
90
154
|
stop: (jobNames) => jobControl.stopJobs(jobNames, context),
|
|
@@ -92,12 +156,15 @@ module.exports = function defineQuestHook(sails) {
|
|
|
92
156
|
jobControl.runJobs(jobNames, inputs, context),
|
|
93
157
|
add: (jobDefs) => {
|
|
94
158
|
const defs = Array.isArray(jobDefs) ? jobDefs : [jobDefs]
|
|
159
|
+
/** @type {string[]} */
|
|
95
160
|
const added = []
|
|
96
161
|
for (const def of defs) {
|
|
162
|
+
/** @type {QuestNamedJobDefinition} */
|
|
97
163
|
const job = typeof def === 'string' ? { name: def } : def
|
|
98
|
-
loader.addJobDefinition(job, jobs, context.config)
|
|
164
|
+
loader.addJobDefinition(job, jobs, context.config || {})
|
|
165
|
+
context.scheduleStates.delete(job.name)
|
|
99
166
|
added.push(job.name)
|
|
100
|
-
if (context.config
|
|
167
|
+
if (context.config?.autoStart) {
|
|
101
168
|
jobControl.scheduleJob(job.name, context)
|
|
102
169
|
}
|
|
103
170
|
}
|
|
@@ -108,12 +175,17 @@ module.exports = function defineQuestHook(sails) {
|
|
|
108
175
|
const removed = []
|
|
109
176
|
for (const name of names) {
|
|
110
177
|
if (loader.removeJob(name, jobs, timers)) {
|
|
178
|
+
context.dueTimes.delete(name)
|
|
179
|
+
context.scheduleStates.delete(name)
|
|
111
180
|
removed.push(name)
|
|
112
181
|
}
|
|
113
182
|
}
|
|
114
183
|
return removed
|
|
115
184
|
},
|
|
116
185
|
|
|
186
|
+
getRuntime: () => resident.runtimeInfo(context),
|
|
187
|
+
metadata: (name) => resident.metadata(context, name),
|
|
188
|
+
|
|
117
189
|
// Job information
|
|
118
190
|
jobs: Array.from(jobs.values()),
|
|
119
191
|
|
|
@@ -127,14 +199,19 @@ module.exports = function defineQuestHook(sails) {
|
|
|
127
199
|
resume: (name) => jobControl.resumeJob(name, jobs)
|
|
128
200
|
}
|
|
129
201
|
|
|
202
|
+
sails.quest = questApi
|
|
130
203
|
sails.log.info(`Quest started with ${jobs.size} scheduled job(s)`)
|
|
131
|
-
}
|
|
204
|
+
}
|
|
132
205
|
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
206
|
+
// Sails waits for this Promise before marking Quest loaded. ORM initializes
|
|
207
|
+
// independently; after() also handles an ORM event that already occurred.
|
|
208
|
+
await new Promise((resolve, reject) => {
|
|
209
|
+
sails.after('hook:orm:loaded', () => {
|
|
210
|
+
initializeAfterOrm().then(resolve, reject)
|
|
211
|
+
})
|
|
137
212
|
})
|
|
138
213
|
}
|
|
139
214
|
}
|
|
140
215
|
}
|
|
216
|
+
|
|
217
|
+
module.exports = defineQuestHook
|