@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/README.md
CHANGED
|
@@ -140,7 +140,7 @@ Configure your models in `Settings → Plugins → Mixture of Agents`:
|
|
|
140
140
|
Inspect line-by-line differences between candidate proposals and the curator's synthesized deliverable directly in the UI. Features file selection, delta line highlights (added, removed, same), and unified diff rendering via a zero-dependency in-memory LCS algorithm.
|
|
141
141
|
|
|
142
142
|
### 8. Pre-Promotion Git Checkpoints (`dsh-time-machine`)
|
|
143
|
-
Before promoting any winning candidate files over the workspace root, `dsh-moa` invokes the local `dsh-time-machine` service to create a shadow Git checkpoint (`moa-pre-promotion: candidate-N`). If `dsh-time-machine` is absent or unreachable, file promotion
|
|
143
|
+
Before promoting any winning candidate files over the workspace root, `dsh-moa` invokes the local `dsh-time-machine` service to create a shadow Git checkpoint (`moa-pre-promotion: candidate-N`). If `dsh-time-machine` is absent or unreachable, file promotion is blocked (fail-closed) to protect uncheckpointed workspace files against unintended overwrites (override with `force: true` only when automated checkpoints are explicitly unneeded).
|
|
144
144
|
|
|
145
145
|
### 9. Automated Test Execution Gate
|
|
146
146
|
When `test_gate_enabled: true` and `test_command` (e.g. `npm test` or `pytest`) are configured, candidate code is executed in an ephemeral sandbox overlay. The judge receives concrete test outcomes, durations, and output logs to ground decisions in objective verification.
|
package/README.ru.md
CHANGED
|
@@ -140,7 +140,7 @@ graph TD
|
|
|
140
140
|
Позволяет визуально сопоставлять построчные различия между решениями моделей-советников и итоговым синтезом судьи прямо в интерфейсе. Поддерживает выбор конкретного файла, подсветку добавленных/удаленных строк и вычисление дельт с помощью легковесного алгоритма LCS без внешних зависимостей.
|
|
141
141
|
|
|
142
142
|
### 8. Теневые Git-чекпоинты (`dsh-time-machine`)
|
|
143
|
-
Перед промоушном файлов победителя в корень рабочей области `dsh-moa` автоматически обращается к сервису `dsh-time-machine` для создания снимка (`moa-pre-promotion: candidate-N`). Если плагин
|
|
143
|
+
Перед промоушном файлов победителя в корень рабочей области `dsh-moa` автоматически обращается к сервису `dsh-time-machine` для создания снимка (`moa-pre-promotion: candidate-N`). Если плагин недоступен или создание снимка завершилось ошибкой, перезапись файлов блокируется во избежание потери несохранённых данных (переопределяется параметром `force: true`, только если создание снимков намеренно отключено).
|
|
144
144
|
|
|
145
145
|
### 9. Автоматический шлюз выполнения тестов (Automated Test Execution Gate)
|
|
146
146
|
При включении `test_gate_enabled: true` и указании тестовой команды (`test_command`, например `npm test` или `pytest`) каждый кандидат запускается в изолированном оверлейном окружении. Судья получает объективный вердикт с кодами возврата, временем исполнения и логами тестов до вынесения итогового решения.
|
package/README.zh.md
CHANGED
|
@@ -140,7 +140,7 @@ graph TD
|
|
|
140
140
|
直接在界面中可视化对比顾问方案之间以及顾问与裁判综合方案之间的逐行差异。支持文件筛选、增删改高亮,基于纯 JavaScript LCS 差异算法,零第三方依赖。
|
|
141
141
|
|
|
142
142
|
### 8. 提升前 Git 快照检查点 (`dsh-time-machine`)
|
|
143
|
-
在将胜出候选者的文件提升并覆盖至项目根目录前,`dsh-moa` 自动调用本地 `dsh-time-machine` 建立影子 Git 检查点 (`moa-pre-promotion: candidate-N`)
|
|
143
|
+
在将胜出候选者的文件提升并覆盖至项目根目录前,`dsh-moa` 自动调用本地 `dsh-time-machine` 建立影子 Git 检查点 (`moa-pre-promotion: candidate-N`)。如 `dsh-time-machine` 未安装或无法访问,文件提升将被阻断以防止未备份的项目文件被意外覆盖(仅在明确不需要检查点时可通过 `force: true` 覆盖)。
|
|
144
144
|
|
|
145
145
|
### 18. 平滑降级与本地容灾恢复 (Local Fallback Resilience)
|
|
146
146
|
当 `local_fallback_enabled: true` 时,因网络中断、超时或速率限制(429/500)而失败的候选模型会自动使用配置的本地模型(Ollama / MiniPC)恢复。每个后备模型都拥有独立的超时执行配额与关联主 turn signal 的隔离取消控制器,防止云端超时耗尽本地恢复时间。同时严格检测终止错误与流中断,确保安全容灾且杜绝已取消 turn 的延迟文件提升。
|
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
|
|