sails-hook-quest 0.0.5 → 0.0.7
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 +34 -0
- package/CONTRACT.md +97 -0
- package/lib/core/child-result.js +80 -0
- package/lib/core/execution-child.js +14 -0
- package/lib/core/executor.js +295 -181
- package/lib/core/job-control.js +69 -8
- package/lib/core/loader.js +9 -3
- package/lib/core/result.js +91 -0
- package/lib/core/runtime.js +240 -0
- package/lib/core/scheduler.js +83 -44
- package/lib/index.js +57 -12
- package/lib/types.js +75 -5
- package/package.json +10 -1
- 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/jsconfig.json +0 -17
- package/test/executor.test.js +0 -95
- package/typecheck/public-api-smoke.js +0 -84
package/lib/core/executor.js
CHANGED
|
@@ -1,205 +1,305 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
60
|
+
try {
|
|
61
|
+
sails.emit(event, payload)
|
|
62
|
+
} catch (error) {
|
|
63
|
+
sails.log.error('Quest lifecycle listener failed:', error)
|
|
64
|
+
}
|
|
46
65
|
}
|
|
47
|
-
return
|
|
66
|
+
return payload
|
|
48
67
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
133
|
-
const duration =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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:
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
264
|
-
//
|
|
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
|
-
|
|
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}`)
|