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.
@@ -1,181 +1,383 @@
1
- /**
2
- * core/executor.js
3
- *
4
- * Functions for executing jobs via child processes
5
- */
1
+ // @ts-check
2
+ const { spawn } = require('node:child_process')
3
+ const { randomUUID } = require('node:crypto')
4
+ const fs = require('node:fs')
5
+ const path = require('node:path')
6
+ const { getGlobalSails } = require('./global-sails')
7
+ const { createRuntime } = require('./runtime')
8
+ const { encodeResult, byteLimit } = require('./result')
9
+ const DEFAULT_DIAGNOSTIC_TAIL_BYTES = 64 * 1024
10
+ const runtimes = new WeakMap()
6
11
 
7
- const { spawn } = require('child_process')
8
- const fs = require('fs')
9
- const path = require('path')
12
+ /** @typedef {import('../types').AnyRecord} AnyRecord */
13
+ /** @typedef {import('../types').QuestExecutableJob} QuestExecutableJob */
14
+ /** @typedef {import('../types').QuestExecutionResult} QuestExecutionResult */
15
+ /** @typedef {import('../types').QuestExecutorContext} QuestExecutorContext */
10
16
 
11
- /**
12
- * Execute a job via `sails run`
13
- * @param {String} name - Job name
14
- * @param {Object} job - Job configuration
15
- * @param {Object} customInputs - Custom input values
16
- * @param {Object} context - Execution context with running map, config, etc
17
- * @returns {Promise} Resolves when job completes
17
+ /** Validate with the application's installed Sails machine runner, without invoking business code.
18
+ * @param {AnyRecord} schema @param {AnyRecord} inputs @param {string} cwd
19
+ */
20
+ function validateInputs(schema, inputs, cwd) {
21
+ if (!Object.keys(schema).length && !Object.keys(inputs).length) return
22
+ const machine = require(require.resolve('machine', { paths: [cwd] }))
23
+ machine
24
+ .buildWithCustomUsage({
25
+ def: {
26
+ identity: 'quest-input-validation',
27
+ sync: true,
28
+ inputs: schema,
29
+ fn: function (_inputs, exits) {
30
+ return exits.success()
31
+ }
32
+ },
33
+ extraArginsTactic: 'error'
34
+ })(inputs)
35
+ .execSync()
36
+ }
37
+
38
+ /** @param {string} name @param {QuestExecutableJob} job @param {AnyRecord} [customInputs] @param {QuestExecutorContext} [context]
39
+ * @returns {Promise<QuestExecutionResult>}
18
40
  */
