sails-hook-quest 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,205 +1,305 @@
1
1
  // @ts-check
2
-
3
- /**
4
- * core/executor.js
5
- *
6
- * Functions for executing jobs via child processes
7
- */
8
-
9
- const { spawn } = require('child_process')
10
- const fs = require('fs')
11
- const path = require('path')
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')
12
6
  const { getGlobalSails } = require('./global-sails')
13
-
7
+ const { createRuntime } = require('./runtime')
8
+ const { encodeResult, byteLimit } = require('./result')
14
9
  const DEFAULT_DIAGNOSTIC_TAIL_BYTES = 64 * 1024
10
+ const runtimes = new WeakMap()
15
11
 
16
12
  /** @typedef {import('../types').AnyRecord} AnyRecord */
17
13
  /** @typedef {import('../types').QuestExecutableJob} QuestExecutableJob */
18
14
  /** @typedef {import('../types').QuestExecutionResult} QuestExecutionResult */
19
15
  /** @typedef {import('../types').QuestExecutorContext} QuestExecutorContext */
20
16
 
21
- /**
22
- * Execute a job via `sails run`
23
- * @param {String} name - Job name
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
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>}
28
40
  */
29
41
  async function executeJob(name, job, customInputs = {}, context = {}) {
30
42
  const { running = new Map(), config = {} } = context
31
-
32
- // Check if job is already running (and overlapping is disabled)
33
- if (job.withoutOverlapping && running.has(name)) {
34
- const sails = getGlobalSails()
35
- if (sails) {
36
- sails.log.warn(`Job "${name}" is already running, skipping...`)
37
- }
38
- 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)
39
47
  }
40
-
41
- // Don't run if paused
42
- if (job.paused) {
43
- const sails = getGlobalSails()
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 }
44
59
  if (sails) {
45
- sails.log.verbose(`Job "${name}" is paused, skipping...`)
60
+ try {
61
+ sails.emit(event, payload)
62
+ } catch (error) {
63
+ sails.log.error('Quest lifecycle listener failed:', error)
64
+ }
46
65
  }
47
- return { skipped: true, reason: 'paused' }
66
+ return payload
48
67
  }
49
-
50
- const sails = getGlobalSails()
51
- if (sails) {
52
- 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() }
53
72
  }
54
-
55
- running.set(name, Date.now())
56
-
57
- // Merge inputs with priority: jobInputs < scriptInputs < customInputs
58
- const inputs = {
59
- ...(job.inputs || {}),
60
- ...(job.scriptInputs || {}),
61
- ...customInputs
62
- }
63
-
64
- // Emit job start event
65
- if (sails) {
66
- 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', {
67
110
  name,
68
- inputs,
69
- 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'
70
123
  })
124
+ throw error
71
125
  }
72
126
 
73
- return new Promise((resolve, reject) => {
74
- // Build command arguments
75
- const args = buildCommandArgs(name, inputs)
76
-
77
- // Setup environment
78
- const env = { ...process.env }
79
- if (config.environment) {
80
- env.NODE_ENV = config.environment
81
- }
82
-
83
- const sailsPath = config.sailsPath || './node_modules/.bin/sails'
84
- const cwd = config.appPath || process.cwd()
85
- const scriptsDir = config.scriptsDir || 'scripts'
86
-
87
- // Validate script exists before attempting to run
88
- const scriptPath = path.resolve(cwd, scriptsDir, `${name}.js`)
89
- if (!fs.existsSync(scriptPath)) {
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)
90
136
  running.delete(name)
91
- const error = new Error(
92
- `Job "${name}" not found. Please check that the script exists at ${scriptsDir}/${name}.js`
93
- )
94
- if (sails) {
95
- sails.log.error(error.message)
96
- sails.emit('quest:job:error', {
97
- name,
98
- inputs,
99
- error: { message: error.message, stack: error.stack },
100
- duration: 0,
101
- timestamp: new Date()
102
- })
103
- }
104
- return reject(error)
105
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
+ })
106
159
 
