@goodandready/dsh-moa 0.2.36 → 0.2.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +3 -0
- package/lib/file-workspace.js +195 -143
- package/lib/moa-candidates.js +54 -6
- package/lib/moa-llm.js +74 -6
- package/lib/moa-runner.js +38 -33
- package/lib/moa-stream.js +5 -3
- package/lib/moa-test-gate.js +94 -41
- package/lib/routes.js +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -170,7 +170,7 @@ Seamlessly retains synthesized baseline code from prior turns while pruning inte
|
|
|
170
170
|
`report_generation_enabled` generates comprehensive Markdown & JSON reports detailing candidate metrics, agreement scores, test gate results, and cost breakdowns via `GET /dsh-moa/runs/:id/report`.
|
|
171
171
|
|
|
172
172
|
### 18. Graceful Degradation & Local Fallback Resilience
|
|
173
|
-
When `local_fallback_enabled: true`, candidates that fail due to network outages or rate limits (429/500) automatically recover using configured local models (Ollama / MiniPC).
|
|
173
|
+
When `local_fallback_enabled: true`, candidates that fail due to network outages, timeouts, or rate limits (429/500) automatically recover using configured local models (Ollama / MiniPC). Each fallback model operates with an independent execution budget and an isolated controller linked to the parent turn signal, preventing cloud timeouts from starving fallback models. Additionally, terminal errors and aborted streams are strictly classified, ensuring reliable error propagation and failover.
|
|
174
174
|
|
|
175
175
|
### 6. Live Canvas 1-Click Preview (optional)
|
|
176
176
|
If `@goodandready/dsh-live-canvas` is installed in the same profile, `dsh-moa` pushes the promoted HTML file to the Live Canvas REST contract (`POST /dsh-live-canvas/api/preview`, served by the same harness webServer) and appends a one-click preview link (`/dsh-live-canvas/sandbox/<id>`) to the answer. Without the plugin the step is skipped silently — no errors in the log, no dead links.
|
package/README.ru.md
CHANGED
|
@@ -170,7 +170,7 @@ graph TD
|
|
|
170
170
|
При включении `report_generation_enabled` генерируется подробный аналитический отчет с матрицей моделей, временами задержек, оценками судей, расходами и результатами тестов. Отчет доступен по API `GET /dsh-moa/runs/:id/report?format=md`.
|
|
171
171
|
|
|
172
172
|
### 18. Плавная деградация и локальный фоллбэк
|
|
173
|
-
Опция `local_fallback_enabled` позволяет задать резервные локальные модели (Ollama, vLLM, MiniPC), к которым плагин бесшовно обращается при сбоях сетевых API или превышении лимитов rate limit (429/500).
|
|
173
|
+
Опция `local_fallback_enabled` позволяет задать резервные локальные модели (Ollama, vLLM, MiniPC), к которым плагин бесшовно обращается при сбоях сетевых API, таймаутах или превышении лимитов rate limit (429/500). Каждая фоллбэк-модель запускается с независимым бюджетом времени и изолированным контроллером отмены, связанным с сигналом основного хода, предотвращая исчерпание времени ожидания из-за задержек облачных провайдеров. Стриминговые сбои и терминальные ошибки классифицируются типизированными исключениями с гарантированной защитой от позднего промоушна.
|
|
174
174
|
|
|
175
175
|
### 6. Live Canvas 1-клик предпросмотр (опционально)
|
|
176
176
|
Если в профиле установлен `@goodandready/dsh-live-canvas`, `dsh-moa` отправляет промоученный HTML-файл в REST-контракт Live Canvas (`POST /dsh-live-canvas/api/preview`, тот же webServer харнесса) и добавляет к ответу ссылку на предпросмотр (`/dsh-live-canvas/sandbox/<id>`). Без плагина шаг пропускается тихо — без ошибок в журнале и без битых ссылок.
|
package/README.zh.md
CHANGED
|
@@ -142,6 +142,9 @@ graph TD
|
|
|
142
142
|
### 8. 提升前 Git 快照检查点 (`dsh-time-machine`)
|
|
143
143
|
在将胜出候选者的文件提升并覆盖至项目根目录前,`dsh-moa` 自动调用本地 `dsh-time-machine` 建立影子 Git 检查点 (`moa-pre-promotion: candidate-N`)。如未安装则静默回退,不阻断提升流程。
|
|
144
144
|
|
|
145
|
+
### 18. 平滑降级与本地容灾恢复 (Local Fallback Resilience)
|
|
146
|
+
当 `local_fallback_enabled: true` 时,因网络中断、超时或速率限制(429/500)而失败的候选模型会自动使用配置的本地模型(Ollama / MiniPC)恢复。每个后备模型都拥有独立的超时执行配额与关联主 turn signal 的隔离取消控制器,防止云端超时耗尽本地恢复时间。同时严格检测终止错误与流中断,确保安全容灾且杜绝已取消 turn 的延迟文件提升。
|
|
147
|
+
|
|
145
148
|
### 6. Live Canvas 一键预览(可选)
|
|
146
149
|
若同一 profile 中安装了 `@goodandready/dsh-live-canvas`,`dsh-moa` 会将提升后的 HTML 文件推送到 Live Canvas 的 REST 契约(`POST /dsh-live-canvas/api/preview`,由同一 harness webServer 提供服务),并在回答中附上一键预览链接(`/dsh-live-canvas/sandbox/<id>`)。未安装该插件时此步骤静默跳过——日志无报错,也不会出现死链接。
|
|
147
150
|
|
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
|
|