@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 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
 
@@ -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
- if (!normalized || normalized === '.' || normalized.startsWith('..') || path.isAbsolute(normalized)) return false
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
- // Greenfield creation phrases - ALWAYS fresh task
177
- const freshPhrases = [
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
- * <baseDir>/.moa/candidate-<index>/
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 targetStr = String(candidateIndex).trim()
207
- const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
208
- const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
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
- const written = []
214
- for (const file of files) {
215
- if (!isSafeRelativePath(file.relativePath)) continue
216
- const fullPath = assertPathContained(candidateDir, file.relativePath)
217
- await fs.mkdir(path.dirname(fullPath), { recursive: true })
218
- await fs.writeFile(fullPath, file.content, 'utf8')
219
- written.push({
220
- relativePath: file.relativePath,
221
- fullPath,
222
- size: Buffer.byteLength(file.content, 'utf8'),
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
- return written
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 targetStr = String(winningIndex).trim()
279
- const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
280
- const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
281
- const moaRoot = path.join(baseDir, '.moa')
282
- const winningDir = assertPathContained(moaRoot, sub)
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 moaRoot on empty candidate', () => fs.rm(moaRoot, { recursive: true, force: true }))
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(moaRoot, { recursive: true, force: true })
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 moaRoot on promote error', () => fs.rm(moaRoot, { recursive: true, force: true }))
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 any left-over .moa candidate folder.
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
- await bestEffort('cleanMoaWorkspaces', () => fs.rm(moaRoot, { recursive: true, force: true }))
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 or .moa/curator-synthesis)
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
- targetDir = assertPathContained(moaRoot, sub)
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
- start++
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
- const oNum = oldEnd + 1 + k + 1
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
- middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
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
- if (subOld[i] === subNew[j]) {
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