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
package/lib/core/executor.js
CHANGED
|
@@ -1,181 +1,383 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
66
|
+
return payload
|
|
36
67
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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 {
|
|
168
|
-
* @
|
|
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
|
-
//
|
|
176
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|