107
- const diagnosticTail = createDiagnosticTail(config.diagnosticTailBytes)
108
- const child = spawn(sailsPath, args, {
109
- cwd,
110
- env,
111
- stdio: ['inherit', 'pipe', 'pipe']
112
- })
113
-
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
-
160
+ return new Promise((resolve, reject) => {
161
+ let child
125
162
  let settled = false
126
-
127
- child.on('close', (code) => {
128
- if (settled) {
129
- return
130
- }
163
+ let resultBuffer = Buffer.alloc(0)
164
+ let resultOverflow = false
165
+ const finish = (error, code, signal = null) => {
166
+ if (settled) return
131
167
  settled = true
132
- const startTime = /** @type {number} */ (running.get(name))
133
- const duration = Date.now() - startTime
134
- running.delete(name)
135
-
136
- if (code === 0) {
137
- if (sails) {
138
- sails.log.info(`Job "${name}" completed successfully`)
139
-
140
- // Emit success event
141
- sails.emit('quest:job:complete', {
142
- name,
143
- inputs,
144
- duration,
145
- timestamp: new Date()
146
- })
147
- }
148
-
149
- resolve({ success: true, duration })
150
- } else {
151
- const error = new Error(`Job "${name}" exited with code ${code}`)
152
-
153
- if (sails) {
154
- sails.log.error(error)
155
-
156
- // Emit error event
157
- sails.emit('quest:job:error', {
158
- name,
159
- inputs,
160
- error: {
161
- message: error.message,
162
- code,
163
- stack: error.stack,
164
- diagnostic: diagnosticTail.value()
165
- },
166
- duration,
167
- timestamp: new Date()
168
- })
169
- }
170
-
171
- reject(error)
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
172
180
  }
173
- })
174
-
175
- child.on('error', (err) => {
176
- if (settled) {
177
- return
178
- }
179
- settled = true
180
- const startTime = running.get(name) || Date.now()
181
- const duration = Date.now() - startTime
182
- running.delete(name)
183
-
184
- if (sails) {
185
- sails.log.error(`Job "${name}" failed to start:`, err)
186
-
187
- // Emit error event
188
- sails.emit('quest:job:error', {
189
- name,
190
- inputs,
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
191
  error: {
192
- message: err.message,
193
- stack: err.stack,
194
- diagnostic: diagnosticTail.value()
195
- },
192
+ message: failure.message,
193
+ code,
194
+ stack: failure.stack,
195
+ diagnostic: diagnostic.value()
196
+ }
197
+ })
198
+ Object.assign(failure, identity, {
199
+ finishedAt,
196
200
  duration,
197
- timestamp: new Date()
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
198
260
  })
199
261
  }
200
-
201
- reject(err)
202
- })
262
+ }
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
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
+ }
203
303
  })
204
304
  }
205
305
 
@@ -212,7 +312,7 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
212
312
  function createDiagnosticTail(requestedMaxBytes) {
213
313
  const maxBytes =
214
314
  Number.isFinite(requestedMaxBytes) && requestedMaxBytes > 0
215
- ? Math.floor(requestedMaxBytes)
315
+ ? Math.min(1024 * 1024, Math.floor(requestedMaxBytes))
216
316
  : DEFAULT_DIAGNOSTIC_TAIL_BYTES
217
317
  let buffer = Buffer.alloc(0)
218
318
 
@@ -220,13 +320,20 @@ function createDiagnosticTail(requestedMaxBytes) {
220
320
  /** @param {Buffer|string} chunk */
221
321
  append(chunk) {
222
322
  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)
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
+ ])
226
331
  }
227
332
  },
228
333
  value() {
229
- return buffer.toString('utf8').trim()
334
+ let text = buffer.toString('utf8').trim()
335
+ while (Buffer.byteLength(text) > maxBytes) text = text.slice(1)
336
+ return text
230
337
  }
231
338
  }
232
339
  }
@@ -245,7 +352,10 @@ function teeChildOutput(source, destination, diagnosticTail) {
245
352
 
246
353
  source.on('data', (chunk) => {
247
354
  diagnosticTail.append(chunk)
248
- destination.write(chunk)
355
+ if (!destination.write(chunk)) {
356
+ source.pause()
357
+ destination.once('drain', () => source.resume())
358
+ }
249
359
  })
250
360
  }
251
361
 
@@ -253,17 +363,21 @@ function teeChildOutput(source, destination, diagnosticTail) {
253
363
  * Build command arguments for sails run
254
364
  * @param {String} scriptName - Name of the script
255
365
  * @param {AnyRecord} [inputs] - Input values
366
+ * @param {import('../types').QuestMachineInputs} [schema] Loaded input types for CLI encoding
256
367
  * @returns {string[]} Command arguments
257
368
  */
258
- function buildCommandArgs(scriptName, inputs = {}) {
369
+ function buildCommandArgs(scriptName, inputs = {}, schema = {}) {
259
370
  const args = ['run', scriptName]
260
371
 
261
372
  // Add inputs as command line args
262
373
  for (const [key, value] of Object.entries(inputs)) {
263
- // Only JSON.stringify objects and arrays (for type: 'json' or 'ref' inputs)
264
- // 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
265
377
  const serialized =
266
- typeof value === 'object' && value !== null
378
+ type === 'json' ||
379
+ type === 'ref' ||
380
+ (typeof value === 'object' && value !== null)
267
381
  ? JSON.stringify(value)
268
382
  : String(value)
269
383
  args.push(`--${key}=${serialized}`)