@goodandready/dsh-moa 0.2.37 → 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/lib/file-workspace.js +195 -143
- package/lib/moa-runner.js +20 -39
- 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/file-workspace.js
CHANGED
|
@@ -2,7 +2,12 @@ import { bestEffort } from './best-effort.js'
|
|
|
2
2
|
import { logger } from './logger.js'
|
|
3
3
|
import vm from 'node:vm'
|
|
4
4
|
import fs from 'node:fs/promises'
|
|
5
|
+
import fsSync from 'node:fs'
|
|
5
6
|
import path from 'node:path'
|
|
7
|
+
/**
|
|
8
|
+
* In-memory serialization mutex to prevent interleaved promotions to the same baseDir.
|
|
9
|
+
*/
|
|
10
|
+
const promotionLocks = new Map()
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Validates that an untrusted relative path is safe and does not traverse upwards.
|
|
@@ -12,12 +17,12 @@ export function isSafeRelativePath(relPath) {
|
|
|
12
17
|
const trimmed = relPath.trim()
|
|
13
18
|
if (!trimmed || path.isAbsolute(trimmed) || trimmed.startsWith('/') || trimmed.startsWith('\\')) return false
|
|
14
19
|
const normalized = path.normalize(trimmed)
|
|
15
|
-
|
|
16
|
-
return true
|
|
20
|
+
return !(!normalized || normalized === '.' || normalized.startsWith('..') || path.isAbsolute(normalized))
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
|
-
* Asserts that a resolved path is strictly contained within baseDir
|
|
24
|
+
* Asserts that a resolved path is strictly contained within baseDir, verifying
|
|
25
|
+
* both lexical boundaries and physical canonical symlink resolution.
|
|
21
26
|
*/
|
|
22
27
|
export function assertPathContained(baseDir, subPath) {
|
|
23
28
|
const resolvedBase = path.resolve(baseDir)
|
|
@@ -25,18 +30,66 @@ export function assertPathContained(baseDir, subPath) {
|
|
|
25
30
|
if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) {
|
|
26
31
|
throw new Error(`Path traversal violation: "${subPath}" escapes base directory "${baseDir}"`)
|
|
27
32
|
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const realBase = fsSync.realpathSync(resolvedBase)
|
|
36
|
+
try {
|
|
37
|
+
const realTarget = fsSync.realpathSync(resolvedTarget)
|
|
38
|
+
if (realTarget !== realBase && !realTarget.startsWith(realBase + path.sep)) {
|
|
39
|
+
throw new Error(`Path traversal violation: symlink "${subPath}" resolves outside "${baseDir}"`)
|
|
40
|
+
}
|
|
41
|
+
} catch (targetErr) {
|
|
42
|
+
if (targetErr?.code === 'ENOENT') {
|
|
43
|
+
let curr = path.dirname(resolvedTarget)
|
|
44
|
+
while (curr === resolvedBase || curr.startsWith(resolvedBase + path.sep)) {
|
|
45
|
+
try {
|
|
46
|
+
const realCurr = fsSync.realpathSync(curr)
|
|
47
|
+
if (realCurr !== realBase && !realCurr.startsWith(realBase + path.sep)) {
|
|
48
|
+
throw new Error(`Path traversal violation: symlink ancestor in "${subPath}" resolves outside "${baseDir}"`)
|
|
49
|
+
}
|
|
50
|
+
break
|
|
51
|
+
} catch (ancestorErr) {
|
|
52
|
+
if (ancestorErr?.code === 'ENOENT') {
|
|
53
|
+
const parent = path.dirname(curr)
|
|
54
|
+
if (parent === curr) break
|
|
55
|
+
curr = parent
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
throw ancestorErr
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
throw targetErr
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (baseErr) {
|
|
66
|
+
if (baseErr?.message?.startsWith('Path traversal violation')) throw baseErr
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
return resolvedTarget
|
|
29
70
|
}
|
|
30
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Resolves directory paths for candidate sandboxes with optional runId scoping.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveCandidateFolder(baseDir, candidateTarget, options = {}) {
|
|
76
|
+
const targetStr = String(candidateTarget ?? 1).trim()
|
|
77
|
+
const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
78
|
+
const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
|
|
79
|
+
const rawRunId = typeof options === 'string' ? options : options?.runId
|
|
80
|
+
const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
|
|
81
|
+
const moaRoot = path.join(baseDir, '.moa')
|
|
82
|
+
const runRoot = safeRunId ? assertPathContained(moaRoot, safeRunId) : moaRoot
|
|
83
|
+
return { moaRoot, runRoot, safeRunId, sub, candidateDir: assertPathContained(runRoot, sub) }
|
|
84
|
+
}
|
|
85
|
+
|
|
31
86
|
/**
|
|
32
87
|
* Parses markdown code blocks that specify file paths.
|
|
33
88
|
*/
|
|
34
89
|
export function extractFileBlocks(text) {
|
|
35
90
|
if (!text || typeof text !== 'string') return []
|
|
36
|
-
|
|
37
91
|
const files = []
|
|
38
92
|
const seenPaths = new Set()
|
|
39
|
-
|
|
40
93
|
const addFile = (rawPath, fileContent) => {
|
|
41
94
|
if (rawPath && isSafeRelativePath(rawPath) && !seenPaths.has(rawPath)) {
|
|
42
95
|
seenPaths.add(rawPath)
|
|
@@ -44,24 +97,19 @@ export function extractFileBlocks(text) {
|
|
|
44
97
|
}
|
|
45
98
|
}
|
|
46
99
|
|
|
47
|
-
// Pattern 1: fenced blocks with file="..." or path="..." in info string
|
|
48
100
|
const fenceRegex = /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi
|
|
49
101
|
let match
|
|
50
102
|
while ((match = fenceRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
51
103
|
|
|
52
|
-
// Pattern 2: comment header: // File: path/to/file.ext or <!-- File: ... -->
|
|
53
104
|
const commentRegex = /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
|
|
54
105
|
while ((match = commentRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
55
106
|
|
|
56
|
-
// Pattern 3: markdown header: ### File: path/to/file.ext
|
|
57
107
|
const headerRegex = /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
|
|
58
108
|
while ((match = headerRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
59
109
|
|
|
60
|
-
// Pattern 4: first line inside code block: // File: path/to/file.ext
|
|
61
110
|
const inlineRegex = /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi
|
|
62
111
|
while ((match = inlineRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
63
112
|
|
|
64
|
-
// Fallback: single anonymous code block if it contains complete HTML
|
|
65
113
|
if (files.length === 0) {
|
|
66
114
|
const htmlBlockRegex = /```(?:html)?[\r\n]+([\s\S]*?)```/gi
|
|
67
115
|
let htmlMatch
|
|
@@ -73,7 +121,6 @@ export function extractFileBlocks(text) {
|
|
|
73
121
|
}
|
|
74
122
|
}
|
|
75
123
|
}
|
|
76
|
-
|
|
77
124
|
return files
|
|
78
125
|
}
|
|
79
126
|
|
|
@@ -99,13 +146,8 @@ export function formatProjectContext(projectFiles = [], options = {}) {
|
|
|
99
146
|
|
|
100
147
|
export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
101
148
|
if (!baseDir) return { files: [], totalChars: 0, skippedFiles: 0, skippedList: [] }
|
|
102
|
-
|
|
103
149
|
const ignoreDirs = new Set(['node_modules', '.git', '.moa', '.worktrees', 'dist', 'build', '.dsh', '.next', '.nuxt', '.cache', 'coverage', '.turbo', '.idea', '.vscode'])
|
|
104
|
-
const codeExts = new Set([
|
|
105
|
-
'.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
|
|
106
|
-
'.vue', '.svelte', '.json', '.py', '.md', '.txt', '.sh', '.yaml', '.yml',
|
|
107
|
-
])
|
|
108
|
-
|
|
150
|
+
const codeExts = new Set(['.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.json', '.py', '.md', '.txt', '.sh', '.yaml', '.yml'])
|
|
109
151
|
const collected = []
|
|
110
152
|
let totalChars = 0
|
|
111
153
|
let skippedFiles = 0
|
|
@@ -173,57 +215,48 @@ export function isRefinementTask(userPrompt = '', projectFiles = null) {
|
|
|
173
215
|
const p = userPrompt.toLowerCase().trim()
|
|
174
216
|
if (!p) return false
|
|
175
217
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
'from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new',
|
|
179
|
-
'从头开始', '新建项目', '创建项目', '新应用', '新建应用',
|
|
180
|
-
'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис',
|
|
181
|
-
'сделай проект', 'сделай приложение', 'сделай игру',
|
|
182
|
-
]
|
|
183
|
-
if (freshPhrases.some((phrase) => p.includes(phrase))) {
|
|
184
|
-
return false
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Modification keywords
|
|
188
|
-
const modKeywords = [
|
|
189
|
-
'add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust',
|
|
190
|
-
'style', 'css', 'color', 'theme', 'bug',
|
|
191
|
-
'添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题',
|
|
192
|
-
'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали',
|
|
193
|
-
'темн', 'светл', 'цвет', 'кнопк', 'баг',
|
|
194
|
-
]
|
|
218
|
+
const freshPhrases = ['from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new', '从头开始', '新建项目', '创建项目', '新应用', '新建应用', 'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис', 'сделай проект', 'сделай приложение', 'сделай игру']
|
|
219
|
+
if (freshPhrases.some((phrase) => p.includes(phrase))) return false
|
|
195
220
|
|
|
221
|
+
const modKeywords = ['add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust', 'style', 'css', 'color', 'theme', 'bug', '添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题', 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали', 'темн', 'светл', 'цвет', 'кнопк', 'баг']
|
|
196
222
|
return modKeywords.some((kw) => p.includes(kw))
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
/**
|
|
200
|
-
* Writes candidate files into an isolated candidate workspace
|
|
201
|
-
*
|
|
226
|
+
* Writes candidate files into an isolated candidate workspace.
|
|
227
|
+
* Uses atomic staging and swap to prevent stale file accumulation and dirty snapshots.
|
|
202
228
|
*/
|
|
203
|
-
export async function writeCandidateWorkspace(baseDir, candidateIndex, files = []) {
|
|
229
|
+
export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [], options = {}) {
|
|
204
230
|
if (!baseDir || !Array.isArray(files) || files.length === 0) return []
|
|
231
|
+
const { runRoot, sub, candidateDir } = resolveCandidateFolder(baseDir, candidateIndex, options)
|
|
232
|
+
await fs.mkdir(runRoot, { recursive: true })
|
|
205
233
|
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
const moaRoot = path.join(baseDir, '.moa')
|
|
210
|
-
const candidateDir = assertPathContained(moaRoot, sub)
|
|
211
|
-
await fs.mkdir(candidateDir, { recursive: true })
|
|
234
|
+
const tempSub = `.tmp-${sub}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
|
235
|
+
const tempCandidateDir = assertPathContained(runRoot, tempSub)
|
|
236
|
+
await fs.mkdir(tempCandidateDir, { recursive: true })
|
|
212
237
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
try {
|
|
239
|
+
const written = []
|
|
240
|
+
for (const file of files) {
|
|
241
|
+
if (!isSafeRelativePath(file.relativePath)) continue
|
|
242
|
+
const fullPath = assertPathContained(tempCandidateDir, file.relativePath)
|
|
243
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
244
|
+
await fs.writeFile(fullPath, file.content, 'utf8')
|
|
245
|
+
written.push({
|
|
246
|
+
relativePath: file.relativePath,
|
|
247
|
+
fullPath: path.join(candidateDir, file.relativePath),
|
|
248
|
+
size: Buffer.byteLength(file.content, 'utf8'),
|
|
249
|
+
})
|
|
250
|
+
}
|
|
225
251
|
|
|
226
|
-
|
|
252
|
+
if (written.length === 0 && files.length > 0) throw new Error('No valid files could be written to candidate workspace')
|
|
253
|
+
await fs.rm(candidateDir, { recursive: true, force: true })
|
|
254
|
+
await fs.rename(tempCandidateDir, candidateDir)
|
|
255
|
+
return written
|
|
256
|
+
} catch (err) {
|
|
257
|
+
await bestEffort('clean tempCandidateDir on error', () => fs.rm(tempCandidateDir, { recursive: true, force: true }))
|
|
258
|
+
throw err
|
|
259
|
+
}
|
|
227
260
|
}
|
|
228
261
|
|
|
229
262
|
/**
|
|
@@ -238,8 +271,6 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
|
|
|
238
271
|
}
|
|
239
272
|
|
|
240
273
|
const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
|
|
241
|
-
|
|
242
|
-
// 1. Direct Cordis timeMachineEngine service call if available
|
|
243
274
|
if (options.timeMachineEngine && typeof options.timeMachineEngine.createSnapshot === 'function') {
|
|
244
275
|
const snap = await bestEffort('dsh-time-machine direct service createSnapshot', async () => {
|
|
245
276
|
return await options.timeMachineEngine.createSnapshot(label, { cwd: baseDir })
|
|
@@ -247,7 +278,6 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
|
|
|
247
278
|
if (snap) return snap
|
|
248
279
|
}
|
|
249
280
|
|
|
250
|
-
// 2. HTTP endpoint call if port or timeMachineUrl is provided
|
|
251
281
|
const port = options.port || options.webServerPort || process.env.DSH_PORT || process.env.PORT
|
|
252
282
|
if (!port && !options.timeMachineUrl) {
|
|
253
283
|
logger.warn('[dsh-moa] Cannot create pre-promotion checkpoint: webServerPort is unknown and no timeMachineUrl provided')
|
|
@@ -255,7 +285,6 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
|
|
|
255
285
|
}
|
|
256
286
|
|
|
257
287
|
const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
|
|
258
|
-
|
|
259
288
|
const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
|
|
260
289
|
const res = await fetch(endpoint, {
|
|
261
290
|
method: 'POST',
|
|
@@ -272,20 +301,39 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
|
|
|
272
301
|
return snap || null
|
|
273
302
|
}
|
|
274
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Promotes winning candidate workspace files into the base workspace.
|
|
306
|
+
* Serialized per baseDir and verifies symlink boundaries on promotion.
|
|
307
|
+
*/
|
|
275
308
|
export async function promoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
|
|
276
309
|
if (!baseDir || winningIndex === null || winningIndex === undefined) return []
|
|
277
310
|
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
311
|
+
const key = path.resolve(baseDir)
|
|
312
|
+
const prevLock = promotionLocks.get(key) || Promise.resolve()
|
|
313
|
+
let releaseLock
|
|
314
|
+
const currentLock = new Promise((resolve) => { releaseLock = resolve })
|
|
315
|
+
promotionLocks.set(key, currentLock)
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
await prevLock
|
|
319
|
+
return await doPromoteCandidateWorkspace(baseDir, winningIndex, options)
|
|
320
|
+
} finally {
|
|
321
|
+
releaseLock()
|
|
322
|
+
if (promotionLocks.get(key) === currentLock) {
|
|
323
|
+
promotionLocks.delete(key)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function doPromoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
|
|
329
|
+
const { runRoot, moaRoot, safeRunId, candidateDir: winningDir } = resolveCandidateFolder(baseDir, winningIndex, options)
|
|
330
|
+
const cleanupTarget = safeRunId ? runRoot : moaRoot
|
|
283
331
|
|
|
284
332
|
try {
|
|
285
333
|
const entries = await getFilesRecursively(winningDir)
|
|
286
334
|
if (entries.length === 0) {
|
|
287
335
|
if (!options.keepMoa) {
|
|
288
|
-
await bestEffort('clean
|
|
336
|
+
await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
|
|
289
337
|
}
|
|
290
338
|
return []
|
|
291
339
|
}
|
|
@@ -298,35 +346,61 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
|
|
|
298
346
|
}
|
|
299
347
|
|
|
300
348
|
const promoted = []
|
|
349
|
+
const baseReal = await fs.realpath(baseDir).catch(() => path.resolve(baseDir))
|
|
350
|
+
|
|
301
351
|
for (const rel of entries) {
|
|
302
352
|
if (!isSafeRelativePath(rel)) continue
|
|
303
353
|
const srcPath = assertPathContained(winningDir, rel)
|
|
304
354
|
const destPath = assertPathContained(baseDir, rel)
|
|
305
355
|
|
|
356
|
+
try {
|
|
357
|
+
const destLstat = await fs.lstat(destPath)
|
|
358
|
+
if (destLstat.isSymbolicLink()) {
|
|
359
|
+
const destReal = await fs.realpath(destPath)
|
|
360
|
+
if (destReal !== baseReal && !destReal.startsWith(baseReal + path.sep)) {
|
|
361
|
+
throw new Error(`Path traversal violation: promotion destination symlink "${rel}" resolves outside "${baseDir}"`)
|
|
362
|
+
}
|
|
363
|
+
await fs.unlink(destPath)
|
|
364
|
+
}
|
|
365
|
+
} catch (lstatErr) {
|
|
366
|
+
if (lstatErr?.code !== 'ENOENT') throw lstatErr
|
|
367
|
+
}
|
|
368
|
+
|
|
306
369
|
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
|
307
370
|
await fs.copyFile(srcPath, destPath)
|
|
308
371
|
promoted.push(rel)
|
|
309
372
|
}
|
|
310
373
|
|
|
311
374
|
if (checkpoint) promoted.checkpoint = checkpoint
|
|
312
|
-
if (!options.keepMoa) await fs.rm(
|
|
375
|
+
if (!options.keepMoa) await fs.rm(cleanupTarget, { recursive: true, force: true })
|
|
313
376
|
|
|
314
377
|
return promoted
|
|
315
378
|
} catch (err) {
|
|
316
379
|
if (!options.keepMoa) {
|
|
317
|
-
await bestEffort('clean
|
|
380
|
+
await bestEffort('clean candidate on promote error', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
|
|
318
381
|
}
|
|
319
382
|
throw err
|
|
320
383
|
}
|
|
321
384
|
}
|
|
322
385
|
|
|
323
386
|
/**
|
|
324
|
-
* Cleans up
|
|
387
|
+
* Cleans up candidate workspaces, optionally scoped to a single runId.
|
|
325
388
|
*/
|
|
326
|
-
export async function cleanMoaWorkspaces(baseDir) {
|
|
389
|
+
export async function cleanMoaWorkspaces(baseDir, options = {}) {
|
|
327
390
|
if (!baseDir) return
|
|
391
|
+
const rawRunId = typeof options === 'string' ? options : options?.runId
|
|
392
|
+
const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
|
|
328
393
|
const moaRoot = path.join(baseDir, '.moa')
|
|
329
|
-
|
|
394
|
+
if (safeRunId) {
|
|
395
|
+
const runRoot = path.join(moaRoot, safeRunId)
|
|
396
|
+
await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(runRoot, { recursive: true, force: true }))
|
|
397
|
+
await bestEffort('clean empty moaRoot', async () => {
|
|
398
|
+
const remaining = await fs.readdir(moaRoot)
|
|
399
|
+
if (remaining.length === 0) await fs.rmdir(moaRoot)
|
|
400
|
+
})
|
|
401
|
+
} else {
|
|
402
|
+
await bestEffort('cleanMoaWorkspaces', () => fs.rm(moaRoot, { recursive: true, force: true }))
|
|
403
|
+
}
|
|
330
404
|
}
|
|
331
405
|
|
|
332
406
|
async function getFilesRecursively(dir) {
|
|
@@ -338,6 +412,13 @@ async function getFilesRecursively(dir) {
|
|
|
338
412
|
return []
|
|
339
413
|
}
|
|
340
414
|
|
|
415
|
+
let realDir
|
|
416
|
+
try {
|
|
417
|
+
realDir = await fs.realpath(dir)
|
|
418
|
+
} catch {
|
|
419
|
+
realDir = path.resolve(dir)
|
|
420
|
+
}
|
|
421
|
+
|
|
341
422
|
async function scan(current, relPrefix = '') {
|
|
342
423
|
let list
|
|
343
424
|
try {
|
|
@@ -345,6 +426,18 @@ async function getFilesRecursively(dir) {
|
|
|
345
426
|
for (const item of list) {
|
|
346
427
|
const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
|
|
347
428
|
const full = path.join(current, item.name)
|
|
429
|
+
|
|
430
|
+
if (item.isSymbolicLink()) {
|
|
431
|
+
try {
|
|
432
|
+
const realTarget = await fs.realpath(full)
|
|
433
|
+
if (realTarget === realDir || realTarget.startsWith(realDir + path.sep)) {
|
|
434
|
+
const st = await fs.stat(full)
|
|
435
|
+
if (st.isFile()) results.push(rel)
|
|
436
|
+
}
|
|
437
|
+
} catch (err) { logger.debug?.('[dsh-moa] symlink inspect skipped', err) }
|
|
438
|
+
continue
|
|
439
|
+
}
|
|
440
|
+
|
|
348
441
|
if (item.isDirectory()) await scan(full, rel)
|
|
349
442
|
else if (item.isFile()) results.push(rel)
|
|
350
443
|
}
|
|
@@ -377,11 +470,7 @@ export function verifyFileSyntax(files = []) {
|
|
|
377
470
|
.replace(/^([ \t]*)export\s+\*\s+from\s+[^;\n]+;?/gm, '$1')
|
|
378
471
|
new vm.Script(transformed)
|
|
379
472
|
} catch (err) {
|
|
380
|
-
warnings.push({
|
|
381
|
-
file: file.relativePath,
|
|
382
|
-
error: err?.message || String(err),
|
|
383
|
-
line: err?.lineNumber || 1,
|
|
384
|
-
})
|
|
473
|
+
warnings.push({ file: file.relativePath, error: err?.message || String(err), line: err?.lineNumber || 1 })
|
|
385
474
|
}
|
|
386
475
|
} else if (ext === '.json') {
|
|
387
476
|
try {
|
|
@@ -395,23 +484,18 @@ export function verifyFileSyntax(files = []) {
|
|
|
395
484
|
}
|
|
396
485
|
}
|
|
397
486
|
}
|
|
398
|
-
|
|
399
487
|
return warnings
|
|
400
488
|
}
|
|
401
489
|
|
|
402
490
|
/**
|
|
403
|
-
* Reads all files from a candidate workspace (.moa/candidate-N
|
|
491
|
+
* Reads all files from a candidate workspace (.moa/candidate-N, .moa/curator-synthesis, or scoped under runId).
|
|
404
492
|
*/
|
|
405
|
-
export async function readCandidateFiles(baseDir, candidateTarget) {
|
|
493
|
+
export async function readCandidateFiles(baseDir, candidateTarget, options = {}) {
|
|
406
494
|
if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
|
|
407
|
-
const targetStr = String(candidateTarget).trim()
|
|
408
|
-
const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
409
|
-
const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
|
|
410
|
-
const moaRoot = path.join(baseDir, '.moa')
|
|
411
|
-
|
|
412
495
|
let targetDir
|
|
413
496
|
try {
|
|
414
|
-
|
|
497
|
+
const resolved = resolveCandidateFolder(baseDir, candidateTarget, options)
|
|
498
|
+
targetDir = resolved.candidateDir
|
|
415
499
|
} catch {
|
|
416
500
|
return []
|
|
417
501
|
}
|
|
@@ -419,16 +503,21 @@ export async function readCandidateFiles(baseDir, candidateTarget) {
|
|
|
419
503
|
try {
|
|
420
504
|
const relFiles = await getFilesRecursively(targetDir)
|
|
421
505
|
const result = []
|
|
506
|
+
const realTarget = await fs.realpath(targetDir).catch(() => targetDir)
|
|
507
|
+
|
|
422
508
|
for (const rel of relFiles) {
|
|
423
509
|
if (!isSafeRelativePath(rel)) continue
|
|
424
510
|
try {
|
|
425
511
|
const fullPath = assertPathContained(targetDir, rel)
|
|
512
|
+
const st = await fs.lstat(fullPath)
|
|
513
|
+
if (st.isSymbolicLink()) {
|
|
514
|
+
const realFile = await fs.realpath(fullPath)
|
|
515
|
+
if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep)) {
|
|
516
|
+
continue
|
|
517
|
+
}
|
|
518
|
+
}
|
|
426
519
|
const content = await fs.readFile(fullPath, 'utf8')
|
|
427
|
-
result.push({
|
|
428
|
-
relativePath: rel,
|
|
429
|
-
content,
|
|
430
|
-
size: Buffer.byteLength(content, 'utf8'),
|
|
431
|
-
})
|
|
520
|
+
result.push({ relativePath: rel, content, size: Buffer.byteLength(content, 'utf8') })
|
|
432
521
|
} catch {
|
|
433
522
|
// file read error best-effort
|
|
434
523
|
}
|
|
@@ -442,95 +531,59 @@ export async function readCandidateFiles(baseDir, candidateTarget) {
|
|
|
442
531
|
/**
|
|
443
532
|
* Computes a line-by-line diff between two text strings using LCS with
|
|
444
533
|
* common prefix and suffix trimming for optimal O(delta) performance.
|
|
445
|
-
* Returns an array of diff items: { type: 'same' | 'added' | 'removed', line: string, oldNum?: number, newNum?: number }
|
|
446
534
|
*/
|
|
447
535
|
export function computeLineDiff(oldText = '', newText = '') {
|
|
448
536
|
const oldLines = typeof oldText === 'string' ? oldText.split(/\r?\n/) : []
|
|
449
537
|
const newLines = typeof newText === 'string' ? newText.split(/\r?\n/) : []
|
|
450
|
-
|
|
451
|
-
const n = oldLines.length
|
|
452
|
-
const m = newLines.length
|
|
453
|
-
|
|
538
|
+
const n = oldLines.length, m = newLines.length
|
|
454
539
|
if (n === 0 && m === 0) return []
|
|
455
540
|
if (oldText === newText) {
|
|
456
|
-
return oldLines.map((line, idx) => ({
|
|
457
|
-
type: 'same',
|
|
458
|
-
line,
|
|
459
|
-
oldNum: idx + 1,
|
|
460
|
-
newNum: idx + 1,
|
|
461
|
-
}))
|
|
541
|
+
return oldLines.map((line, idx) => ({ type: 'same', line, oldNum: idx + 1, newNum: idx + 1 }))
|
|
462
542
|
}
|
|
463
543
|
|
|
464
|
-
// 1. Trim common prefix
|
|
465
544
|
let start = 0
|
|
466
|
-
while (start < n && start < m && oldLines[start] === newLines[start])
|
|
467
|
-
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// 2. Trim common suffix
|
|
471
|
-
let oldEnd = n - 1
|
|
472
|
-
let newEnd = m - 1
|
|
545
|
+
while (start < n && start < m && oldLines[start] === newLines[start]) start++
|
|
546
|
+
let oldEnd = n - 1, newEnd = m - 1
|
|
473
547
|
while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
|
|
474
548
|
oldEnd--
|
|
475
549
|
newEnd--
|
|
476
550
|
}
|
|
477
551
|
|
|
478
552
|
const prefixItems = []
|
|
479
|
-
for (let k = 0; k < start; k++) {
|
|
480
|
-
prefixItems.push({ type: 'same', line: oldLines[k], oldNum: k + 1, newNum: k + 1 })
|
|
481
|
-
}
|
|
553
|
+
for (let k = 0; k < start; k++) prefixItems.push({ type: 'same', line: oldLines[k], oldNum: k + 1, newNum: k + 1 })
|
|
482
554
|
|
|
483
555
|
const suffixItems = []
|
|
484
556
|
const suffixLen = (n - 1) - oldEnd
|
|
485
557
|
for (let k = 0; k < suffixLen; k++) {
|
|
486
|
-
|
|
487
|
-
const nNum = newEnd + 1 + k + 1
|
|
488
|
-
suffixItems.push({ type: 'same', line: oldLines[oldEnd + 1 + k], oldNum: oNum, newNum: nNum })
|
|
558
|
+
suffixItems.push({ type: 'same', line: oldLines[oldEnd + 1 + k], oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k })
|
|
489
559
|
}
|
|
490
560
|
|
|
491
561
|
const subOld = oldLines.slice(start, oldEnd + 1)
|
|
492
562
|
const subNew = newLines.slice(start, newEnd + 1)
|
|
493
|
-
const subN = subOld.length
|
|
494
|
-
const subM = subNew.length
|
|
495
|
-
|
|
563
|
+
const subN = subOld.length, subM = subNew.length
|
|
496
564
|
const middleItems = []
|
|
497
565
|
|
|
498
566
|
if (subN === 0) {
|
|
499
|
-
for (let j = 0; j < subM; j++) {
|
|
500
|
-
middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
|
|
501
|
-
}
|
|
567
|
+
for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
|
|
502
568
|
} else if (subM === 0) {
|
|
503
|
-
for (let i = 0; i < subN; i++) {
|
|
504
|
-
middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
|
|
505
|
-
}
|
|
569
|
+
for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
|
|
506
570
|
} else if (subN > 2000 || subM > 2000) {
|
|
507
|
-
for (let i = 0; i < subN; i++) {
|
|
508
|
-
|
|
509
|
-
}
|
|
510
|
-
for (let j = 0; j < subM; j++) {
|
|
511
|
-
middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
|
|
512
|
-
}
|
|
571
|
+
for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
|
|
572
|
+
for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
|
|
513
573
|
} else {
|
|
514
574
|
const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
|
|
515
575
|
for (let i = 0; i < subN; i++) {
|
|
516
576
|
for (let j = 0; j < subM; j++) {
|
|
517
|
-
|
|
518
|
-
dp[i + 1][j + 1] = dp[i][j] + 1
|
|
519
|
-
} else {
|
|
520
|
-
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1])
|
|
521
|
-
}
|
|
577
|
+
dp[i + 1][j + 1] = subOld[i] === subNew[j] ? dp[i][j] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
|
|
522
578
|
}
|
|
523
579
|
}
|
|
524
580
|
|
|
525
|
-
let i = subN
|
|
526
|
-
let j = subM
|
|
581
|
+
let i = subN, j = subM
|
|
527
582
|
const stack = []
|
|
528
|
-
|
|
529
583
|
while (i > 0 || j > 0) {
|
|
530
584
|
if (i > 0 && j > 0 && subOld[i - 1] === subNew[j - 1]) {
|
|
531
585
|
stack.push({ type: 'same', line: subOld[i - 1], oldNum: start + i, newNum: start + j })
|
|
532
|
-
i--
|
|
533
|
-
j--
|
|
586
|
+
i--; j--
|
|
534
587
|
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
535
588
|
stack.push({ type: 'added', line: subNew[j - 1], newNum: start + j })
|
|
536
589
|
j--
|
|
@@ -539,7 +592,6 @@ export function computeLineDiff(oldText = '', newText = '') {
|
|
|
539
592
|
i--
|
|
540
593
|
}
|
|
541
594
|
}
|
|
542
|
-
|
|
543
595
|
middleItems.push(...stack.reverse())
|
|
544
596
|
}
|
|
545
597
|
|
package/lib/moa-runner.js
CHANGED
|
@@ -12,7 +12,6 @@ import { executeMultiJudgePanel, buildCompositeBlockDirectives } from './moa-mul
|
|
|
12
12
|
import { applyBudgetGuardrails } from './moa-budget.js'
|
|
13
13
|
import { extractPriorTurnBaseline, pruneMultiTurnMessages } from './moa-context.js'
|
|
14
14
|
import { generateMoABenchmarkReport } from './moa-report.js'
|
|
15
|
-
|
|
16
15
|
// Re-exports for consumers & backward compatibility
|
|
17
16
|
export { slotLabel, cleanAdvisoryMessages, isBroadPromptRequiringQuestions, buildQuestionSynthesisPrompt, buildCuratorSynthesisPrompt, buildSynthesisPrompt, ANTIPATTERNS_RUBRIC } from './moa-prompts.js'
|
|
18
17
|
export { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSummarizeCode, formatMoAResponse } from './moa-parser.js'
|
|
@@ -20,7 +19,6 @@ export { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
|
|
|
20
19
|
export { REFERENCE_SYSTEM_PROMPT, callWithTransientRetry, runReferencesParallel } from './moa-candidates.js'
|
|
21
20
|
export { executeTestGateForCandidates, runCandidateTestGate } from './moa-test-gate.js'
|
|
22
21
|
export { streamMoATurn } from './moa-stream.js'
|
|
23
|
-
|
|
24
22
|
/**
|
|
25
23
|
* Executes the full Mixture of Agents pipeline.
|
|
26
24
|
*/
|
|
@@ -45,6 +43,7 @@ export async function runMoAPipeline({
|
|
|
45
43
|
force = isTestEnvironment() ? true : false,
|
|
46
44
|
checkpoint = true,
|
|
47
45
|
checkpointFn = null,
|
|
46
|
+
runId: customRunId = null,
|
|
48
47
|
}) {
|
|
49
48
|
const promoteOpts = {
|
|
50
49
|
webServerPort,
|
|
@@ -56,16 +55,13 @@ export async function runMoAPipeline({
|
|
|
56
55
|
}
|
|
57
56
|
if (signal?.aborted) return { error: new Error('Turn aborted') }
|
|
58
57
|
const startTime = Date.now()
|
|
59
|
-
|
|
60
58
|
// 1. Resolve configurations
|
|
61
59
|
const referenceModels = Array.isArray(preset?.reference_models) && preset.reference_models.length > 0
|
|
62
60
|
? preset.reference_models : [{ provider: '', model: '' }]
|
|
63
61
|
const primaryJudge = preset?.aggregator?.provider && preset?.aggregator?.model
|
|
64
62
|
? preset.aggregator : { provider: '', model: '' }
|
|
65
|
-
|
|
66
63
|
const fallbackJudges = Array.isArray(preset?.aggregator_fallbacks) ? preset.aggregator_fallbacks : []
|
|
67
64
|
const judgesChain = [primaryJudge, ...fallbackJudges]
|
|
68
|
-
|
|
69
65
|
const refTemp = typeof preset?.reference_temperature === 'number' ? preset.reference_temperature : 0.6
|
|
70
66
|
const aggTemp = typeof preset?.aggregator_temperature === 'number' ? preset.aggregator_temperature : 0.4
|
|
71
67
|
const maxTokens = typeof preset?.max_tokens === 'number' ? preset.max_tokens : 4096
|
|
@@ -76,8 +72,7 @@ export async function runMoAPipeline({
|
|
|
76
72
|
const candidateRetries = 1, refTimeoutSec = typeof preset?.reference_timeout_sec === 'number' ? preset.reference_timeout_sec : 60
|
|
77
73
|
const aggTimeoutSec = typeof preset?.aggregator_timeout_sec === 'number' ? preset.aggregator_timeout_sec : 180
|
|
78
74
|
const isBlindEvaluation = Boolean(preset?.blind_evaluation), isPeerCritiqueEnabled = Boolean(preset?.peer_critique_enabled)
|
|
79
|
-
const allowCandidateOverride = Boolean(preset?.allow_candidate_override), runId =
|
|
80
|
-
|
|
75
|
+
const allowCandidateOverride = Boolean(preset?.allow_candidate_override), runId = customRunId || null
|
|
81
76
|
// Feature 6: Budget Guardrail check
|
|
82
77
|
const budgetCheck = applyBudgetGuardrails({
|
|
83
78
|
references: referenceModels,
|
|
@@ -109,7 +104,6 @@ export async function runMoAPipeline({
|
|
|
109
104
|
if (budgetCheck.action === 'trim' && typeof onProgress === 'function') {
|
|
110
105
|
onProgress(`✂️ *${budgetCheck.reason}*\n`)
|
|
111
106
|
}
|
|
112
|
-
|
|
113
107
|
// 2. Collect project context for refinement tasks
|
|
114
108
|
const collectedCtx = await collectProjectContext(cwd, 16000)
|
|
115
109
|
const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
|
|
@@ -128,19 +122,15 @@ export async function runMoAPipeline({
|
|
|
128
122
|
skippedList: collectedCtx.skippedList,
|
|
129
123
|
})
|
|
130
124
|
}
|
|
131
|
-
|
|
132
125
|
// 3. Build prompts & Feature 8 multi-turn context
|
|
133
126
|
const priorTurnBaseline = preset?.multi_turn_enabled !== false ? extractPriorTurnBaseline(messages) : null
|
|
134
127
|
const prunedHistory = preset?.multi_turn_enabled !== false ? pruneMultiTurnMessages(messages) : messages
|
|
135
|
-
|
|
136
128
|
const candidateSystemPrompt = projectContext
|
|
137
129
|
? `${REFERENCE_SYSTEM_PROMPT}\n\n### Current Project Files & Context:\n${projectContext}`
|
|
138
130
|
: REFERENCE_SYSTEM_PROMPT
|
|
139
|
-
|
|
140
131
|
const askClarifyingQuestions = preset?.ask_clarifying_questions !== false
|
|
141
132
|
const needsQuestions = askClarifyingQuestions && isBroadPromptRequiringQuestions(userPrompt, messages)
|
|
142
133
|
const enrichedMessages = [...prunedHistory, { role: 'user', content: userPrompt }]
|
|
143
|
-
|
|
144
134
|
// 4. Parallel fan-out to candidate models with temperature gradient & local fallback
|
|
145
135
|
const referenceOutputs = await runReferencesParallel(
|
|
146
136
|
effectiveReferences,
|
|
@@ -162,12 +152,10 @@ export async function runMoAPipeline({
|
|
|
162
152
|
callLlm,
|
|
163
153
|
onProgress
|
|
164
154
|
)
|
|
165
|
-
|
|
166
155
|
if (signal?.aborted) {
|
|
167
|
-
await cleanMoaWorkspaces(cwd)
|
|
156
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
168
157
|
return { error: new Error('Turn aborted') }
|
|
169
158
|
}
|
|
170
|
-
|
|
171
159
|
// Fail fast if all candidates failed
|
|
172
160
|
const successfulRefs = referenceOutputs.filter((r) => r.ok)
|
|
173
161
|
if (successfulRefs.length === 0) {
|
|
@@ -194,7 +182,6 @@ export async function runMoAPipeline({
|
|
|
194
182
|
durationMs: Date.now() - startTime,
|
|
195
183
|
}
|
|
196
184
|
}
|
|
197
|
-
|
|
198
185
|
// 5. Extract file blocks & write candidate workspaces
|
|
199
186
|
for (let i = 0; i < referenceOutputs.length; i++) {
|
|
200
187
|
const ref = referenceOutputs[i]
|
|
@@ -203,24 +190,21 @@ export async function runMoAPipeline({
|
|
|
203
190
|
ref.files = files
|
|
204
191
|
if (files.length > 0) {
|
|
205
192
|
ref.syntaxWarning = (verifyFileSyntax(files) || []).map((w) => `${w.file}: ${w.error}`).join('; ')
|
|
206
|
-
await writeCandidateWorkspace(cwd, i + 1, files)
|
|
193
|
+
await writeCandidateWorkspace(cwd, i + 1, files, { runId })
|
|
207
194
|
}
|
|
208
195
|
}
|
|
209
|
-
|
|
210
196
|
if (signal?.aborted) {
|
|
211
|
-
await cleanMoaWorkspaces(cwd)
|
|
197
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
212
198
|
return { error: new Error('Turn aborted') }
|
|
213
199
|
}
|
|
214
|
-
|
|
215
200
|
// 5b. Test Execution Gate
|
|
216
201
|
await executeTestGateForCandidates({
|
|
217
202
|
cwd,
|
|
218
203
|
referenceOutputs,
|
|
219
204
|
preset,
|
|
220
|
-
options: { testGateEnabled, testCommand, execFn },
|
|
205
|
+
options: { testGateEnabled, testCommand, execFn, runId },
|
|
221
206
|
onProgress,
|
|
222
207
|
})
|
|
223
|
-
|
|
224
208
|
// 6. Questionnaire synthesis branch
|
|
225
209
|
if (needsQuestions) {
|
|
226
210
|
if (typeof onProgress === 'function') {
|
|
@@ -248,12 +232,10 @@ export async function runMoAPipeline({
|
|
|
248
232
|
logger.warn('[dsh-moa] Questionnaire synthesis failed, proceeding with fallback questions:', err)
|
|
249
233
|
questionsContent = `### Clarification of Requirements: "${userPrompt}"\n\n1. **Architecture & Scope**: Single-file deliverable or multi-module project structure?\n2. **Design & Style**: Minimalist, dark mode, or clean neutral theme?\n3. **Functional Priorities**: Core MVP or comprehensive extended implementation?\n\n*Reply with your preferences (e.g. "1, 2") or proceed with defaults.*`
|
|
250
234
|
}
|
|
251
|
-
|
|
252
|
-
await cleanMoaWorkspaces(cwd)
|
|
235
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
253
236
|
const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + qUsage.totalTokens
|
|
254
237
|
const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + qUsage.costUsd).toFixed(5))
|
|
255
238
|
const durationMs = Date.now() - startTime
|
|
256
|
-
|
|
257
239
|
try {
|
|
258
240
|
await recordMoaRunAsync({
|
|
259
241
|
prompt: userPrompt,
|
|
@@ -271,7 +253,6 @@ export async function runMoAPipeline({
|
|
|
271
253
|
} catch (histErr) {
|
|
272
254
|
logger.warn('[dsh-moa] Failed to record questionnaire run in history:', histErr?.message || histErr)
|
|
273
255
|
}
|
|
274
|
-
|
|
275
256
|
return {
|
|
276
257
|
kind: 'questions',
|
|
277
258
|
content: questionsContent,
|
|
@@ -298,15 +279,15 @@ export async function runMoAPipeline({
|
|
|
298
279
|
// 7. Fast Mode (1 candidate, bypass judge)
|
|
299
280
|
if (isFastMode) {
|
|
300
281
|
if (signal?.aborted) {
|
|
301
|
-
await cleanMoaWorkspaces(cwd)
|
|
282
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
302
283
|
return { error: new Error('Turn aborted') }
|
|
303
284
|
}
|
|
304
285
|
const single = successfulRefs[0]
|
|
305
286
|
let promotedFiles = []
|
|
306
287
|
if (single.files?.length > 0) {
|
|
307
|
-
promotedFiles = await promoteCandidateWorkspace(cwd, single.index, promoteOpts)
|
|
288
|
+
promotedFiles = await promoteCandidateWorkspace(cwd, single.index, { ...promoteOpts, runId })
|
|
308
289
|
} else {
|
|
309
|
-
await cleanMoaWorkspaces(cwd)
|
|
290
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
310
291
|
}
|
|
311
292
|
|
|
312
293
|
const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
|
|
@@ -358,7 +339,7 @@ export async function runMoAPipeline({
|
|
|
358
339
|
}
|
|
359
340
|
|
|
360
341
|
if (signal?.aborted) {
|
|
361
|
-
await cleanMoaWorkspaces(cwd)
|
|
342
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
362
343
|
return { error: new Error('Turn aborted') }
|
|
363
344
|
}
|
|
364
345
|
|
|
@@ -393,7 +374,7 @@ export async function runMoAPipeline({
|
|
|
393
374
|
if (r2Files.length > 0) {
|
|
394
375
|
cand.files = r2Files
|
|
395
376
|
cand.syntaxWarning = (verifyFileSyntax(r2Files) || []).map((w) => `${w.file}: ${w.error}`).join('; ')
|
|
396
|
-
await writeCandidateWorkspace(cwd, cand.index, r2Files)
|
|
377
|
+
await writeCandidateWorkspace(cwd, cand.index, r2Files, { runId })
|
|
397
378
|
}
|
|
398
379
|
if (typeof res === 'object' && res?.usage) {
|
|
399
380
|
cand.usage.inputTokens = (cand.usage.inputTokens || 0) + (res.usage.inputTokens || 0)
|
|
@@ -411,7 +392,7 @@ export async function runMoAPipeline({
|
|
|
411
392
|
cwd,
|
|
412
393
|
referenceOutputs: successfulRefs,
|
|
413
394
|
preset,
|
|
414
|
-
options: { testGateEnabled, testCommand, execFn },
|
|
395
|
+
options: { testGateEnabled, testCommand, execFn, runId },
|
|
415
396
|
onProgress,
|
|
416
397
|
})
|
|
417
398
|
}
|
|
@@ -419,7 +400,7 @@ export async function runMoAPipeline({
|
|
|
419
400
|
// 7c. Feature 2: Multi-Judge Panel & Consensus Voting
|
|
420
401
|
let multiJudgeResult = null
|
|
421
402
|
if (signal?.aborted) {
|
|
422
|
-
await cleanMoaWorkspaces(cwd)
|
|
403
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
423
404
|
return { error: new Error('Turn aborted') }
|
|
424
405
|
}
|
|
425
406
|
|
|
@@ -508,7 +489,7 @@ export async function runMoAPipeline({
|
|
|
508
489
|
}
|
|
509
490
|
|
|
510
491
|
if (signal?.aborted) {
|
|
511
|
-
await cleanMoaWorkspaces(cwd)
|
|
492
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
512
493
|
return { error: new Error('Turn aborted') }
|
|
513
494
|
}
|
|
514
495
|
|
|
@@ -527,15 +508,15 @@ export async function runMoAPipeline({
|
|
|
527
508
|
|
|
528
509
|
const keepWorkspaces = allowCandidateOverride
|
|
529
510
|
if (synthesizedFiles.length > 0) {
|
|
530
|
-
await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles)
|
|
531
|
-
promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis', { ...promoteOpts, keepMoa: keepWorkspaces })
|
|
511
|
+
await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles, { runId })
|
|
512
|
+
promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis', { ...promoteOpts, runId, keepMoa: keepWorkspaces })
|
|
532
513
|
} else if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
|
|
533
|
-
promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex, { ...promoteOpts, keepMoa: keepWorkspaces })
|
|
514
|
+
promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex, { ...promoteOpts, runId, keepMoa: keepWorkspaces })
|
|
534
515
|
} else if (successfulRefs[0]?.files?.length > 0) {
|
|
535
516
|
const fallbackIdx = successfulRefs[0].index
|
|
536
|
-
promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx, { ...promoteOpts, keepMoa: keepWorkspaces })
|
|
517
|
+
promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx, { ...promoteOpts, runId, keepMoa: keepWorkspaces })
|
|
537
518
|
} else if (!keepWorkspaces) {
|
|
538
|
-
await cleanMoaWorkspaces(cwd)
|
|
519
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
539
520
|
}
|
|
540
521
|
|
|
541
522
|
const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
|
package/lib/moa-stream.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
1
2
|
/**
|
|
2
3
|
* Zero-latency Live Streaming Generator for MoA Turns
|
|
3
4
|
*/
|
|
@@ -11,6 +12,7 @@ import { runMoAPipeline } from './moa-runner.js'
|
|
|
11
12
|
*/
|
|
12
13
|
export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas, webServerPort, timeMachineEngine, force }, options = {}) {
|
|
13
14
|
const signal = options?.signal
|
|
15
|
+
const runId = options?.runId || null
|
|
14
16
|
if (signal?.aborted) return
|
|
15
17
|
|
|
16
18
|
const internalAbortCtrl = new AbortController()
|
|
@@ -69,7 +71,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
|
|
|
69
71
|
|
|
70
72
|
while (!done || queue.length > 0) {
|
|
71
73
|
if (combinedSignal.aborted) {
|
|
72
|
-
await cleanMoaWorkspaces(cwd)
|
|
74
|
+
await cleanMoaWorkspaces(cwd, runId ? { runId } : {})
|
|
73
75
|
return
|
|
74
76
|
}
|
|
75
77
|
|
|
@@ -96,7 +98,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
|
|
|
96
98
|
|
|
97
99
|
const result = await pipelinePromise
|
|
98
100
|
if (combinedSignal.aborted) {
|
|
99
|
-
await cleanMoaWorkspaces(cwd)
|
|
101
|
+
await cleanMoaWorkspaces(cwd, runId ? { runId } : {})
|
|
100
102
|
return
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -126,7 +128,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
|
|
|
126
128
|
} finally {
|
|
127
129
|
internalAbortCtrl.abort()
|
|
128
130
|
if (!done) {
|
|
129
|
-
await cleanMoaWorkspaces(cwd).catch(() => {})
|
|
131
|
+
await cleanMoaWorkspaces(cwd, runId ? { runId } : {}).catch(() => {})
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
134
|
}
|
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([
|