19
41
  async function executeJob(name, job, customInputs = {}, context = {}) {
20
42
  const { running = new Map(), config = {} } = context
21
-
22
- // Check if job is already running (and overlapping is disabled)
23
- if (job.withoutOverlapping && running.has(name)) {
24
- if (global.sails) {
25
- sails.log.warn(`Job "${name}" is already running, skipping...`)
26
- }
27
- return { skipped: true, reason: 'already_running' }
43
+ let runtime = context.runtime || runtimes.get(running)
44
+ if (!runtime) {
45
+ runtime = createRuntime()
46
+ runtimes.set(running, runtime)
28
47
  }
29
-
30
- // Don't run if paused
31
- if (job.paused) {
32
- if (global.sails) {
33
- sails.log.verbose(`Job "${name}" is paused, skipping...`)
48
+ const runId = randomUUID()
49
+ const startedAt = new Date()
50
+ const identity = {
51
+ runId,
52
+ runtimeId: runtime.runtimeId,
53
+ startedAt,
54
+ trigger: context.trigger || 'manual'
55
+ }
56
+ const sails = context.sails || getGlobalSails()
57
+ const emit = (event, data) => {
58
+ const payload = { ...identity, ...data, sequence: ++runtime.sequence }
59
+ if (sails) {
60
+ try {
61
+ sails.emit(event, payload)
62
+ } catch (error) {
63
+ sails.log.error('Quest lifecycle listener failed:', error)
64
+ }
34
65
  }
35
- return { skipped: true, reason: 'paused' }
66
+ return payload
36
67
  }
37
-
38
- if (global.sails) {
39
- sails.log.info(`Running job: ${name}`)
68
+ if ((job.withoutOverlapping && running.has(name)) || job.paused) {
69
+ const reason = job.paused ? 'paused' : 'already_running'
70
+ emit('quest:job:skip', { name, reason, timestamp: new Date() })
71
+ return { skipped: true, reason, ...identity, finishedAt: new Date() }
40
72
  }
41
-
42
- running.set(name, Date.now())
43
-
44
- // Merge inputs with priority: jobInputs < scriptInputs < customInputs
45
- const inputs = { ...job.inputs, ...job.scriptInputs, ...customInputs }
46
-
47
- // Emit job start event
48
- if (global.sails) {
49
- sails.emit('quest:job:start', {
73
+ const cwd = config.appPath || process.cwd()
74
+ const scriptsDir = config.scriptsDir || 'scripts'
75
+ const script = job.script || name
76
+ let inputs
77
+ let args
78
+ let scriptPath
79
+ let inputJson
80
+ try {
81
+ if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(script) || script.includes('..'))
82
+ throw new Error('Invalid Quest script identity')
83
+ inputs = {
84
+ ...(job.inputs || {}),
85
+ ...(job.scriptInputs || {}),
86
+ ...customInputs
87
+ }
88
+ const encoded = encodeResult(inputs, byteLimit(config.inputBytes))
89
+ if (encoded.status !== 'available')
90
+ throw new Error(`Quest inputs: ${encoded.status}`)
91
+ inputJson = encoded.json
92
+ if (job.inputSchema) validateInputs(job.inputSchema, inputs, cwd)
93
+ args = buildCommandArgs(script, inputs, job.inputSchema)
94
+ scriptPath = path.resolve(cwd, scriptsDir, `${script}.js`)
95
+ if (!fs.existsSync(scriptPath))
96
+ throw new Error(
97
+ `Job "${name}" not found. Please check that the script exists at ${scriptsDir}/${script}.js`
98
+ )
99
+ scriptPath = fs.realpathSync(scriptPath)
100
+ } catch (error) {
101
+ const validationCode =
102
+ typeof error.code === 'string' ? error.code : undefined
103
+ Object.assign(error, identity, {
104
+ code: 'E_QUEST_ADMISSION_REJECTED',
105
+ validationCode,
106
+ phase: 'validation',
107
+ admission: 'rejected_before_start'
108
+ })
109
+ emit('quest:job:error', {
50
110
  name,
51
- inputs,
52
- timestamp: new Date()
111
+ inputs: inputs || {},
112
+ error: {
113
+ message: error.message,
114
+ stack: error.stack,
115
+ admissionCode: error.code,
116
+ validationCode
117
+ },
118
+ admission: 'rejected_before_start',
119
+ duration: 0,
120
+ timestamp: new Date(),
121
+ finishedAt: new Date(),
122
+ phase: 'validation'
53
123
  })
124
+ throw error
54
125
  }
55
126
 
56
- return new Promise((resolve, reject) => {
57
- // Build command arguments
58
- const args = buildCommandArgs(name, inputs)
59
-
60
- // Setup environment
61
- const env = { ...process.env }
62
- if (config.environment) {
63
- env.NODE_ENV = config.environment
127
+ const active = runtime.active.get(name) || new Map()
128
+ active.set(runId, startedAt.getTime())
129
+ runtime.active.set(name, active)
130
+ running.set(name, Math.min(...active.values()))
131
+ const release = () => {
132
+ active.delete(runId)
133
+ if (active.size) running.set(name, Math.min(...active.values()))
134
+ else {
135
+ runtime.active.delete(name)
136
+ running.delete(name)
64
137
  }
138
+ }
139
+ emit('quest:job:start', { name, inputs, timestamp: startedAt })
140
+ const env = { ...process.env }
141
+ if (config.environment) env.NODE_ENV = config.environment
142
+ env.QUEST_SCRIPT_PATH = scriptPath
143
+ env.QUEST_RUN_ID = runId
144
+ env.QUEST_RESULT_BYTES = String(byteLimit(config.resultBytes))
145
+ env.NODE_OPTIONS =
146
+ `${env.NODE_OPTIONS || ''} --require ${JSON.stringify(path.join(__dirname, 'child-result.js'))}`.trim()
147
+ const diagnostic = createDiagnosticTail(config.diagnosticTailBytes)
148
+ const stdout = createDiagnosticTail(byteLimit(config.logBytes))
149
+ const stderr = createDiagnosticTail(byteLimit(config.logBytes))
150
+ let stdoutBytes = 0
151
+ let stderrBytes = 0
152
+ const logs = () => ({
153
+ stdout: stdout.value(),
154
+ stderr: stderr.value(),
155
+ stdoutTruncated: stdoutBytes > byteLimit(config.logBytes),
156
+ stderrTruncated: stderrBytes > byteLimit(config.logBytes),
157
+ limitBytes: byteLimit(config.logBytes)
158
+ })
65
159
 
66
- const sailsPath = config.sailsPath || './node_modules/.bin/sails'
67
- const cwd = config.appPath || process.cwd()
68
- const scriptsDir = config.scriptsDir || 'scripts'
69
-
70
- // Validate script exists before attempting to run
71
- const scriptPath = path.resolve(cwd, scriptsDir, `${name}.js`)
72
- if (!fs.existsSync(scriptPath)) {
73
- running.delete(name)
74
- const error = new Error(
75
- `Job "${name}" not found. Please check that the script exists at ${scriptsDir}/${name}.js`
76
- )
77
- if (global.sails) {
78
- sails.log.error(error.message)
79
- sails.emit('quest:job:error', {
80
- name,
81
- inputs,
82
- error: { message: error.message },
83
- duration: 0,
84
- timestamp: new Date()
160
+ return new Promise((resolve, reject) => {
161
+ let child
162
+ let settled = false
163
+ let resultBuffer = Buffer.alloc(0)
164
+ let resultOverflow = false
165
+ const finish = (error, code, signal = null) => {
166
+ if (settled) return
167
+ settled = true
168
+ const finishedAt = new Date()
169
+ const duration = finishedAt.getTime() - startedAt.getTime()
170
+ release()
171
+ const terminal = {
172
+ name,
173
+ inputs,
174
+ duration,
175
+ timestamp: finishedAt,
176
+ finishedAt,
177
+ logs: logs(),
178
+ exitCode: typeof code === 'number' ? code : null,
179
+ signal: typeof signal === 'string' ? signal : null
180
+ }
181
+ if (error || code !== 0) {
182
+ const failure =
183
+ error ||
184
+ new Error(
185
+ signal
186
+ ? `Job "${name}" terminated by signal ${signal}`
187
+ : `Job "${name}" exited with code ${code}`
188
+ )
189
+ const event = emit('quest:job:error', {
190
+ ...terminal,
191
+ error: {
192
+ message: failure.message,
193
+ code,
194
+ stack: failure.stack,
195
+ diagnostic: diagnostic.value()
196
+ }
197
+ })
198
+ Object.assign(failure, identity, {
199
+ finishedAt,
200
+ duration,
201
+ logs: terminal.logs,
202
+ exitCode: terminal.exitCode,
203
+ signal: terminal.signal,
204
+ sequence: event.sequence
205
+ })
206
+ reject(failure)
207
+ } else {
208
+ /** @type {import('../types').QuestBusinessResult} */
209
+ let result = { status: 'unsupported', reason: 'missing_transport' }
210
+ if (resultOverflow)
211
+ result = { status: 'too_large', reason: 'transport_limit' }
212
+ else if (resultBuffer.length) {
213
+ try {
214
+ const envelope = JSON.parse(resultBuffer.toString('utf8'))
215
+ if (envelope.version !== 1 || envelope.runId !== runId)
216
+ throw new Error('mismatched_transport')
217
+ if (
218
+ ![
219
+ 'available',
220
+ 'undefined',
221
+ 'too_large',
222
+ 'serialization_error'
223
+ ].includes(envelope.result?.status)
224
+ )
225
+ throw new Error('invalid_result')
226
+ if (envelope.result.status === 'available') {
227
+ if (
228
+ !Object.prototype.hasOwnProperty.call(envelope.result, 'value')
229
+ )
230
+ throw new Error('missing_value')
231
+ const encoded = encodeResult(
232
+ envelope.result.value,
233
+ config.resultBytes
234
+ )
235
+ result =
236
+ encoded.status === 'available'
237
+ ? { status: 'available', value: encoded.value }
238
+ : { status: encoded.status }
239
+ } else result = { status: envelope.result.status }
240
+ if (
241
+ typeof envelope.result.exit === 'string' &&
242
+ envelope.result.exit.length <= 128
243
+ )
244
+ result.exit = envelope.result.exit
245
+ } catch {
246
+ result = { status: 'unsupported', reason: 'invalid_transport' }
247
+ }
248
+ }
249
+ const event = emit('quest:job:complete', { ...terminal, result })
250
+ resolve({
251
+ success: true,
252
+ duration,
253
+ ...identity,
254
+ finishedAt,
255
+ sequence: event.sequence,
256
+ result,
257
+ logs: terminal.logs,
258
+ exitCode: terminal.exitCode,
259
+ signal: terminal.signal
85
260
  })
86
261
  }
87
- return reject(error)
88
262
  }
89
-
90
- const child = spawn(sailsPath, args, {
91
- cwd,
92
- env,
93
- stdio: 'inherit'
94
- })
95
-
96
- child.on('exit', (code) => {
97
- const startTime = running.get(name)
98
- const duration = Date.now() - startTime
99
- running.delete(name)
100
-
101
- if (code === 0) {
102
- if (global.sails) {
103
- sails.log.info(`Job "${name}" completed successfully`)
104
-
105
- // Emit success event
106
- sails.emit('quest:job:complete', {
107
- name,
108
- inputs,
109
- duration,
110
- timestamp: new Date()
111
- })
263
+ try {
264
+ child = spawn(config.sailsPath || './node_modules/.bin/sails', args, {
265
+ cwd,
266
+ env,
267
+ stdio: ['inherit', 'pipe', 'pipe', 'pipe', 'pipe']
268
+ })
269
+ child.stdout.on('data', (chunk) => {
270
+ stdoutBytes += chunk.length
271
+ stdout.append(chunk)
272
+ })
273
+ child.stderr.on('data', (chunk) => {
274
+ stderrBytes += chunk.length
275
+ stderr.append(chunk)
276
+ })
277
+ teeChildOutput(child.stdout, context.stdout || process.stdout, diagnostic)
278
+ teeChildOutput(child.stderr, context.stderr || process.stderr, diagnostic)
279
+ const channel = /** @type {NodeJS.ReadableStream} */ (child.stdio[3])
280
+ channel.on('data', (chunk) => {
281
+ if (resultOverflow) return
282
+ if (
283
+ resultBuffer.length + chunk.length >
284
+ byteLimit(config.resultBytes) + 1024
285
+ ) {
286
+ resultOverflow = true
287
+ resultBuffer = Buffer.alloc(0)
288
+ return
112
289
  }
290
+ resultBuffer = Buffer.concat([resultBuffer, chunk])
291
+ })
292
+ channel.on('error', () => {
293
+ resultOverflow = true
294
+ })
295
+ const inputChannel = /** @type {NodeJS.WritableStream} */ (child.stdio[4])
296
+ inputChannel.on('error', () => {}) // Spawn failure/early exit is settled by close/error.
297
+ inputChannel.end(inputJson)
298
+ child.once('close', (code, signal) => finish(null, code, signal))
299
+ child.once('error', (error) => finish(error, null))
300
+ } catch (error) {
301
+ finish(error, null)
302
+ }
303
+ })
304
+ }
113
305
 
114
- resolve({ success: true, duration })
115
- } else {
116
- const error = new Error(`Job "${name}" exited with code ${code}`)
117
-
118
- if (global.sails) {
119
- sails.log.error(error)
120
-
121
- // Emit error event
122
- sails.emit('quest:job:error', {
123
- name,
124
- inputs,
125
- error: {
126
- message: error.message,
127
- code
128
- },
129
- duration,
130
- timestamp: new Date()
131
- })
132
- }
306
+ /**
307
+ * Retain only the end of child output so a failed job can explain itself
308
+ * without buffering an unbounded process log.
309
+ *
310
+ * @param {number} [requestedMaxBytes]
311
+ */
312
+ function createDiagnosticTail(requestedMaxBytes) {
313
+ const maxBytes =
314
+ Number.isFinite(requestedMaxBytes) && requestedMaxBytes > 0
315
+ ? Math.min(1024 * 1024, Math.floor(requestedMaxBytes))
316
+ : DEFAULT_DIAGNOSTIC_TAIL_BYTES
317
+ let buffer = Buffer.alloc(0)
133
318
 
134
- reject(error)
319
+ return {
320
+ /** @param {Buffer|string} chunk */
321
+ append(chunk) {
322
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
323
+ if (incoming.length >= maxBytes) {
324
+ buffer = Buffer.from(incoming.subarray(incoming.length - maxBytes))
325
+ } else {
326
+ const keep = Math.max(0, maxBytes - incoming.length)
327
+ buffer = Buffer.concat([
328
+ buffer.subarray(Math.max(0, buffer.length - keep)),
329
+ incoming
330
+ ])
135
331
  }
136
- })
137
-
138
- child.on('error', (err) => {
139
- const startTime = running.get(name) || Date.now()
140
- const duration = Date.now() - startTime
141
- running.delete(name)
142
-
143
- if (global.sails) {
144
- sails.log.error(`Job "${name}" failed to start:`, err)
332
+ },
333
+ value() {
334
+ let text = buffer.toString('utf8').trim()
335
+ while (Buffer.byteLength(text) > maxBytes) text = text.slice(1)
336
+ return text
337
+ }
338
+ }
339
+ }
145
340
 
146
- // Emit error event
147
- sails.emit('quest:job:error', {
148
- name,
149
- inputs,
150
- error: {
151
- message: err.message,
152
- stack: err.stack
153
- },
154
- duration,
155
- timestamp: new Date()
156
- })
157
- }
341
+ /**
342
+ * Mirror child output to the parent while retaining a bounded diagnostic tail.
343
+ *
344
+ * @param {NodeJS.ReadableStream|null} source
345
+ * @param {NodeJS.WritableStream} destination
346
+ * @param {ReturnType<typeof createDiagnosticTail>} diagnosticTail
347
+ */
348
+ function teeChildOutput(source, destination, diagnosticTail) {
349
+ if (!source) {
350
+ return
351
+ }
158
352
 
159
- reject(err)
160
- })
353
+ source.on('data', (chunk) => {
354
+ diagnosticTail.append(chunk)
355
+ if (!destination.write(chunk)) {
356
+ source.pause()
357
+ destination.once('drain', () => source.resume())
358
+ }
161
359
  })
162
360
  }
163
361
 
164
362
  /**
165
363
  * Build command arguments for sails run
166
364
  * @param {String} scriptName - Name of the script
167
- * @param {Object} inputs - Input values
168
- * @returns {Array} Command arguments
365
+ * @param {AnyRecord} [inputs] - Input values
366
+ * @param {import('../types').QuestMachineInputs} [schema] Loaded input types for CLI encoding
367
+ * @returns {string[]} Command arguments
169
368
  */
170
- function buildCommandArgs(scriptName, inputs = {}) {
369
+ function buildCommandArgs(scriptName, inputs = {}, schema = {}) {
171
370
  const args = ['run', scriptName]
172
371
 
173
372
  // Add inputs as command line args
174
373
  for (const [key, value] of Object.entries(inputs)) {
175
- // Only JSON.stringify objects and arrays (for type: 'json' or 'ref' inputs)
176
- // Primitives should be passed directly for whelk/rttc.parseHuman() to handle
374
+ // Whelk checks JSON/ref argins for JSON syntax before invoking the machine.
375
+ // Encode their strings too; ordinary string inputs retain literal CLI text.
376
+ const type = schema[key]?.type
177
377
  const serialized =
178
- typeof value === 'object' && value !== null
378
+ type === 'json' ||
379
+ type === 'ref' ||
380
+ (typeof value === 'object' && value !== null)
179
381
  ? JSON.stringify(value)
180
382
  : String(value)
181
383
  args.push(`--${key}=${serialized}`)
@@ -186,5 +388,6 @@ function buildCommandArgs(scriptName, inputs = {}) {
186
388
 
187
389
  module.exports = {
188
390
  executeJob,
189
- buildCommandArgs
391
+ buildCommandArgs,
392
+ createDiagnosticTail
190
393
  }
@@ -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
+ }