sails-hook-quest 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/jsconfig.json +17 -0
- package/lib/core/executor.js +111 -22
- package/lib/core/global-sails.js +16 -0
- package/lib/core/job-control.js +81 -25
- package/lib/core/loader.js +52 -26
- package/lib/core/scheduler.js +16 -6
- package/lib/index.js +44 -8
- package/lib/types.js +231 -0
- package/package.json +7 -2
- package/test/executor.test.js +95 -0
- package/typecheck/public-api-smoke.js +84 -0
- package/sails-hook-quest-0.0.0.tgz +0 -0
package/README.md
CHANGED
|
@@ -79,6 +79,9 @@ module.exports.quest = {
|
|
|
79
79
|
// Run jobs in console environment (minimal Sails lift)
|
|
80
80
|
environment: 'console',
|
|
81
81
|
|
|
82
|
+
// Retain the final 64 KiB of child output when a job fails
|
|
83
|
+
diagnosticTailBytes: 64 * 1024,
|
|
84
|
+
|
|
82
85
|
// Define additional jobs in config
|
|
83
86
|
jobs: [
|
|
84
87
|
{
|
|
@@ -215,10 +218,20 @@ Each event includes:
|
|
|
215
218
|
inputs: { /* job inputs */ },
|
|
216
219
|
timestamp: Date,
|
|
217
220
|
duration: 1234, // milliseconds (complete/error only)
|
|
218
|
-
error: {
|
|
221
|
+
error: {
|
|
222
|
+
message: 'Job "job-name" exited with code 1',
|
|
223
|
+
code: 1,
|
|
224
|
+
stack: 'Error: Job "job-name" exited with code 1\\n...',
|
|
225
|
+
diagnostic: 'The final bounded section of the child process output'
|
|
226
|
+
}
|
|
219
227
|
}
|
|
220
228
|
```
|
|
221
229
|
|
|
230
|
+
Job output still streams live to the parent process. On failure, Quest also
|
|
231
|
+
includes a bounded diagnostic tail and a parent stack in the error event so
|
|
232
|
+
observability tools can preserve the useful failure context without buffering
|
|
233
|
+
the complete job log.
|
|
234
|
+
|
|
222
235
|
## Console Environment
|
|
223
236
|
|
|
224
237
|
Quest can run jobs in a minimal 'console' environment that skips unnecessary hooks for better performance:
|
package/jsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"allowJs": true,
|
|
4
|
+
"checkJs": true,
|
|
5
|
+
"noEmit": true,
|
|
6
|
+
"target": "ES2022",
|
|
7
|
+
"module": "NodeNext",
|
|
8
|
+
"moduleResolution": "NodeNext",
|
|
9
|
+
"lib": ["ES2022"],
|
|
10
|
+
"types": ["node"],
|
|
11
|
+
"maxNodeModuleJsDepth": 0,
|
|
12
|
+
"strict": false,
|
|
13
|
+
"skipLibCheck": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["lib/**/*.js", "typecheck/**/*.js"],
|
|
16
|
+
"exclude": ["node_modules"]
|
|
17
|
+
}
|
package/lib/core/executor.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* core/executor.js
|
|
3
5
|
*
|
|
@@ -7,21 +9,30 @@
|
|
|
7
9
|
const { spawn } = require('child_process')
|
|
8
10
|
const fs = require('fs')
|
|
9
11
|
const path = require('path')
|
|
12
|
+
const { getGlobalSails } = require('./global-sails')
|
|
13
|
+
|
|
14
|
+
const DEFAULT_DIAGNOSTIC_TAIL_BYTES = 64 * 1024
|
|
15
|
+
|
|
16
|
+
/** @typedef {import('../types').AnyRecord} AnyRecord */
|
|
17
|
+
/** @typedef {import('../types').QuestExecutableJob} QuestExecutableJob */
|
|
18
|
+
/** @typedef {import('../types').QuestExecutionResult} QuestExecutionResult */
|
|
19
|
+
/** @typedef {import('../types').QuestExecutorContext} QuestExecutorContext */
|
|
10
20
|
|
|
11
21
|
/**
|
|
12
22
|
* Execute a job via `sails run`
|
|
13
23
|
* @param {String} name - Job name
|
|
14
|
-
* @param {
|
|
15
|
-
* @param {
|
|
16
|
-
* @param {
|
|
17
|
-
* @returns {Promise} Resolves when job completes
|
|
24
|
+
* @param {QuestExecutableJob} job - Job configuration
|
|
25
|
+
* @param {AnyRecord} [customInputs] - Custom input values
|
|
26
|
+
* @param {QuestExecutorContext} [context] - Execution context with running map, config, etc
|
|
27
|
+
* @returns {Promise<QuestExecutionResult>} Resolves when job completes
|
|
18
28
|
*/
|
|
19
29
|
async function executeJob(name, job, customInputs = {}, context = {}) {
|
|
20
30
|
const { running = new Map(), config = {} } = context
|
|
21
31
|
|
|
22
32
|
// Check if job is already running (and overlapping is disabled)
|
|
23
33
|
if (job.withoutOverlapping && running.has(name)) {
|
|
24
|
-
|
|
34
|
+
const sails = getGlobalSails()
|
|
35
|
+
if (sails) {
|
|
25
36
|
sails.log.warn(`Job "${name}" is already running, skipping...`)
|
|
26
37
|
}
|
|
27
38
|
return { skipped: true, reason: 'already_running' }
|
|
@@ -29,23 +40,29 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
|
|
|
29
40
|
|
|
30
41
|
// Don't run if paused
|
|
31
42
|
if (job.paused) {
|
|
32
|
-
|
|
43
|
+
const sails = getGlobalSails()
|
|
44
|
+
if (sails) {
|
|
33
45
|
sails.log.verbose(`Job "${name}" is paused, skipping...`)
|
|
34
46
|
}
|
|
35
47
|
return { skipped: true, reason: 'paused' }
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
|
|
50
|
+
const sails = getGlobalSails()
|
|
51
|
+
if (sails) {
|
|
39
52
|
sails.log.info(`Running job: ${name}`)
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
running.set(name, Date.now())
|
|
43
56
|
|
|
44
57
|
// Merge inputs with priority: jobInputs < scriptInputs < customInputs
|
|
45
|
-
const inputs = {
|
|
58
|
+
const inputs = {
|
|
59
|
+
...(job.inputs || {}),
|
|
60
|
+
...(job.scriptInputs || {}),
|
|
61
|
+
...customInputs
|
|
62
|
+
}
|
|
46
63
|
|
|
47
64
|
// Emit job start event
|
|
48
|
-
if (
|
|
65
|
+
if (sails) {
|
|
49
66
|
sails.emit('quest:job:start', {
|
|
50
67
|
name,
|
|
51
68
|
inputs,
|
|
@@ -74,12 +91,12 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
|
|
|
74
91
|
const error = new Error(
|
|
75
92
|
`Job "${name}" not found. Please check that the script exists at ${scriptsDir}/${name}.js`
|
|
76
93
|
)
|
|
77
|
-
if (
|
|
94
|
+
if (sails) {
|
|
78
95
|
sails.log.error(error.message)
|
|
79
96
|
sails.emit('quest:job:error', {
|
|
80
97
|
name,
|
|
81
98
|
inputs,
|
|
82
|
-
error: { message: error.message },
|
|
99
|
+
error: { message: error.message, stack: error.stack },
|
|
83
100
|
duration: 0,
|
|
84
101
|
timestamp: new Date()
|
|
85
102
|
})
|
|
@@ -87,19 +104,37 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
|
|
|
87
104
|
return reject(error)
|
|
88
105
|
}
|
|
89
106
|
|
|
107
|
+
const diagnosticTail = createDiagnosticTail(config.diagnosticTailBytes)
|
|
90
108
|
const child = spawn(sailsPath, args, {
|
|
91
109
|
cwd,
|
|
92
110
|
env,
|
|
93
|
-
stdio: 'inherit'
|
|
111
|
+
stdio: ['inherit', 'pipe', 'pipe']
|
|
94
112
|
})
|
|
95
113
|
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
teeChildOutput(
|
|
115
|
+
child.stdout,
|
|
116
|
+
context.stdout || process.stdout,
|
|
117
|
+
diagnosticTail
|
|
118
|
+
)
|
|
119
|
+
teeChildOutput(
|
|
120
|
+
child.stderr,
|
|
121
|
+
context.stderr || process.stderr,
|
|
122
|
+
diagnosticTail
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
let settled = false
|
|
126
|
+
|
|
127
|
+
child.on('close', (code) => {
|
|
128
|
+
if (settled) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
settled = true
|
|
132
|
+
const startTime = /** @type {number} */ (running.get(name))
|
|
98
133
|
const duration = Date.now() - startTime
|
|
99
134
|
running.delete(name)
|
|
100
135
|
|
|
101
136
|
if (code === 0) {
|
|
102
|
-
if (
|
|
137
|
+
if (sails) {
|
|
103
138
|
sails.log.info(`Job "${name}" completed successfully`)
|
|
104
139
|
|
|
105
140
|
// Emit success event
|
|
@@ -115,7 +150,7 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
|
|
|
115
150
|
} else {
|
|
116
151
|
const error = new Error(`Job "${name}" exited with code ${code}`)
|
|
117
152
|
|
|
118
|
-
if (
|
|
153
|
+
if (sails) {
|
|
119
154
|
sails.log.error(error)
|
|
120
155
|
|
|
121
156
|
// Emit error event
|
|
@@ -124,7 +159,9 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
|
|
|
124
159
|
inputs,
|
|
125
160
|
error: {
|
|
126
161
|
message: error.message,
|
|
127
|
-
code
|
|
162
|
+
code,
|
|
163
|
+
stack: error.stack,
|
|
164
|
+
diagnostic: diagnosticTail.value()
|
|
128
165
|
},
|
|
129
166
|
duration,
|
|
130
167
|
timestamp: new Date()
|
|
@@ -136,11 +173,15 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
|
|
|
136
173
|
})
|
|
137
174
|
|
|
138
175
|
child.on('error', (err) => {
|
|
176
|
+
if (settled) {
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
settled = true
|
|
139
180
|
const startTime = running.get(name) || Date.now()
|
|
140
181
|
const duration = Date.now() - startTime
|
|
141
182
|
running.delete(name)
|
|
142
183
|
|
|
143
|
-
if (
|
|
184
|
+
if (sails) {
|
|
144
185
|
sails.log.error(`Job "${name}" failed to start:`, err)
|
|
145
186
|
|
|
146
187
|
// Emit error event
|
|
@@ -149,7 +190,8 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
|
|
|
149
190
|
inputs,
|
|
150
191
|
error: {
|
|
151
192
|
message: err.message,
|
|
152
|
-
stack: err.stack
|
|
193
|
+
stack: err.stack,
|
|
194
|
+
diagnostic: diagnosticTail.value()
|
|
153
195
|
},
|
|
154
196
|
duration,
|
|
155
197
|
timestamp: new Date()
|
|
@@ -161,11 +203,57 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
|
|
|
161
203
|
})
|
|
162
204
|
}
|
|
163
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Retain only the end of child output so a failed job can explain itself
|
|
208
|
+
* without buffering an unbounded process log.
|
|
209
|
+
*
|
|
210
|
+
* @param {number} [requestedMaxBytes]
|
|
211
|
+
*/
|
|
212
|
+
function createDiagnosticTail(requestedMaxBytes) {
|
|
213
|
+
const maxBytes =
|
|
214
|
+
Number.isFinite(requestedMaxBytes) && requestedMaxBytes > 0
|
|
215
|
+
? Math.floor(requestedMaxBytes)
|
|
216
|
+
: DEFAULT_DIAGNOSTIC_TAIL_BYTES
|
|
217
|
+
let buffer = Buffer.alloc(0)
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
/** @param {Buffer|string} chunk */
|
|
221
|
+
append(chunk) {
|
|
222
|
+
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
223
|
+
buffer = Buffer.concat([buffer, incoming])
|
|
224
|
+
if (buffer.length > maxBytes) {
|
|
225
|
+
buffer = buffer.subarray(buffer.length - maxBytes)
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
value() {
|
|
229
|
+
return buffer.toString('utf8').trim()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Mirror child output to the parent while retaining a bounded diagnostic tail.
|
|
236
|
+
*
|
|
237
|
+
* @param {NodeJS.ReadableStream|null} source
|
|
238
|
+
* @param {NodeJS.WritableStream} destination
|
|
239
|
+
* @param {ReturnType<typeof createDiagnosticTail>} diagnosticTail
|
|
240
|
+
*/
|
|
241
|
+
function teeChildOutput(source, destination, diagnosticTail) {
|
|
242
|
+
if (!source) {
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
source.on('data', (chunk) => {
|
|
247
|
+
diagnosticTail.append(chunk)
|
|
248
|
+
destination.write(chunk)
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
164
252
|
/**
|
|
165
253
|
* Build command arguments for sails run
|
|
166
254
|
* @param {String} scriptName - Name of the script
|
|
167
|
-
* @param {
|
|
168
|
-
* @returns {
|
|
255
|
+
* @param {AnyRecord} [inputs] - Input values
|
|
256
|
+
* @returns {string[]} Command arguments
|
|
169
257
|
*/
|
|
170
258
|
function buildCommandArgs(scriptName, inputs = {}) {
|
|
171
259
|
const args = ['run', scriptName]
|
|
@@ -186,5 +274,6 @@ function buildCommandArgs(scriptName, inputs = {}) {
|
|
|
186
274
|
|
|
187
275
|
module.exports = {
|
|
188
276
|
executeJob,
|
|
189
|
-
buildCommandArgs
|
|
277
|
+
buildCommandArgs,
|
|
278
|
+
createDiagnosticTail
|
|
190
279
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @typedef {import('../types').QuestSailsApp} QuestSailsApp */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the ambient Sails app when core modules run outside the hook factory.
|
|
7
|
+
*
|
|
8
|
+
* @returns {QuestSailsApp | undefined}
|
|
9
|
+
*/
|
|
10
|
+
function getGlobalSails() {
|
|
11
|
+
return /** @type {{ sails?: QuestSailsApp }} */ (global).sails
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
getGlobalSails
|
|
16
|
+
}
|
package/lib/core/job-control.js
CHANGED
|
@@ -1,25 +1,46 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* core/job-control.js
|
|
3
5
|
*
|
|
4
6
|
* Functions for controlling job lifecycle (start, stop, pause, resume)
|
|
5
7
|
*/
|
|
6
8
|
|
|
9
|
+
const { getGlobalSails } = require('./global-sails')
|
|
10
|
+
|
|
11
|
+
/** @typedef {import('../types').AnyRecord} AnyRecord */
|
|
12
|
+
/** @typedef {import('../types').QuestExecutionResult} QuestExecutionResult */
|
|
13
|
+
/** @typedef {import('../types').QuestJobNameInput} QuestJobNameInput */
|
|
14
|
+
/** @typedef {import('../types').QuestJobsMap} QuestJobsMap */
|
|
15
|
+
/** @typedef {import('../types').QuestRunContext} QuestRunContext */
|
|
16
|
+
/** @typedef {import('../types').QuestScheduleContext} QuestScheduleContext */
|
|
17
|
+
/** @typedef {import('../types').QuestStartContext} QuestStartContext */
|
|
18
|
+
/** @typedef {import('../types').QuestStopContext} QuestStopContext */
|
|
19
|
+
|
|
20
|
+
// setTimeout uses a 32-bit signed integer internally.
|
|
21
|
+
// Delays larger than this overflow and fire immediately (~1ms), causing infinite loops.
|
|
22
|
+
const MAX_SAFE_TIMEOUT = 2_147_483_647 // 2^31 - 1, ~24.8 days
|
|
23
|
+
|
|
7
24
|
/**
|
|
8
25
|
* Schedule a job to run at its next scheduled time
|
|
9
26
|
* @param {String} name - Job name
|
|
10
|
-
* @param {
|
|
27
|
+
* @param {QuestScheduleContext} [context] - Context with jobs, timers maps and helper functions
|
|
11
28
|
*/
|
|
12
29
|
function scheduleJob(name, context = {}) {
|
|
13
|
-
const {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
30
|
+
const { jobs = new Map(), timers = new Map() } = context
|
|
31
|
+
const getNextRunTime =
|
|
32
|
+
/** @type {NonNullable<QuestScheduleContext['getNextRunTime']>} */ (
|
|
33
|
+
context.getNextRunTime
|
|
34
|
+
)
|
|
35
|
+
const executeJob =
|
|
36
|
+
/** @type {NonNullable<QuestScheduleContext['executeJob']>} */ (
|
|
37
|
+
context.executeJob
|
|
38
|
+
)
|
|
19
39
|
|
|
20
40
|
const job = jobs.get(name)
|
|
21
41
|
if (!job) {
|
|
22
|
-
|
|
42
|
+
const sails = getGlobalSails()
|
|
43
|
+
if (sails) {
|
|
23
44
|
sails.log.warn(`Job "${name}" not found in jobs Map`)
|
|
24
45
|
}
|
|
25
46
|
return
|
|
@@ -31,7 +52,8 @@ function scheduleJob(name, context = {}) {
|
|
|
31
52
|
// Get the next run time
|
|
32
53
|
const nextRun = getNextRunTime(job)
|
|
33
54
|
if (!nextRun) {
|
|
34
|
-
|
|
55
|
+
const sails = getGlobalSails()
|
|
56
|
+
if (sails) {
|
|
35
57
|
sails.log.warn(
|
|
36
58
|
`Job "${name}" has no valid schedule (interval: ${job.interval}, cron: ${job.cron}, timeout: ${job.timeout})`
|
|
37
59
|
)
|
|
@@ -44,7 +66,8 @@ function scheduleJob(name, context = {}) {
|
|
|
44
66
|
// If delay is negative (past time), run immediately
|
|
45
67
|
if (delay <= 0) {
|
|
46
68
|
executeJob(name).catch((err) => {
|
|
47
|
-
|
|
69
|
+
const sails = getGlobalSails()
|
|
70
|
+
if (sails) {
|
|
48
71
|
sails.log.error(`Error running job "${name}":`, err)
|
|
49
72
|
}
|
|
50
73
|
})
|
|
@@ -56,10 +79,29 @@ function scheduleJob(name, context = {}) {
|
|
|
56
79
|
return
|
|
57
80
|
}
|
|
58
81
|
|
|
82
|
+
// If delay exceeds the 32-bit setTimeout max, set an intermediate timer
|
|
83
|
+
// that rechecks when we're closer to the target time
|
|
84
|
+
if (delay > MAX_SAFE_TIMEOUT) {
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
scheduleJob(name, context)
|
|
87
|
+
}, MAX_SAFE_TIMEOUT)
|
|
88
|
+
|
|
89
|
+
timers.set(name, timer)
|
|
90
|
+
|
|
91
|
+
const sails = getGlobalSails()
|
|
92
|
+
if (sails) {
|
|
93
|
+
sails.log.verbose(
|
|
94
|
+
`Job "${name}" scheduled for ${nextRun.toISOString()} (delay exceeds 24.8d, re-checking later)`
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
59
100
|
// Set timer for the next execution
|
|
60
101
|
const timer = setTimeout(() => {
|
|
61
102
|
executeJob(name).catch((err) => {
|
|
62
|
-
|
|
103
|
+
const sails = getGlobalSails()
|
|
104
|
+
if (sails) {
|
|
63
105
|
sails.log.error(`Error running job "${name}":`, err)
|
|
64
106
|
}
|
|
65
107
|
})
|
|
@@ -72,7 +114,8 @@ function scheduleJob(name, context = {}) {
|
|
|
72
114
|
|
|
73
115
|
timers.set(name, timer)
|
|
74
116
|
|
|
75
|
-
|
|
117
|
+
const sails = getGlobalSails()
|
|
118
|
+
if (sails) {
|
|
76
119
|
sails.log.verbose(`Job "${name}" scheduled for ${nextRun.toISOString()}`)
|
|
77
120
|
}
|
|
78
121
|
}
|
|
@@ -80,7 +123,7 @@ function scheduleJob(name, context = {}) {
|
|
|
80
123
|
/**
|
|
81
124
|
* Stop a single job
|
|
82
125
|
* @param {String} name - Job name
|
|
83
|
-
* @param {
|
|
126
|
+
* @param {QuestStopContext} [context] - Context with timers map
|
|
84
127
|
*/
|
|
85
128
|
function stopJob(name, context = {}) {
|
|
86
129
|
const { timers = new Map() } = context
|
|
@@ -90,7 +133,8 @@ function stopJob(name, context = {}) {
|
|
|
90
133
|
clearTimeout(timer)
|
|
91
134
|
clearInterval(timer)
|
|
92
135
|
timers.delete(name)
|
|
93
|
-
|
|
136
|
+
const sails = getGlobalSails()
|
|
137
|
+
if (sails) {
|
|
94
138
|
sails.log.verbose(`Job "${name}" stopped`)
|
|
95
139
|
}
|
|
96
140
|
}
|
|
@@ -98,11 +142,16 @@ function stopJob(name, context = {}) {
|
|
|
98
142
|
|
|
99
143
|
/**
|
|
100
144
|
* Start scheduling jobs
|
|
101
|
-
* @param {
|
|
102
|
-
* @param {
|
|
145
|
+
* @param {QuestJobNameInput} jobNames - Job names to start (optional)
|
|
146
|
+
* @param {QuestStartContext} [context] - Context with jobs map and scheduleJob function
|
|
147
|
+
* @returns {Promise<void>}
|
|
103
148
|
*/
|
|
104
149
|
async function startJobs(jobNames, context = {}) {
|
|
105
|
-
const { jobs = new Map()
|
|
150
|
+
const { jobs = new Map() } = context
|
|
151
|
+
const scheduleJob =
|
|
152
|
+
/** @type {NonNullable<QuestStartContext['scheduleJob']>} */ (
|
|
153
|
+
context.scheduleJob
|
|
154
|
+
)
|
|
106
155
|
|
|
107
156
|
const names = !jobNames
|
|
108
157
|
? Array.from(jobs.keys())
|
|
@@ -117,8 +166,9 @@ async function startJobs(jobNames, context = {}) {
|
|
|
117
166
|
|
|
118
167
|
/**
|
|
119
168
|
* Stop scheduling jobs
|
|
120
|
-
* @param {
|
|
121
|
-
* @param {
|
|
169
|
+
* @param {QuestJobNameInput} jobNames - Job names to stop (optional)
|
|
170
|
+
* @param {QuestStopContext} [context] - Context with jobs and timers maps
|
|
171
|
+
* @returns {void}
|
|
122
172
|
*/
|
|
123
173
|
function stopJobs(jobNames, context = {}) {
|
|
124
174
|
const { jobs = new Map(), timers = new Map() } = context
|
|
@@ -136,12 +186,16 @@ function stopJobs(jobNames, context = {}) {
|
|
|
136
186
|
|
|
137
187
|
/**
|
|
138
188
|
* Run jobs immediately
|
|
139
|
-
* @param {
|
|
140
|
-
* @param {
|
|
141
|
-
* @param {
|
|
189
|
+
* @param {QuestJobNameInput} jobNames - Job names to run
|
|
190
|
+
* @param {AnyRecord} [inputs] - Custom inputs
|
|
191
|
+
* @param {QuestRunContext} [context] - Context with executeJob function
|
|
192
|
+
* @returns {Promise<QuestExecutionResult[]>}
|
|
142
193
|
*/
|
|
143
194
|
async function runJobs(jobNames, inputs, context = {}) {
|
|
144
|
-
const { jobs = new Map()
|
|
195
|
+
const { jobs = new Map() } = context
|
|
196
|
+
const executeJob = /** @type {NonNullable<QuestRunContext['executeJob']>} */ (
|
|
197
|
+
context.executeJob
|
|
198
|
+
)
|
|
145
199
|
|
|
146
200
|
const names = !jobNames
|
|
147
201
|
? Array.from(jobs.keys())
|
|
@@ -156,7 +210,8 @@ async function runJobs(jobNames, inputs, context = {}) {
|
|
|
156
210
|
/**
|
|
157
211
|
* Pause a job
|
|
158
212
|
* @param {String} name - Job name
|
|
159
|
-
* @param {
|
|
213
|
+
* @param {QuestJobsMap} [jobs] - Jobs map
|
|
214
|
+
* @returns {boolean}
|
|
160
215
|
*/
|
|
161
216
|
function pauseJob(name, jobs = new Map()) {
|
|
162
217
|
const job = jobs.get(name)
|
|
@@ -170,7 +225,8 @@ function pauseJob(name, jobs = new Map()) {
|
|
|
170
225
|
/**
|
|
171
226
|
* Resume a job
|
|
172
227
|
* @param {String} name - Job name
|
|
173
|
-
* @param {
|
|
228
|
+
* @param {QuestJobsMap} [jobs] - Jobs map
|
|
229
|
+
* @returns {boolean}
|
|
174
230
|
*/
|
|
175
231
|
function resumeJob(name, jobs = new Map()) {
|
|
176
232
|
const job = jobs.get(name)
|
package/lib/core/loader.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* core/loader.js
|
|
3
5
|
*
|
|
@@ -6,13 +8,24 @@
|
|
|
6
8
|
|
|
7
9
|
const path = require('path')
|
|
8
10
|
const includeAll = require('include-all')
|
|
11
|
+
const { getGlobalSails } = require('./global-sails')
|
|
12
|
+
|
|
13
|
+
/** @typedef {import('../types').AnyRecord} AnyRecord */
|
|
14
|
+
/** @typedef {import('../types').QuestConfig} QuestConfig */
|
|
15
|
+
/** @typedef {import('../types').QuestJob} QuestJob */
|
|
16
|
+
/** @typedef {import('../types').QuestJobDefinition} QuestJobDefinition */
|
|
17
|
+
/** @typedef {import('../types').QuestJobsMap} QuestJobsMap */
|
|
18
|
+
/** @typedef {import('../types').QuestMachineInputs} QuestMachineInputs */
|
|
19
|
+
/** @typedef {import('../types').QuestScriptDefinition} QuestScriptDefinition */
|
|
20
|
+
/** @typedef {import('../types').QuestTimersMap} QuestTimersMap */
|
|
9
21
|
|
|
10
22
|
/**
|
|
11
23
|
* Extract default values from a script's inputs schema
|
|
12
|
-
* @param {
|
|
13
|
-
* @returns {
|
|
24
|
+
* @param {QuestMachineInputs | null | undefined} inputs - Script's inputs definition (Sails machine format)
|
|
25
|
+
* @returns {AnyRecord} Object with input names and their defaultsTo values
|
|
14
26
|
*/
|
|
15
27
|
function extractScriptInputDefaults(inputs) {
|
|
28
|
+
/** @type {AnyRecord} */
|
|
16
29
|
const defaults = {}
|
|
17
30
|
if (!inputs || typeof inputs !== 'object') return defaults
|
|
18
31
|
|
|
@@ -26,9 +39,9 @@ function extractScriptInputDefaults(inputs) {
|
|
|
26
39
|
|
|
27
40
|
/**
|
|
28
41
|
* Load jobs from scripts directory and config
|
|
29
|
-
* @param {
|
|
30
|
-
* @param {
|
|
31
|
-
* @returns {Promise<
|
|
42
|
+
* @param {QuestConfig} config - Quest configuration
|
|
43
|
+
* @param {QuestJobsMap} [jobs] - Jobs map to populate
|
|
44
|
+
* @returns {Promise<QuestJobsMap>} Populated jobs map
|
|
32
45
|
*/
|
|
33
46
|
async function loadJobs(config, jobs = new Map()) {
|
|
34
47
|
// First, load scripts from the scripts directory
|
|
@@ -36,17 +49,21 @@ async function loadJobs(config, jobs = new Map()) {
|
|
|
36
49
|
const appPath = config.appPath || process.cwd()
|
|
37
50
|
const fullPath = path.resolve(appPath, scriptsDir)
|
|
38
51
|
|
|
52
|
+
/** @type {Record<string, QuestScriptDefinition>} */
|
|
39
53
|
let scripts = {}
|
|
40
54
|
|
|
41
55
|
try {
|
|
42
|
-
scripts =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
scripts = /** @type {Record<string, QuestScriptDefinition>} */ (
|
|
57
|
+
includeAll({
|
|
58
|
+
dirname: fullPath,
|
|
59
|
+
filter: /(.+)\.js$/,
|
|
60
|
+
excludeDirs: /^\.(git|svn)$/,
|
|
61
|
+
flatten: true
|
|
62
|
+
})
|
|
63
|
+
)
|
|
48
64
|
} catch (e) {
|
|
49
|
-
|
|
65
|
+
const sails = getGlobalSails()
|
|
66
|
+
if (sails) {
|
|
50
67
|
sails.log.verbose('No scripts directory found, skipping script jobs')
|
|
51
68
|
}
|
|
52
69
|
}
|
|
@@ -57,18 +74,22 @@ async function loadJobs(config, jobs = new Map()) {
|
|
|
57
74
|
|
|
58
75
|
for (const jobDef of config.jobs) {
|
|
59
76
|
// Handle string shorthand (just job name)
|
|
77
|
+
/** @type {QuestJobDefinition} */
|
|
60
78
|
const job = typeof jobDef === 'string' ? { name: jobDef } : jobDef
|
|
79
|
+
const jobName = job.name
|
|
61
80
|
|
|
62
81
|
// Check for duplicate job names in config
|
|
63
|
-
if (configJobNames.has(
|
|
82
|
+
if (jobName && configJobNames.has(jobName)) {
|
|
64
83
|
throw new Error(
|
|
65
|
-
`Duplicate job name "${
|
|
84
|
+
`Duplicate job name "${jobName}" in config/quest.js. Each job must have a unique name.`
|
|
66
85
|
)
|
|
67
86
|
}
|
|
68
|
-
|
|
87
|
+
if (jobName) {
|
|
88
|
+
configJobNames.add(jobName)
|
|
89
|
+
}
|
|
69
90
|
|
|
70
91
|
// Try to get script inputs if the script exists
|
|
71
|
-
const scriptDef = scripts[
|
|
92
|
+
const scriptDef = jobName ? scripts[jobName] : undefined
|
|
72
93
|
if (scriptDef && !job.scriptInputs) {
|
|
73
94
|
job.scriptInputs = extractScriptInputDefaults(scriptDef.inputs)
|
|
74
95
|
}
|
|
@@ -92,13 +113,14 @@ async function loadJobs(config, jobs = new Map()) {
|
|
|
92
113
|
const existingJob = jobs.get(jobName)
|
|
93
114
|
|
|
94
115
|
// Merge: config as base, script quest config takes priority
|
|
116
|
+
/** @type {QuestJobDefinition} */
|
|
95
117
|
const jobDef = {
|
|
96
118
|
name: jobName,
|
|
97
119
|
friendlyName: scriptDef.friendlyName,
|
|
98
120
|
description: scriptDef.description,
|
|
99
121
|
// Preserve config's withoutOverlapping if script doesn't specify
|
|
100
122
|
withoutOverlapping: existingJob?.withoutOverlapping,
|
|
101
|
-
inputs: { ...existingJob?.inputs, ...questConfig.inputs },
|
|
123
|
+
inputs: { ...(existingJob?.inputs || {}), ...(questConfig.inputs || {}) },
|
|
102
124
|
...questConfig,
|
|
103
125
|
scriptInputs
|
|
104
126
|
}
|
|
@@ -111,21 +133,23 @@ async function loadJobs(config, jobs = new Map()) {
|
|
|
111
133
|
|
|
112
134
|
/**
|
|
113
135
|
* Add a job definition to the jobs Map
|
|
114
|
-
* @param {
|
|
115
|
-
* @param {
|
|
116
|
-
* @param {
|
|
117
|
-
* @returns {
|
|
136
|
+
* @param {QuestJobDefinition} jobDef - Job definition
|
|
137
|
+
* @param {QuestJobsMap} [jobs] - Jobs map
|
|
138
|
+
* @param {QuestConfig} [config] - Quest configuration
|
|
139
|
+
* @returns {QuestJob | null} Normalized job
|
|
118
140
|
*/
|
|
119
141
|
function addJobDefinition(jobDef, jobs = new Map(), config = {}) {
|
|
120
142
|
const name = jobDef.name
|
|
121
143
|
if (!name) {
|
|
122
|
-
|
|
144
|
+
const sails = getGlobalSails()
|
|
145
|
+
if (sails) {
|
|
123
146
|
sails.log.warn('Job definition missing name, skipping:', jobDef)
|
|
124
147
|
}
|
|
125
148
|
return null
|
|
126
149
|
}
|
|
127
150
|
|
|
128
151
|
// Parse and normalize the job definition
|
|
152
|
+
/** @type {QuestJob} */
|
|
129
153
|
const job = {
|
|
130
154
|
name,
|
|
131
155
|
friendlyName: jobDef.friendlyName || name,
|
|
@@ -151,7 +175,8 @@ function addJobDefinition(jobDef, jobs = new Map(), config = {}) {
|
|
|
151
175
|
|
|
152
176
|
jobs.set(name, job)
|
|
153
177
|
|
|
154
|
-
|
|
178
|
+
const sails = getGlobalSails()
|
|
179
|
+
if (sails) {
|
|
155
180
|
const schedule = {}
|
|
156
181
|
if (job.interval !== undefined) schedule.interval = job.interval
|
|
157
182
|
if (job.cron !== undefined) schedule.cron = job.cron
|
|
@@ -165,8 +190,8 @@ function addJobDefinition(jobDef, jobs = new Map(), config = {}) {
|
|
|
165
190
|
/**
|
|
166
191
|
* Remove a job from the jobs map
|
|
167
192
|
* @param {String} name - Job name
|
|
168
|
-
* @param {
|
|
169
|
-
* @param {
|
|
193
|
+
* @param {QuestJobsMap} [jobs] - Jobs map
|
|
194
|
+
* @param {QuestTimersMap} [timers] - Timers map
|
|
170
195
|
* @returns {Boolean} Success
|
|
171
196
|
*/
|
|
172
197
|
function removeJob(name, jobs = new Map(), timers = new Map()) {
|
|
@@ -181,7 +206,8 @@ function removeJob(name, jobs = new Map(), timers = new Map()) {
|
|
|
181
206
|
// Remove from jobs map
|
|
182
207
|
const existed = jobs.delete(name)
|
|
183
208
|
|
|
184
|
-
|
|
209
|
+
const sails = getGlobalSails()
|
|
210
|
+
if (existed && sails) {
|
|
185
211
|
sails.log.verbose(`Job "${name}" removed`)
|
|
186
212
|
}
|
|
187
213
|
|
package/lib/core/scheduler.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* core/scheduler.js
|
|
3
5
|
*
|
|
@@ -7,11 +9,16 @@
|
|
|
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
|
|
15
22
|
* @returns {Date|null} Next run time or null if invalid
|
|
16
23
|
*/
|
|
17
24
|
function getNextRunTime(job, config = {}) {
|
|
@@ -36,10 +43,11 @@ function getNextRunTime(job, config = {}) {
|
|
|
36
43
|
const interval = CronExpressionParser.parse(job.cron, options)
|
|
37
44
|
return interval.next().toDate()
|
|
38
45
|
} catch (err) {
|
|
39
|
-
|
|
46
|
+
const sails = getGlobalSails()
|
|
47
|
+
if (sails) {
|
|
40
48
|
sails.log.error(
|
|
41
49
|
`Invalid cron expression for job "${job.name}": ${job.cron}`,
|
|
42
|
-
err.message
|
|
50
|
+
err instanceof Error ? err.message : err
|
|
43
51
|
)
|
|
44
52
|
}
|
|
45
53
|
return null
|
|
@@ -51,7 +59,8 @@ function getNextRunTime(job, config = {}) {
|
|
|
51
59
|
const nextTime = parseInterval(job.interval, now)
|
|
52
60
|
if (nextTime) return nextTime
|
|
53
61
|
|
|
54
|
-
|
|
62
|
+
const sails = getGlobalSails()
|
|
63
|
+
if (sails) {
|
|
55
64
|
sails.log.error(`Invalid interval for job "${job.name}": ${job.interval}`)
|
|
56
65
|
}
|
|
57
66
|
return null
|
|
@@ -95,6 +104,7 @@ function parseInterval(intervalStr, fromDate = new Date()) {
|
|
|
95
104
|
if (everyMatch) {
|
|
96
105
|
const amount = parseInt(everyMatch[1])
|
|
97
106
|
const unit = everyMatch[2].replace(/s$/, '') // Remove plural 's'
|
|
107
|
+
/** @type {Record<string, number>} */
|
|
98
108
|
const msMap = {
|
|
99
109
|
second: 1000,
|
|
100
110
|
minute: 60000,
|
|
@@ -138,7 +148,7 @@ function parseInterval(intervalStr, fromDate = new Date()) {
|
|
|
138
148
|
|
|
139
149
|
/**
|
|
140
150
|
* Parse a timeout value into a Date
|
|
141
|
-
* @param {
|
|
151
|
+
* @param {QuestTimeout} timeout - Timeout value
|
|
142
152
|
* @param {Date} fromDate - Calculate from this date
|
|
143
153
|
* @returns {Date|null} Next run time or null
|
|
144
154
|
*/
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* sails-hook-quest
|
|
3
5
|
*
|
|
@@ -10,12 +12,34 @@ const executor = require('./core/executor')
|
|
|
10
12
|
const loader = require('./core/loader')
|
|
11
13
|
const jobControl = require('./core/job-control')
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
/** @typedef {import('./types').AnyRecord} AnyRecord */
|
|
16
|
+
/** @typedef {import('./types').QuestApi} QuestApi */
|
|
17
|
+
/** @typedef {import('./types').QuestConfig} QuestConfig */
|
|
18
|
+
/** @typedef {import('./types').QuestContext} QuestContext */
|
|
19
|
+
/** @typedef {import('./types').QuestExecutableJob} QuestExecutableJob */
|
|
20
|
+
/** @typedef {import('./types').QuestJobsMap} QuestJobsMap */
|
|
21
|
+
/** @typedef {import('./types').QuestNamedJobDefinition} QuestNamedJobDefinition */
|
|
22
|
+
/** @typedef {import('./types').QuestRunningMap} QuestRunningMap */
|
|
23
|
+
/** @typedef {import('./types').QuestSailsApp} QuestSailsApp */
|
|
24
|
+
/** @typedef {import('./types').QuestSailsHook} QuestSailsHook */
|
|
25
|
+
/** @typedef {import('./types').QuestTimersMap} QuestTimersMap */
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sails hook factory.
|
|
29
|
+
*
|
|
30
|
+
* @param {QuestSailsApp} sails
|
|
31
|
+
* @returns {QuestSailsHook}
|
|
32
|
+
*/
|
|
33
|
+
function defineQuestHook(sails) {
|
|
34
|
+
/** @type {QuestJobsMap} */
|
|
14
35
|
const jobs = new Map()
|
|
36
|
+
/** @type {QuestTimersMap} */
|
|
15
37
|
const timers = new Map()
|
|
38
|
+
/** @type {QuestRunningMap} */
|
|
16
39
|
const running = new Map()
|
|
17
40
|
|
|
18
41
|
// Create context object that will be passed to modules
|
|
42
|
+
/** @type {QuestContext} */
|
|
19
43
|
const context = {
|
|
20
44
|
jobs,
|
|
21
45
|
timers,
|
|
@@ -47,6 +71,9 @@ module.exports = function defineQuestHook(sails) {
|
|
|
47
71
|
// Directory containing job scripts
|
|
48
72
|
scriptsDir: 'scripts',
|
|
49
73
|
|
|
74
|
+
// Maximum child output retained on failure (live output remains unbounded)
|
|
75
|
+
diagnosticTailBytes: 64 * 1024,
|
|
76
|
+
|
|
50
77
|
// Jobs defined in config
|
|
51
78
|
jobs: []
|
|
52
79
|
}
|
|
@@ -56,15 +83,18 @@ module.exports = function defineQuestHook(sails) {
|
|
|
56
83
|
sails.log.info('Initializing Quest job scheduler')
|
|
57
84
|
|
|
58
85
|
sails.after('hook:orm:loaded', async () => {
|
|
86
|
+
const questConfig = sails.config.quest
|
|
87
|
+
|
|
59
88
|
// Set up context with config
|
|
60
|
-
context.config =
|
|
89
|
+
context.config = questConfig
|
|
61
90
|
context.getNextRunTime = (job) =>
|
|
62
|
-
scheduler.getNextRunTime(job,
|
|
91
|
+
scheduler.getNextRunTime(job, questConfig)
|
|
63
92
|
context.scheduleJob = (name) => jobControl.scheduleJob(name, context)
|
|
64
93
|
context.executeJob = (name, customInputs) => {
|
|
65
94
|
const job = jobs.get(name)
|
|
66
95
|
if (!job) {
|
|
67
96
|
// Try to run as a regular script without quest config
|
|
97
|
+
/** @type {QuestExecutableJob} */
|
|
68
98
|
const minimalJob = {
|
|
69
99
|
name,
|
|
70
100
|
withoutOverlapping: false,
|
|
@@ -76,15 +106,16 @@ module.exports = function defineQuestHook(sails) {
|
|
|
76
106
|
}
|
|
77
107
|
|
|
78
108
|
// Load jobs from scripts and config
|
|
79
|
-
await loader.loadJobs(
|
|
109
|
+
await loader.loadJobs(questConfig, jobs)
|
|
80
110
|
|
|
81
111
|
// Start all jobs if autoStart is enabled
|
|
82
|
-
if (
|
|
112
|
+
if (questConfig.autoStart) {
|
|
83
113
|
await jobControl.startJobs(null, context)
|
|
84
114
|
}
|
|
85
115
|
|
|
86
116
|
// Expose the Quest API
|
|
87
|
-
|
|
117
|
+
/** @type {QuestApi} */
|
|
118
|
+
const questApi = {
|
|
88
119
|
// Core job control
|
|
89
120
|
start: (jobNames) => jobControl.startJobs(jobNames, context),
|
|
90
121
|
stop: (jobNames) => jobControl.stopJobs(jobNames, context),
|
|
@@ -92,12 +123,14 @@ module.exports = function defineQuestHook(sails) {
|
|
|
92
123
|
jobControl.runJobs(jobNames, inputs, context),
|
|
93
124
|
add: (jobDefs) => {
|
|
94
125
|
const defs = Array.isArray(jobDefs) ? jobDefs : [jobDefs]
|
|
126
|
+
/** @type {string[]} */
|
|
95
127
|
const added = []
|
|
96
128
|
for (const def of defs) {
|
|
129
|
+
/** @type {QuestNamedJobDefinition} */
|
|
97
130
|
const job = typeof def === 'string' ? { name: def } : def
|
|
98
|
-
loader.addJobDefinition(job, jobs, context.config)
|
|
131
|
+
loader.addJobDefinition(job, jobs, context.config || {})
|
|
99
132
|
added.push(job.name)
|
|
100
|
-
if (context.config
|
|
133
|
+
if (context.config?.autoStart) {
|
|
101
134
|
jobControl.scheduleJob(job.name, context)
|
|
102
135
|
}
|
|
103
136
|
}
|
|
@@ -127,6 +160,7 @@ module.exports = function defineQuestHook(sails) {
|
|
|
127
160
|
resume: (name) => jobControl.resumeJob(name, jobs)
|
|
128
161
|
}
|
|
129
162
|
|
|
163
|
+
sails.quest = questApi
|
|
130
164
|
sails.log.info(`Quest started with ${jobs.size} scheduled job(s)`)
|
|
131
165
|
})
|
|
132
166
|
|
|
@@ -138,3 +172,5 @@ module.exports = function defineQuestHook(sails) {
|
|
|
138
172
|
}
|
|
139
173
|
}
|
|
140
174
|
}
|
|
175
|
+
|
|
176
|
+
module.exports = defineQuestHook
|
package/lib/types.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared TypeScript-consumable JSDoc typedefs for Quest's public API.
|
|
3
|
+
*
|
|
4
|
+
* These comments are intentionally colocated with the JavaScript source so JSDoc
|
|
5
|
+
* stays the source of truth for editor autocomplete and local type checks.
|
|
6
|
+
*
|
|
7
|
+
* @typedef {Record<string, any>} AnyRecord
|
|
8
|
+
*
|
|
9
|
+
* @typedef {string | number | boolean | null} JsonPrimitive
|
|
10
|
+
*
|
|
11
|
+
* @typedef {JsonPrimitive | any[] | AnyRecord} JsonValue
|
|
12
|
+
*
|
|
13
|
+
* @typedef {string | number} QuestInterval
|
|
14
|
+
*
|
|
15
|
+
* @typedef {string | number | false} QuestTimeout
|
|
16
|
+
*
|
|
17
|
+
* @typedef {string | number | Date} QuestDateInput
|
|
18
|
+
*
|
|
19
|
+
* @typedef {Record<string, any>} QuestCronOptions
|
|
20
|
+
*
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* defaultsTo?: any,
|
|
23
|
+
* type?: string,
|
|
24
|
+
* description?: string,
|
|
25
|
+
* required?: boolean,
|
|
26
|
+
* allowNull?: boolean,
|
|
27
|
+
* [key: string]: any,
|
|
28
|
+
* }} QuestMachineInputDefinition
|
|
29
|
+
*
|
|
30
|
+
* @typedef {Record<string, QuestMachineInputDefinition>} QuestMachineInputs
|
|
31
|
+
*
|
|
32
|
+
* @typedef {{
|
|
33
|
+
* name?: string,
|
|
34
|
+
* friendlyName?: string,
|
|
35
|
+
* description?: string,
|
|
36
|
+
* interval?: QuestInterval,
|
|
37
|
+
* timeout?: QuestTimeout,
|
|
38
|
+
* cron?: string,
|
|
39
|
+
* cronOptions?: QuestCronOptions,
|
|
40
|
+
* date?: QuestDateInput,
|
|
41
|
+
* timezone?: string,
|
|
42
|
+
* inputs?: AnyRecord,
|
|
43
|
+
* scriptInputs?: AnyRecord,
|
|
44
|
+
* withoutOverlapping?: boolean,
|
|
45
|
+
* paused?: boolean,
|
|
46
|
+
* [key: string]: any,
|
|
47
|
+
* }} QuestJobDefinition
|
|
48
|
+
*
|
|
49
|
+
* @typedef {QuestJobDefinition & { name: string }} QuestNamedJobDefinition
|
|
50
|
+
*
|
|
51
|
+
* @typedef {string | QuestNamedJobDefinition} QuestJobDefinitionInput
|
|
52
|
+
*
|
|
53
|
+
* @typedef {{
|
|
54
|
+
* name: string,
|
|
55
|
+
* friendlyName: string,
|
|
56
|
+
* description?: string,
|
|
57
|
+
* interval?: QuestInterval,
|
|
58
|
+
* timeout?: QuestTimeout,
|
|
59
|
+
* cron?: string,
|
|
60
|
+
* cronOptions?: QuestCronOptions,
|
|
61
|
+
* date?: QuestDateInput,
|
|
62
|
+
* timezone?: string,
|
|
63
|
+
* inputs: AnyRecord,
|
|
64
|
+
* scriptInputs: AnyRecord,
|
|
65
|
+
* paused: boolean,
|
|
66
|
+
* withoutOverlapping: boolean,
|
|
67
|
+
* }} QuestJob
|
|
68
|
+
*
|
|
69
|
+
* @typedef {{
|
|
70
|
+
* name: string,
|
|
71
|
+
* inputs?: AnyRecord,
|
|
72
|
+
* scriptInputs?: AnyRecord,
|
|
73
|
+
* paused?: boolean,
|
|
74
|
+
* withoutOverlapping?: boolean,
|
|
75
|
+
* }} QuestExecutableJob
|
|
76
|
+
*
|
|
77
|
+
* @typedef {{
|
|
78
|
+
* friendlyName?: string,
|
|
79
|
+
* description?: string,
|
|
80
|
+
* quest?: QuestJobDefinition,
|
|
81
|
+
* inputs?: QuestMachineInputs,
|
|
82
|
+
* fn?: Function,
|
|
83
|
+
* [key: string]: any,
|
|
84
|
+
* }} QuestScriptDefinition
|
|
85
|
+
*
|
|
86
|
+
* @typedef {string | string[] | null | undefined} QuestJobNameInput
|
|
87
|
+
*
|
|
88
|
+
* @typedef {{
|
|
89
|
+
* autoStart?: boolean,
|
|
90
|
+
* timezone?: string,
|
|
91
|
+
* withoutOverlapping?: boolean,
|
|
92
|
+
* sailsPath?: string,
|
|
93
|
+
* environment?: string,
|
|
94
|
+
* scriptsDir?: string,
|
|
95
|
+
* appPath?: string,
|
|
96
|
+
* diagnosticTailBytes?: number,
|
|
97
|
+
* jobs?: QuestJobDefinitionInput[],
|
|
98
|
+
* [key: string]: any,
|
|
99
|
+
* }} QuestConfig
|
|
100
|
+
*
|
|
101
|
+
* @typedef {{
|
|
102
|
+
* skipped: true,
|
|
103
|
+
* reason: 'already_running' | 'paused',
|
|
104
|
+
* }} QuestSkippedResult
|
|
105
|
+
*
|
|
106
|
+
* @typedef {{
|
|
107
|
+
* success: true,
|
|
108
|
+
* duration: number,
|
|
109
|
+
* }} QuestSuccessResult
|
|
110
|
+
*
|
|
111
|
+
* @typedef {QuestSkippedResult | QuestSuccessResult} QuestExecutionResult
|
|
112
|
+
*
|
|
113
|
+
* @typedef {{
|
|
114
|
+
* name: string,
|
|
115
|
+
* inputs: AnyRecord,
|
|
116
|
+
* timestamp: Date,
|
|
117
|
+
* }} QuestJobStartEvent
|
|
118
|
+
*
|
|
119
|
+
* @typedef {{
|
|
120
|
+
* name: string,
|
|
121
|
+
* inputs: AnyRecord,
|
|
122
|
+
* duration: number,
|
|
123
|
+
* timestamp: Date,
|
|
124
|
+
* }} QuestJobCompleteEvent
|
|
125
|
+
*
|
|
126
|
+
* @typedef {{
|
|
127
|
+
* message: string,
|
|
128
|
+
* code?: number | null,
|
|
129
|
+
* stack?: string,
|
|
130
|
+
* diagnostic?: string,
|
|
131
|
+
* }} QuestJobErrorDetails
|
|
132
|
+
*
|
|
133
|
+
* @typedef {{
|
|
134
|
+
* name: string,
|
|
135
|
+
* inputs: AnyRecord,
|
|
136
|
+
* error: QuestJobErrorDetails,
|
|
137
|
+
* duration: number,
|
|
138
|
+
* timestamp: Date,
|
|
139
|
+
* }} QuestJobErrorEvent
|
|
140
|
+
*
|
|
141
|
+
* @typedef {{
|
|
142
|
+
* start(jobNames?: QuestJobNameInput): Promise<void>,
|
|
143
|
+
* stop(jobNames?: QuestJobNameInput): void,
|
|
144
|
+
* run(jobNames?: QuestJobNameInput, inputs?: AnyRecord): Promise<QuestExecutionResult[]>,
|
|
145
|
+
* add(jobDefs: QuestJobDefinitionInput | QuestJobDefinitionInput[]): string[],
|
|
146
|
+
* remove(jobNames: QuestJobNameInput): string[],
|
|
147
|
+
* jobs: QuestJob[],
|
|
148
|
+
* list(): QuestJob[],
|
|
149
|
+
* get(name: string): QuestJob | undefined,
|
|
150
|
+
* isRunning(name: string): boolean,
|
|
151
|
+
* pause(name: string): boolean,
|
|
152
|
+
* resume(name: string): boolean,
|
|
153
|
+
* }} QuestApi
|
|
154
|
+
*
|
|
155
|
+
* @typedef {Map<string, QuestJob>} QuestJobsMap
|
|
156
|
+
*
|
|
157
|
+
* @typedef {Map<string, ReturnType<typeof setTimeout>>} QuestTimersMap
|
|
158
|
+
*
|
|
159
|
+
* @typedef {Map<string, number>} QuestRunningMap
|
|
160
|
+
*
|
|
161
|
+
* @typedef {{
|
|
162
|
+
* jobs: QuestJobsMap,
|
|
163
|
+
* timers: QuestTimersMap,
|
|
164
|
+
* running: QuestRunningMap,
|
|
165
|
+
* config: QuestConfig | null,
|
|
166
|
+
* scheduleJob: ((name: string) => void) | null,
|
|
167
|
+
* executeJob: ((name: string, customInputs?: AnyRecord) => Promise<QuestExecutionResult>) | null,
|
|
168
|
+
* getNextRunTime: ((job: QuestJob) => Date | null) | null,
|
|
169
|
+
* }} QuestContext
|
|
170
|
+
*
|
|
171
|
+
* @typedef {{
|
|
172
|
+
* jobs?: QuestJobsMap,
|
|
173
|
+
* timers?: QuestTimersMap,
|
|
174
|
+
* getNextRunTime?: (job: QuestJob) => Date | null,
|
|
175
|
+
* executeJob?: (name: string, customInputs?: AnyRecord) => Promise<QuestExecutionResult>,
|
|
176
|
+
* }} QuestScheduleContext
|
|
177
|
+
*
|
|
178
|
+
* @typedef {{
|
|
179
|
+
* jobs?: QuestJobsMap,
|
|
180
|
+
* scheduleJob?: (name: string) => void,
|
|
181
|
+
* }} QuestStartContext
|
|
182
|
+
*
|
|
183
|
+
* @typedef {{
|
|
184
|
+
* jobs?: QuestJobsMap,
|
|
185
|
+
* timers?: QuestTimersMap,
|
|
186
|
+
* }} QuestStopContext
|
|
187
|
+
*
|
|
188
|
+
* @typedef {{
|
|
189
|
+
* jobs?: QuestJobsMap,
|
|
190
|
+
* executeJob?: (name: string, customInputs?: AnyRecord) => Promise<QuestExecutionResult>,
|
|
191
|
+
* }} QuestRunContext
|
|
192
|
+
*
|
|
193
|
+
* @typedef {{
|
|
194
|
+
* running?: QuestRunningMap,
|
|
195
|
+
* config?: QuestConfig | null,
|
|
196
|
+
* stdout?: NodeJS.WritableStream,
|
|
197
|
+
* stderr?: NodeJS.WritableStream,
|
|
198
|
+
* }} QuestExecutorContext
|
|
199
|
+
*
|
|
200
|
+
* @typedef {{
|
|
201
|
+
* info(...args: any[]): void,
|
|
202
|
+
* warn(...args: any[]): void,
|
|
203
|
+
* error(...args: any[]): void,
|
|
204
|
+
* verbose(...args: any[]): void,
|
|
205
|
+
* [key: string]: any,
|
|
206
|
+
* }} QuestLogger
|
|
207
|
+
*
|
|
208
|
+
* @typedef {{
|
|
209
|
+
* config: {
|
|
210
|
+
* quest: QuestConfig,
|
|
211
|
+
* [key: string]: any,
|
|
212
|
+
* },
|
|
213
|
+
* log: QuestLogger,
|
|
214
|
+
* after(eventName: string, handler: Function): void,
|
|
215
|
+
* on(eventName: string, handler: Function): void,
|
|
216
|
+
* emit(eventName: 'quest:job:start', payload: QuestJobStartEvent): void,
|
|
217
|
+
* emit(eventName: 'quest:job:complete', payload: QuestJobCompleteEvent): void,
|
|
218
|
+
* emit(eventName: 'quest:job:error', payload: QuestJobErrorEvent): void,
|
|
219
|
+
* emit(eventName: string, payload?: any): void,
|
|
220
|
+
* quest?: QuestApi,
|
|
221
|
+
* [key: string]: any,
|
|
222
|
+
* }} QuestSailsApp
|
|
223
|
+
*
|
|
224
|
+
* @typedef {{
|
|
225
|
+
* defaults: { quest: QuestConfig },
|
|
226
|
+
* initialize(): Promise<void>,
|
|
227
|
+
* [key: string]: any,
|
|
228
|
+
* }} QuestSailsHook
|
|
229
|
+
*/
|
|
230
|
+
|
|
231
|
+
module.exports = {}
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sails-hook-quest",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Elegant job scheduling for Sails.js applications with human-readable intervals, cron expressions, and full Sails context",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
+
"test": "sounding test",
|
|
7
8
|
"lint": "prettier --check .",
|
|
8
9
|
"lint:fix": "prettier --write .",
|
|
10
|
+
"typecheck": "tsc -p jsconfig.json --noEmit",
|
|
9
11
|
"prepare": "husky"
|
|
10
12
|
},
|
|
11
13
|
"repository": {
|
|
@@ -50,8 +52,11 @@
|
|
|
50
52
|
"devDependencies": {
|
|
51
53
|
"@commitlint/cli": "^19.5.0",
|
|
52
54
|
"@commitlint/config-conventional": "^19.5.0",
|
|
55
|
+
"@types/node": "^22.7.5",
|
|
53
56
|
"husky": "^9.1.6",
|
|
54
57
|
"lint-staged": "^15.2.10",
|
|
55
|
-
"prettier": "^3.3.3"
|
|
58
|
+
"prettier": "^3.3.3",
|
|
59
|
+
"sounding": "^0.2.0",
|
|
60
|
+
"typescript": "^5.6.2"
|
|
56
61
|
}
|
|
57
62
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const { EventEmitter } = require('node:events')
|
|
2
|
+
const fs = require('node:fs')
|
|
3
|
+
const os = require('node:os')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const { Writable } = require('node:stream')
|
|
6
|
+
const { createRuntime, createTestApi } = require('sounding')
|
|
7
|
+
|
|
8
|
+
const { createDiagnosticTail, executeJob } = require('../lib/core/executor')
|
|
9
|
+
|
|
10
|
+
const sails = new EventEmitter()
|
|
11
|
+
sails.config = {
|
|
12
|
+
appPath: process.cwd(),
|
|
13
|
+
environment: 'test',
|
|
14
|
+
datastores: {}
|
|
15
|
+
}
|
|
16
|
+
sails.hooks = {}
|
|
17
|
+
sails.helpers = {}
|
|
18
|
+
sails.models = {}
|
|
19
|
+
sails.log = { info() {}, warn() {}, error() {}, verbose() {} }
|
|
20
|
+
|
|
21
|
+
const test = createTestApi({ runtime: createRuntime(sails) })
|
|
22
|
+
|
|
23
|
+
test('failed jobs keep live output and emit bounded diagnostics', async ({
|
|
24
|
+
expect,
|
|
25
|
+
t
|
|
26
|
+
}) => {
|
|
27
|
+
const appPath = fs.mkdtempSync(path.join(os.tmpdir(), 'quest-diagnostics-'))
|
|
28
|
+
const scriptsPath = path.join(appPath, 'scripts')
|
|
29
|
+
const runnerPath = path.join(appPath, 'fake-sails')
|
|
30
|
+
fs.mkdirSync(scriptsPath)
|
|
31
|
+
fs.writeFileSync(path.join(scriptsPath, 'send-issue-notifications.js'), '')
|
|
32
|
+
fs.writeFileSync(
|
|
33
|
+
runnerPath,
|
|
34
|
+
[
|
|
35
|
+
'#!/usr/bin/env node',
|
|
36
|
+
"process.stdout.write('Preparing notifications\\n')",
|
|
37
|
+
"process.stderr.write('Error: database exploded\\n at sendIssueNotifications (/app/scripts/send-issue-notifications.js:12:3)\\n')",
|
|
38
|
+
'process.exitCode = 1'
|
|
39
|
+
].join('\n')
|
|
40
|
+
)
|
|
41
|
+
fs.chmodSync(runnerPath, 0o755)
|
|
42
|
+
|
|
43
|
+
const previousSails = global.sails
|
|
44
|
+
global.sails = sails
|
|
45
|
+
|
|
46
|
+
let liveOutput = ''
|
|
47
|
+
const output = new Writable({
|
|
48
|
+
write(chunk, encoding, callback) {
|
|
49
|
+
liveOutput += chunk.toString()
|
|
50
|
+
callback()
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
const failure = new Promise((resolve) =>
|
|
54
|
+
sails.once('quest:job:error', resolve)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
t.after(() => {
|
|
58
|
+
global.sails = previousSails
|
|
59
|
+
fs.rmSync(appPath, { recursive: true, force: true })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
let executionError
|
|
63
|
+
try {
|
|
64
|
+
await executeJob(
|
|
65
|
+
'send-issue-notifications',
|
|
66
|
+
{ name: 'send-issue-notifications' },
|
|
67
|
+
{},
|
|
68
|
+
{
|
|
69
|
+
config: { appPath, sailsPath: runnerPath, diagnosticTailBytes: 1024 },
|
|
70
|
+
stdout: output,
|
|
71
|
+
stderr: output
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
executionError = error
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const event = await failure
|
|
79
|
+
expect(executionError.message).toContain('exited with code 1')
|
|
80
|
+
expect(liveOutput).toContain('Preparing notifications')
|
|
81
|
+
expect(liveOutput).toContain('database exploded')
|
|
82
|
+
expect(event.error.diagnostic).toContain('sendIssueNotifications')
|
|
83
|
+
expect(event.error.stack).toContain('executor.js')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('diagnostic tails discard old output at the configured byte limit', ({
|
|
87
|
+
expect
|
|
88
|
+
}) => {
|
|
89
|
+
const tail = createDiagnosticTail(12)
|
|
90
|
+
tail.append('discard-this-')
|
|
91
|
+
tail.append('keep-this')
|
|
92
|
+
|
|
93
|
+
expect(Buffer.byteLength(tail.value())).toBe(12)
|
|
94
|
+
expect(tail.value()).toBe('is-keep-this')
|
|
95
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const defineQuestHook = require('../lib')
|
|
4
|
+
const scheduler = require('../lib/core/scheduler')
|
|
5
|
+
const loader = require('../lib/core/loader')
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../lib/types').QuestSailsApp} QuestSailsApp */
|
|
8
|
+
/** @typedef {import('../lib/types').QuestNamedJobDefinition} QuestNamedJobDefinition */
|
|
9
|
+
|
|
10
|
+
/** @type {QuestSailsApp} */
|
|
11
|
+
const sails = {
|
|
12
|
+
config: {
|
|
13
|
+
quest: {
|
|
14
|
+
autoStart: false,
|
|
15
|
+
timezone: 'UTC',
|
|
16
|
+
withoutOverlapping: true,
|
|
17
|
+
jobs: [
|
|
18
|
+
'cleanup-sessions',
|
|
19
|
+
{
|
|
20
|
+
name: 'health-check',
|
|
21
|
+
interval: '5 minutes',
|
|
22
|
+
inputs: { url: 'https://example.com/health' }
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
log: {
|
|
28
|
+
info() {},
|
|
29
|
+
warn() {},
|
|
30
|
+
error() {},
|
|
31
|
+
verbose() {}
|
|
32
|
+
},
|
|
33
|
+
after(_eventName, handler) {
|
|
34
|
+
handler()
|
|
35
|
+
},
|
|
36
|
+
on() {},
|
|
37
|
+
emit() {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const hook = defineQuestHook(sails)
|
|
41
|
+
hook.defaults.quest.jobs.push({
|
|
42
|
+
name: 'daily-digest',
|
|
43
|
+
cron: '0 9 * * *',
|
|
44
|
+
timezone: 'Africa/Lagos'
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** @type {QuestNamedJobDefinition} */
|
|
48
|
+
const dynamicJob = {
|
|
49
|
+
name: 'dynamic-report',
|
|
50
|
+
interval: '1 hour',
|
|
51
|
+
inputs: { format: 'csv' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
sails.quest?.add(dynamicJob)
|
|
55
|
+
sails.quest?.start(['health-check', 'dynamic-report'])
|
|
56
|
+
sails.quest?.run('health-check', { url: 'https://example.com/ping' })
|
|
57
|
+
sails.quest?.pause('dynamic-report')
|
|
58
|
+
sails.quest?.resume('dynamic-report')
|
|
59
|
+
sails.quest?.remove(['dynamic-report'])
|
|
60
|
+
|
|
61
|
+
const jobs = sails.quest?.list() || []
|
|
62
|
+
const firstJob = jobs[0]
|
|
63
|
+
if (firstJob) {
|
|
64
|
+
scheduler.getNextRunTime(firstJob, sails.config.quest)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
loader.addJobDefinition(
|
|
68
|
+
{
|
|
69
|
+
name: 'warm-cache',
|
|
70
|
+
timeout: '10 minutes',
|
|
71
|
+
withoutOverlapping: false
|
|
72
|
+
},
|
|
73
|
+
new Map(),
|
|
74
|
+
sails.config.quest
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// @ts-expect-error Job names must be strings or string arrays.
|
|
78
|
+
sails.quest?.start(123)
|
|
79
|
+
|
|
80
|
+
// @ts-expect-error Dynamic jobs need a string name when provided as objects.
|
|
81
|
+
sails.quest?.add({ name: 123, interval: '5 minutes' })
|
|
82
|
+
|
|
83
|
+
// @ts-expect-error Dynamic job objects need an explicit name.
|
|
84
|
+
sails.quest?.add({ interval: '5 minutes' })
|
|
Binary file
|