@goodandready/dsh-moa 0.2.36 → 0.2.38
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 +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +3 -0
- package/lib/file-workspace.js +195 -143
- package/lib/moa-candidates.js +54 -6
- package/lib/moa-llm.js +74 -6
- package/lib/moa-runner.js +38 -33
- package/lib/moa-stream.js +5 -3
- package/lib/moa-test-gate.js +94 -41
- package/lib/routes.js +5 -2
- package/package.json +1 -1
package/lib/moa-test-gate.js
CHANGED
|
@@ -3,32 +3,47 @@ import fs from 'node:fs/promises'
|
|
|
3
3
|
import fsSync from 'node:fs'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
|
-
import {
|
|
6
|
+
import { resolveCandidateFolder } from './file-workspace.js'
|
|
7
7
|
import { bestEffort } from './best-effort.js'
|
|
8
8
|
|
|
9
|
+
const MAX_CAPTURE_BYTES = 256 * 1024
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns a sanitized environment stripping sensitive tokens and secrets (#161).
|
|
13
|
+
*/
|
|
14
|
+
export function getScrubbedEnv(overrides = {}) {
|
|
15
|
+
const safeVars = new Set([
|
|
16
|
+
'PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TEMP', 'TMP',
|
|
17
|
+
'USER', 'LOGNAME', 'HOME', 'SHELL', 'TERM', 'NODE_ENV', 'SYSTEMROOT',
|
|
18
|
+
'WINDIR', 'COMSPEC', 'PATHEXT',
|
|
19
|
+
])
|
|
20
|
+
const scrubbed = {}
|
|
21
|
+
for (const [key, val] of Object.entries(process.env)) {
|
|
22
|
+
if (!val || key.startsWith('MOA_')) continue
|
|
23
|
+
if (/(API_KEY|TOKEN|SECRET|PASSWORD|PASSWD|AUTH|CREDENTIAL|PRIVATE|KEY|ACCESS)/i.test(key)) {
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
if (safeVars.has(key) || key.startsWith('LC_')) {
|
|
27
|
+
scrubbed[key] = val
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { ...scrubbed, ...overrides }
|
|
31
|
+
}
|
|
32
|
+
|
|
9
33
|
/**
|
|
10
34
|
* Resolves the directory for candidate sandbox.
|
|
11
35
|
*/
|
|
12
|
-
export function resolveCandidateDir(baseDir, candidateIndex) {
|
|
36
|
+
export function resolveCandidateDir(baseDir, candidateIndex, options = {}) {
|
|
13
37
|
if (!baseDir || candidateIndex === null || candidateIndex === undefined) return null
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
38
|
+
try {
|
|
39
|
+
return resolveCandidateFolder(baseDir, candidateIndex, options).candidateDir
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
19
43
|
}
|
|
20
44
|
|
|
21
45
|
/**
|
|
22
46
|
* Executes a test command in a candidate sandbox or ephemeral test staging directory.
|
|
23
|
-
*
|
|
24
|
-
* @param {object} params
|
|
25
|
-
* @param {string} params.cwd - Project root workspace directory
|
|
26
|
-
* @param {number|string} params.candidateIndex - Index of candidate (1, 2, ...)
|
|
27
|
-
* @param {string} params.testCommand - Shell command to execute (e.g. "npm test", "node --test")
|
|
28
|
-
* @param {number} [params.timeoutMs=15000] - Execution timeout in ms
|
|
29
|
-
* @param {number} [params.maxOutputChars=2000] - Maximum captured output chars
|
|
30
|
-
* @param {function} [params.execFn] - Optional mock/test runner function
|
|
31
|
-
* @returns {Promise<object|null>} Test result object or null if skipped
|
|
32
47
|
*/
|
|
33
48
|
export async function runCandidateTestGate({
|
|
34
49
|
cwd,
|
|
@@ -37,18 +52,14 @@ export async function runCandidateTestGate({
|
|
|
37
52
|
timeoutMs = 15000,
|
|
38
53
|
maxOutputChars = 2000,
|
|
39
54
|
execFn = null,
|
|
55
|
+
runId = null,
|
|
40
56
|
}) {
|
|
41
|
-
if (!cwd || !testCommand || typeof testCommand !== 'string')
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
44
|
-
|
|
57
|
+
if (!cwd || !testCommand || typeof testCommand !== 'string') return null
|
|
45
58
|
const trimmedCmd = testCommand.trim()
|
|
46
59
|
if (!trimmedCmd) return null
|
|
47
60
|
|
|
48
|
-
const candidateDir = resolveCandidateDir(cwd, candidateIndex)
|
|
49
|
-
if (!candidateDir || !fsSync.existsSync(candidateDir))
|
|
50
|
-
return null
|
|
51
|
-
}
|
|
61
|
+
const candidateDir = resolveCandidateDir(cwd, candidateIndex, { runId })
|
|
62
|
+
if (!candidateDir || !fsSync.existsSync(candidateDir)) return null
|
|
52
63
|
|
|
53
64
|
const startTime = Date.now()
|
|
54
65
|
let stageDir = null
|
|
@@ -66,13 +77,19 @@ export async function runCandidateTestGate({
|
|
|
66
77
|
const rel = path.relative(cwd, src)
|
|
67
78
|
if (!rel) return true
|
|
68
79
|
const parts = rel.split(path.sep)
|
|
69
|
-
|
|
70
|
-
return false
|
|
71
|
-
}
|
|
72
|
-
return true
|
|
80
|
+
return parts[0] !== 'node_modules' && parts[0] !== '.git' && parts[0] !== '.moa'
|
|
73
81
|
},
|
|
74
82
|
})
|
|
75
83
|
|
|
84
|
+
// #162: Preserve ESM & CJS resolution by symlinking project node_modules
|
|
85
|
+
const baseNodeModules = path.join(cwd, 'node_modules')
|
|
86
|
+
const stageNodeModules = path.join(stageDir, 'node_modules')
|
|
87
|
+
if (fsSync.existsSync(baseNodeModules)) {
|
|
88
|
+
await bestEffort('symlink node_modules in test stage', () =>
|
|
89
|
+
fs.symlink(baseNodeModules, stageNodeModules, process.platform === 'win32' ? 'junction' : 'dir')
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
76
93
|
await fs.cp(candidateDir, stageDir, { recursive: true })
|
|
77
94
|
executionDir = stageDir
|
|
78
95
|
}
|
|
@@ -98,22 +115,25 @@ export async function runCandidateTestGate({
|
|
|
98
115
|
stderr = customRes?.stderr || ''
|
|
99
116
|
} else {
|
|
100
117
|
const nodePath = path.join(cwd, 'node_modules')
|
|
101
|
-
const env = {
|
|
102
|
-
...process.env,
|
|
118
|
+
const env = getScrubbedEnv({
|
|
103
119
|
MOA_CANDIDATE_INDEX: String(candidateIndex),
|
|
104
120
|
MOA_CANDIDATE_DIR: candidateDir,
|
|
105
121
|
MOA_BASE_DIR: cwd,
|
|
122
|
+
...(runId ? { MOA_RUN_ID: String(runId) } : {}),
|
|
106
123
|
NODE_PATH: process.env.NODE_PATH ? `${nodePath}:${process.env.NODE_PATH}` : nodePath,
|
|
107
|
-
}
|
|
124
|
+
})
|
|
108
125
|
|
|
109
126
|
const runPromise = new Promise((resolve) => {
|
|
110
127
|
let child
|
|
128
|
+
let timeoutHandle = null
|
|
129
|
+
let killEscalateHandle = null
|
|
130
|
+
|
|
111
131
|
try {
|
|
112
132
|
child = spawn(resolvedCmd, {
|
|
113
133
|
cwd: executionDir,
|
|
114
134
|
shell: true,
|
|
135
|
+
detached: process.platform !== 'win32',
|
|
115
136
|
env,
|
|
116
|
-
timeout: timeoutMs,
|
|
117
137
|
})
|
|
118
138
|
} catch (spawnErr) {
|
|
119
139
|
resolve({ exitCode: 1, stdout: '', stderr: spawnErr.message || String(spawnErr) })
|
|
@@ -122,15 +142,50 @@ export async function runCandidateTestGate({
|
|
|
122
142
|
|
|
123
143
|
const outChunks = []
|
|
124
144
|
const errChunks = []
|
|
145
|
+
let capturedBytes = 0
|
|
146
|
+
|
|
147
|
+
child.stdout?.on('data', (d) => {
|
|
148
|
+
if (capturedBytes < MAX_CAPTURE_BYTES) {
|
|
149
|
+
outChunks.push(d)
|
|
150
|
+
capturedBytes += d.length
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
child.stderr?.on('data', (d) => {
|
|
154
|
+
if (capturedBytes < MAX_CAPTURE_BYTES) {
|
|
155
|
+
errChunks.push(d)
|
|
156
|
+
capturedBytes += d.length
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const killChild = (sig = 'SIGTERM') => {
|
|
161
|
+
if (!child || !child.pid) return
|
|
162
|
+
bestEffort('test-gate kill child process group', () => {
|
|
163
|
+
if (process.platform !== 'win32') {
|
|
164
|
+
process.kill(-child.pid, sig)
|
|
165
|
+
} else {
|
|
166
|
+
child.kill(sig)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
}
|
|
125
170
|
|
|
126
|
-
|
|
127
|
-
|
|
171
|
+
if (timeoutMs > 0) {
|
|
172
|
+
timeoutHandle = setTimeout(() => {
|
|
173
|
+
killChild('SIGTERM')
|
|
174
|
+
killEscalateHandle = setTimeout(() => {
|
|
175
|
+
killChild('SIGKILL')
|
|
176
|
+
}, 1000)
|
|
177
|
+
}, timeoutMs)
|
|
178
|
+
}
|
|
128
179
|
|
|
129
180
|
child.on('error', (err) => {
|
|
181
|
+
if (timeoutHandle) clearTimeout(timeoutHandle)
|
|
182
|
+
if (killEscalateHandle) clearTimeout(killEscalateHandle)
|
|
130
183
|
resolve({ exitCode: 1, stdout: Buffer.concat(outChunks).toString('utf8'), stderr: err.message || String(err) })
|
|
131
184
|
})
|
|
132
185
|
|
|
133
186
|
child.on('close', (code, sig) => {
|
|
187
|
+
if (timeoutHandle) clearTimeout(timeoutHandle)
|
|
188
|
+
if (killEscalateHandle) clearTimeout(killEscalateHandle)
|
|
134
189
|
const combinedErr = Buffer.concat(errChunks).toString('utf8')
|
|
135
190
|
const finalErr = sig ? `${combinedErr}\nProcess terminated by signal ${sig}`.trim() : combinedErr
|
|
136
191
|
resolve({
|
|
@@ -154,9 +209,7 @@ export async function runCandidateTestGate({
|
|
|
154
209
|
: combinedOutput
|
|
155
210
|
|
|
156
211
|
const passed = exitCode === 0
|
|
157
|
-
const summary = passed
|
|
158
|
-
? `PASS (${durationMs}ms)`
|
|
159
|
-
: `FAIL (code ${exitCode}, ${durationMs}ms)`
|
|
212
|
+
const summary = passed ? `PASS (${durationMs}ms)` : `FAIL (code ${exitCode}, ${durationMs}ms)`
|
|
160
213
|
|
|
161
214
|
return {
|
|
162
215
|
candidateIndex,
|
|
@@ -199,12 +252,11 @@ export async function executeTestGateForCandidates({
|
|
|
199
252
|
}) {
|
|
200
253
|
const isEnabled = Boolean(options.testGateEnabled ?? preset.test_gate_enabled)
|
|
201
254
|
const testCmd = options.testCommand || preset.test_command
|
|
202
|
-
if (!isEnabled || !testCmd || !cwd)
|
|
203
|
-
return []
|
|
204
|
-
}
|
|
255
|
+
if (!isEnabled || !testCmd || !cwd) return []
|
|
205
256
|
|
|
206
257
|
const timeoutMs = (preset.test_gate_timeout_sec || 15) * 1000
|
|
207
258
|
const maxOutputChars = preset.test_gate_max_output_chars || 2000
|
|
259
|
+
const runId = options.runId || preset.runId
|
|
208
260
|
|
|
209
261
|
if (typeof onProgress === 'function') {
|
|
210
262
|
onProgress('🧪 *Test Execution Gate: Running test suite against candidate sandboxes...*\n')
|
|
@@ -221,6 +273,7 @@ export async function executeTestGateForCandidates({
|
|
|
221
273
|
timeoutMs,
|
|
222
274
|
maxOutputChars,
|
|
223
275
|
execFn: options.execFn,
|
|
276
|
+
runId,
|
|
224
277
|
})
|
|
225
278
|
|
|
226
279
|
if (res) {
|
|
@@ -234,4 +287,4 @@ export async function executeTestGateForCandidates({
|
|
|
234
287
|
}
|
|
235
288
|
|
|
236
289
|
return results
|
|
237
|
-
}
|
|
290
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -323,7 +323,9 @@ ctx.effect(() => {
|
|
|
323
323
|
return
|
|
324
324
|
}
|
|
325
325
|
const cwd = body?.cwd || process.cwd()
|
|
326
|
+
const runId = body?.runId
|
|
326
327
|
const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, {
|
|
328
|
+
runId,
|
|
327
329
|
keepMoa: true,
|
|
328
330
|
port: ctx.webServer?.port,
|
|
329
331
|
timeMachineEngine: ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
|
|
@@ -359,8 +361,9 @@ ctx.effect(() => {
|
|
|
359
361
|
const rawFile = u.searchParams.get('file') || ''
|
|
360
362
|
const file = (rawFile && isSafeRelativePath(rawFile)) ? rawFile : ''
|
|
361
363
|
|
|
362
|
-
const
|
|
363
|
-
const
|
|
364
|
+
const runId = u.searchParams.get('runId') || undefined
|
|
365
|
+
const fromFiles = await readCandidateFiles(cwd, from, { runId })
|
|
366
|
+
const toFiles = await readCandidateFiles(cwd, to, { runId })
|
|
364
367
|
|
|
365
368
|
// Collect all unique file paths
|
|
366
369
|
const allPaths = Array.from(new Set([
|