@goodandready/dsh-moa 0.2.37 → 0.2.39
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 +1 -1
- package/lib/file-workspace.js +195 -143
- package/lib/index.js +26 -19
- package/lib/moa-budget.js +65 -8
- package/lib/moa-parser.js +16 -3
- package/lib/moa-runner.js +30 -58
- package/lib/moa-stream.js +5 -3
- package/lib/moa-test-gate.js +94 -41
- package/lib/routes.js +18 -11
- 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
|
@@ -40,7 +40,7 @@ function readBody(req) {
|
|
|
40
40
|
})
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas, saveConfig, Config, plainConfig }) {
|
|
43
|
+
export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas, saveConfig, Config, plainConfig, webServerPort, timeMachineEngine }) {
|
|
44
44
|
|
|
45
45
|
ctx.effect(() => {
|
|
46
46
|
return ctx.webServer.register({
|
|
@@ -220,7 +220,12 @@ ctx.effect(() => {
|
|
|
220
220
|
|
|
221
221
|
// Persist via saveConfig (in-memory and DSH profile settings)
|
|
222
222
|
if (typeof saveConfig === 'function') {
|
|
223
|
-
|
|
223
|
+
try {
|
|
224
|
+
await saveConfig(validated)
|
|
225
|
+
} catch (perr) {
|
|
226
|
+
writeJson(res, 500, { ok: false, error: 'Settings persistence failed: ' + (perr?.message || String(perr)) })
|
|
227
|
+
return
|
|
228
|
+
}
|
|
224
229
|
}
|
|
225
230
|
|
|
226
231
|
const unwrapped = typeof plainConfig === 'function' ? plainConfig(validated) : validated
|
|
@@ -323,7 +328,9 @@ ctx.effect(() => {
|
|
|
323
328
|
return
|
|
324
329
|
}
|
|
325
330
|
const cwd = body?.cwd || process.cwd()
|
|
331
|
+
const runId = body?.runId
|
|
326
332
|
const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, {
|
|
333
|
+
runId,
|
|
327
334
|
keepMoa: true,
|
|
328
335
|
port: ctx.webServer?.port,
|
|
329
336
|
timeMachineEngine: ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
|
|
@@ -359,8 +366,9 @@ ctx.effect(() => {
|
|
|
359
366
|
const rawFile = u.searchParams.get('file') || ''
|
|
360
367
|
const file = (rawFile && isSafeRelativePath(rawFile)) ? rawFile : ''
|
|
361
368
|
|
|
362
|
-
const
|
|
363
|
-
const
|
|
369
|
+
const runId = u.searchParams.get('runId') || undefined
|
|
370
|
+
const fromFiles = await readCandidateFiles(cwd, from, { runId })
|
|
371
|
+
const toFiles = await readCandidateFiles(cwd, to, { runId })
|
|
364
372
|
|
|
365
373
|
// Collect all unique file paths
|
|
366
374
|
const allPaths = Array.from(new Set([
|
|
@@ -560,13 +568,12 @@ ctx.effect(() => {
|
|
|
560
568
|
const preset = (cfg.presets || []).find((p) => p.name === presetName) || cfg.presets?.[0]
|
|
561
569
|
|
|
562
570
|
const result = await runMoAPipeline({
|
|
563
|
-
userPrompt: prompt,
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
liveCanvas,
|
|
571
|
+
userPrompt: prompt, messages: payload.messages || [], preset, callLlm,
|
|
572
|
+
cwd: payload.cwd || process.cwd(), prices: cfg.prices || {}, liveCanvas,
|
|
573
|
+
webServerPort: webServerPort || ctx.webServer?.port,
|
|
574
|
+
timeMachineEngine: timeMachineEngine || ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
|
|
575
|
+
timeMachineUrl: payload.timeMachineUrl, force: payload.force,
|
|
576
|
+
checkpoint: payload.checkpoint, checkpointFn: payload.checkpointFn,
|
|
570
577
|
})
|
|
571
578
|
|
|
572
579
|
if (result?.skippedFiles > 0) {
|