@goodandready/dsh-moa 0.2.60 → 0.2.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/file-workspace.js +33 -593
- package/lib/routes/history.js +81 -0
- package/lib/routes/pipeline.js +91 -0
- package/lib/routes/presets.js +241 -0
- package/lib/routes/workbench.js +197 -0
- package/lib/routes.js +17 -582
- package/lib/workspace/checkpoint.js +51 -0
- package/lib/workspace/diff.js +94 -0
- package/lib/workspace/fs.js +284 -0
- package/lib/workspace/scanner.js +100 -0
- package/lib/workspace/security.js +80 -0
- package/package.json +1 -1
package/lib/file-workspace.js
CHANGED
|
@@ -1,594 +1,34 @@
|
|
|
1
|
-
import { bestEffort } from './best-effort.js'
|
|
2
|
-
import { logger } from './logger.js'
|
|
3
|
-
import vm from 'node:vm'
|
|
4
|
-
import fs from 'node:fs/promises'
|
|
5
|
-
import fsSync from 'node:fs'
|
|
6
|
-
import path from 'node:path'
|
|
7
1
|
/**
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
69
|
-
return resolvedTarget
|
|
70
|
-
}
|
|
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 = assertPathContained(baseDir, '.moa')
|
|
82
|
-
const runRoot = safeRunId ? assertPathContained(moaRoot, safeRunId) : moaRoot
|
|
83
|
-
return { moaRoot, runRoot, safeRunId, sub, candidateDir: assertPathContained(runRoot, sub) }
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Parses markdown code blocks that specify file paths.
|
|
88
|
-
*/
|
|
89
|
-
export function extractFileBlocks(text) {
|
|
90
|
-
if (!text || typeof text !== 'string') return []
|
|
91
|
-
const files = []
|
|
92
|
-
const seenPaths = new Set()
|
|
93
|
-
const addFile = (rawPath, fileContent) => {
|
|
94
|
-
if (rawPath && isSafeRelativePath(rawPath) && !seenPaths.has(rawPath)) {
|
|
95
|
-
seenPaths.add(rawPath)
|
|
96
|
-
files.push({ relativePath: rawPath, content: fileContent.trimEnd() + '\n' })
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const patterns = [
|
|
101
|
-
/```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi,
|
|
102
|
-
/(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
|
|
103
|
-
/#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
|
|
104
|
-
/```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi,
|
|
105
|
-
]
|
|
106
|
-
for (const regex of patterns) {
|
|
107
|
-
let match
|
|
108
|
-
while ((match = regex.exec(text)) !== null) addFile(match[1].trim(), match[2])
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (files.length === 0) {
|
|
112
|
-
const htmlBlockRegex = /```(?:html)?[\r\n]+([\s\S]*?)```/gi
|
|
113
|
-
let htmlMatch
|
|
114
|
-
while ((htmlMatch = htmlBlockRegex.exec(text)) !== null) {
|
|
115
|
-
const candidateHtml = htmlMatch[1]
|
|
116
|
-
if (/<(!DOCTYPE\s+)?html/i.test(candidateHtml) || /<body/i.test(candidateHtml)) {
|
|
117
|
-
files.push({ relativePath: 'index.html', content: candidateHtml.trimEnd() + '\n' })
|
|
118
|
-
break
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return files
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Formats collected project files into a structured markdown context string.
|
|
127
|
-
*/
|
|
128
|
-
export function formatProjectContext(projectFiles = [], options = {}) {
|
|
129
|
-
if (!Array.isArray(projectFiles) || projectFiles.length === 0) return ''
|
|
130
|
-
const { skippedFiles = 0, skippedList = [] } = options
|
|
131
|
-
const fence = '```'
|
|
132
|
-
let formatted = projectFiles
|
|
133
|
-
.map((f) => `### File: ${f.relativePath}\n${fence}\n${f.content}\n${fence}`)
|
|
134
|
-
.join('\n\n')
|
|
135
|
-
|
|
136
|
-
if (skippedFiles > 0) {
|
|
137
|
-
const preview = skippedList.slice(0, 5).join(', ')
|
|
138
|
-
const more = skippedList.length > 5 ? ` and ${skippedList.length - 5} more` : ''
|
|
139
|
-
const warning = `> ⚠️ **Workspace Scan Notice:** ${skippedFiles} workspace file(s) were skipped or truncated due to size/budget limits or read errors (${preview}${more}). The workspace context below is partial.`
|
|
140
|
-
formatted = `${warning}\n\n${formatted}`
|
|
141
|
-
}
|
|
142
|
-
return formatted
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
146
|
-
if (!baseDir) return { files: [], totalChars: 0, skippedFiles: 0, skippedList: [] }
|
|
147
|
-
const ignoreDirs = new Set(['node_modules', '.git', '.moa', '.worktrees', 'dist', 'build', '.dsh', '.next', '.nuxt', '.cache', 'coverage', '.turbo', '.idea', '.vscode'])
|
|
148
|
-
const codeExts = new Set(['.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.json', '.py', '.md', '.txt', '.sh', '.yaml', '.yml'])
|
|
149
|
-
const collected = []
|
|
150
|
-
let totalChars = 0
|
|
151
|
-
let skippedFiles = 0
|
|
152
|
-
const skippedList = []
|
|
153
|
-
|
|
154
|
-
async function scan(currentDir, relPrefix = '') {
|
|
155
|
-
if (totalChars >= maxCharBudget) return
|
|
156
|
-
let list
|
|
157
|
-
try {
|
|
158
|
-
list = await fs.readdir(currentDir, { withFileTypes: true })
|
|
159
|
-
} catch {
|
|
160
|
-
skippedFiles++
|
|
161
|
-
skippedList.push(relPrefix || '.')
|
|
162
|
-
return
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const item of list) {
|
|
166
|
-
if (item.name.startsWith('.')) continue
|
|
167
|
-
const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
|
|
168
|
-
const full = path.join(currentDir, item.name)
|
|
169
|
-
|
|
170
|
-
if (totalChars >= maxCharBudget) {
|
|
171
|
-
if (item.isFile() && codeExts.has(path.extname(item.name).toLowerCase())) {
|
|
172
|
-
skippedFiles++
|
|
173
|
-
skippedList.push(`${rel} (budget limit)`)
|
|
174
|
-
}
|
|
175
|
-
continue
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (item.isDirectory()) {
|
|
179
|
-
if (!ignoreDirs.has(item.name)) await scan(full, rel)
|
|
180
|
-
} else if (item.isFile()) {
|
|
181
|
-
const ext = path.extname(item.name).toLowerCase()
|
|
182
|
-
if (codeExts.has(ext)) {
|
|
183
|
-
try {
|
|
184
|
-
const stat = await fs.stat(full)
|
|
185
|
-
if (stat.size > 100000) {
|
|
186
|
-
skippedFiles++
|
|
187
|
-
skippedList.push(`${rel} (>100KB)`)
|
|
188
|
-
continue
|
|
189
|
-
}
|
|
190
|
-
const content = await fs.readFile(full, 'utf8')
|
|
191
|
-
const budgetRemaining = maxCharBudget - totalChars
|
|
192
|
-
const truncated = content.length > budgetRemaining ? content.slice(0, budgetRemaining) + '\n... [truncated]' : content
|
|
193
|
-
totalChars += truncated.length
|
|
194
|
-
collected.push({ relativePath: rel, content: truncated, size: stat.size })
|
|
195
|
-
} catch (err) {
|
|
196
|
-
skippedFiles++
|
|
197
|
-
skippedList.push(`${rel} (read error: ${err?.message || String(err)})`)
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
await scan(baseDir)
|
|
205
|
-
return { files: collected, totalChars, skippedFiles, skippedList }
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Checks if a task prompt is an iterative refinement / modification.
|
|
210
|
-
*/
|
|
211
|
-
export function isRefinementTask(userPrompt = '', projectFiles = null) {
|
|
212
|
-
if ((Array.isArray(projectFiles) && projectFiles.length === 0) || !userPrompt?.trim()) return false
|
|
213
|
-
const p = userPrompt.toLowerCase().trim()
|
|
214
|
-
const fresh = ['from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new', '从头开始', '新建项目', '创建项目', '新应用', '新建应用', 'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис', 'сделай проект', 'сделай приложение', 'сделай игру']
|
|
215
|
-
if (fresh.some((phrase) => p.includes(phrase))) return false
|
|
216
|
-
const mod = ['add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust', 'style', 'css', 'color', 'theme', 'bug', '添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题', 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали', 'темн', 'светл', 'цвет', 'кнопк', 'баг']
|
|
217
|
-
return mod.some((kw) => p.includes(kw))
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Writes candidate files into an isolated candidate workspace.
|
|
222
|
-
* Uses atomic staging and swap to prevent stale file accumulation and dirty snapshots.
|
|
223
|
-
*/
|
|
224
|
-
export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [], options = {}) {
|
|
225
|
-
if (!baseDir || !Array.isArray(files) || files.length === 0) return []
|
|
226
|
-
const { runRoot, sub, candidateDir } = resolveCandidateFolder(baseDir, candidateIndex, options)
|
|
227
|
-
await fs.mkdir(runRoot, { recursive: true })
|
|
228
|
-
|
|
229
|
-
const tempSub = `.tmp-${sub}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
|
230
|
-
const tempCandidateDir = assertPathContained(runRoot, tempSub)
|
|
231
|
-
await fs.mkdir(tempCandidateDir, { recursive: true })
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
const written = []
|
|
235
|
-
for (const file of files) {
|
|
236
|
-
if (!isSafeRelativePath(file.relativePath)) continue
|
|
237
|
-
const fullPath = assertPathContained(tempCandidateDir, file.relativePath)
|
|
238
|
-
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
239
|
-
await fs.writeFile(fullPath, file.content, 'utf8')
|
|
240
|
-
written.push({
|
|
241
|
-
relativePath: file.relativePath,
|
|
242
|
-
fullPath: path.join(candidateDir, file.relativePath),
|
|
243
|
-
size: Buffer.byteLength(file.content, 'utf8'),
|
|
244
|
-
})
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (written.length === 0 && files.length > 0) throw new Error('No valid files could be written to candidate workspace')
|
|
248
|
-
await fs.rm(candidateDir, { recursive: true, force: true })
|
|
249
|
-
await fs.rename(tempCandidateDir, candidateDir)
|
|
250
|
-
return written
|
|
251
|
-
} catch (err) {
|
|
252
|
-
await bestEffort('clean tempCandidateDir on error', () => fs.rm(tempCandidateDir, { recursive: true, force: true }))
|
|
253
|
-
throw err
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export function isLoopbackUrl(urlStr) {
|
|
258
|
-
if (!urlStr || typeof urlStr !== 'string') return false
|
|
259
|
-
try {
|
|
260
|
-
const u = new URL(urlStr)
|
|
261
|
-
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
|
|
262
|
-
const h = u.hostname.replace(/^\[|\]$/g, '')
|
|
263
|
-
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h)
|
|
264
|
-
} catch {
|
|
265
|
-
return false
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Best-effort integration with dsh-time-machine to create a shadow Git checkpoint
|
|
271
|
-
* before candidate promotion overwrites files in the project workspace.
|
|
272
|
-
*/
|
|
273
|
-
export async function createPrePromotionCheckpoint(baseDir, candidateTarget, options = {}) {
|
|
274
|
-
if (!baseDir || options.checkpoint === false) return null
|
|
275
|
-
if (typeof options.checkpointFn === 'function') {
|
|
276
|
-
return (await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))) || null
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
|
|
280
|
-
if (options.timeMachineEngine && typeof options.timeMachineEngine.createSnapshot === 'function') {
|
|
281
|
-
const snap = await bestEffort('dsh-time-machine direct service createSnapshot', async () => {
|
|
282
|
-
return await options.timeMachineEngine.createSnapshot(label, { cwd: baseDir })
|
|
283
|
-
})
|
|
284
|
-
if (snap) return snap
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const port = options.port || options.webServerPort || process.env.DSH_PORT || process.env.PORT
|
|
288
|
-
if (!port && !options.timeMachineUrl) {
|
|
289
|
-
logger.warn('[dsh-moa] Cannot create pre-promotion checkpoint: webServerPort is unknown and no timeMachineUrl provided')
|
|
290
|
-
return null
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (options.timeMachineUrl && !isLoopbackUrl(options.timeMachineUrl)) {
|
|
294
|
-
logger.warn(`[dsh-moa] Blocked non-loopback timeMachineUrl: ${options.timeMachineUrl}`)
|
|
295
|
-
return null
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
|
|
299
|
-
const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
|
|
300
|
-
const res = await fetch(endpoint, {
|
|
301
|
-
method: 'POST',
|
|
302
|
-
headers: { 'Content-Type': 'application/json' },
|
|
303
|
-
body: JSON.stringify({ label, cwd: baseDir }),
|
|
304
|
-
signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
|
|
305
|
-
redirect: 'error',
|
|
306
|
-
})
|
|
307
|
-
if (res.ok) {
|
|
308
|
-
const data = await res.json()
|
|
309
|
-
return data?.snapshot || data || null
|
|
310
|
-
}
|
|
311
|
-
return null
|
|
312
|
-
})
|
|
313
|
-
return snap || null
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Promotes winning candidate workspace files into the base workspace.
|
|
318
|
-
* Serialized per baseDir and verifies symlink boundaries on promotion.
|
|
319
|
-
*/
|
|
320
|
-
export async function promoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
|
|
321
|
-
if (!baseDir || winningIndex === null || winningIndex === undefined) return []
|
|
322
|
-
|
|
323
|
-
const key = path.resolve(baseDir)
|
|
324
|
-
const prevLock = promotionLocks.get(key) || Promise.resolve()
|
|
325
|
-
let releaseLock
|
|
326
|
-
const currentLock = new Promise((resolve) => { releaseLock = resolve })
|
|
327
|
-
promotionLocks.set(key, currentLock)
|
|
328
|
-
|
|
329
|
-
try {
|
|
330
|
-
await prevLock
|
|
331
|
-
return await doPromoteCandidateWorkspace(baseDir, winningIndex, options)
|
|
332
|
-
} finally {
|
|
333
|
-
releaseLock()
|
|
334
|
-
if (promotionLocks.get(key) === currentLock) {
|
|
335
|
-
promotionLocks.delete(key)
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
async function doPromoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
|
|
341
|
-
const { runRoot, moaRoot, safeRunId, candidateDir: winningDir } = resolveCandidateFolder(baseDir, winningIndex, options)
|
|
342
|
-
const cleanupTarget = safeRunId ? runRoot : moaRoot
|
|
343
|
-
|
|
344
|
-
try {
|
|
345
|
-
const entries = await getFilesRecursively(winningDir)
|
|
346
|
-
if (entries.length === 0) {
|
|
347
|
-
if (!options.keepMoa) await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
|
|
348
|
-
return []
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const checkpoint = await createPrePromotionCheckpoint(baseDir, winningIndex, options)
|
|
352
|
-
if (options.checkpoint !== false && !checkpoint && !options.force) {
|
|
353
|
-
const errMsg = `[dsh-moa] Pre-promotion checkpoint failed for candidate-${winningIndex} in ${baseDir}. File overwrite blocked to prevent uncheckpointed changes (pass force: true to override).`
|
|
354
|
-
logger.warn(errMsg)
|
|
355
|
-
throw new Error(errMsg)
|
|
356
|
-
}
|
|
357
|
-
if (options.signal?.aborted) {
|
|
358
|
-
throw (options.signal.reason instanceof Error ? options.signal.reason : new Error('Turn aborted'))
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const promoted = []
|
|
362
|
-
const baseReal = await fs.realpath(baseDir).catch(() => path.resolve(baseDir))
|
|
363
|
-
|
|
364
|
-
for (const rel of entries) {
|
|
365
|
-
if (!isSafeRelativePath(rel)) continue
|
|
366
|
-
const srcPath = assertPathContained(winningDir, rel)
|
|
367
|
-
const destPath = assertPathContained(baseDir, rel)
|
|
368
|
-
|
|
369
|
-
try {
|
|
370
|
-
const destLstat = await fs.lstat(destPath)
|
|
371
|
-
if (destLstat.isSymbolicLink()) {
|
|
372
|
-
const destReal = await fs.realpath(destPath)
|
|
373
|
-
if (destReal !== baseReal && !destReal.startsWith(baseReal + path.sep)) {
|
|
374
|
-
throw new Error(`Path traversal violation: promotion destination symlink "${rel}" resolves outside "${baseDir}"`)
|
|
375
|
-
}
|
|
376
|
-
await fs.unlink(destPath)
|
|
377
|
-
}
|
|
378
|
-
} catch (lstatErr) {
|
|
379
|
-
if (lstatErr?.code !== 'ENOENT') throw lstatErr
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
|
383
|
-
await fs.copyFile(srcPath, destPath)
|
|
384
|
-
promoted.push(rel)
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (checkpoint) promoted.checkpoint = checkpoint
|
|
388
|
-
if (!options.keepMoa) await fs.rm(cleanupTarget, { recursive: true, force: true })
|
|
389
|
-
|
|
390
|
-
return promoted
|
|
391
|
-
} catch (err) {
|
|
392
|
-
if (!options.keepMoa) {
|
|
393
|
-
await bestEffort('clean candidate on promote error', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
|
|
394
|
-
}
|
|
395
|
-
throw err
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Cleans up candidate workspaces, optionally scoped to a single runId.
|
|
401
|
-
*/
|
|
402
|
-
export async function cleanMoaWorkspaces(baseDir, options = {}) {
|
|
403
|
-
if (!baseDir) return
|
|
404
|
-
const rawRunId = typeof options === 'string' ? options : options?.runId
|
|
405
|
-
const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
|
|
406
|
-
const moaRoot = assertPathContained(baseDir, '.moa')
|
|
407
|
-
if (safeRunId) {
|
|
408
|
-
await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(assertPathContained(moaRoot, safeRunId), { recursive: true, force: true }))
|
|
409
|
-
await bestEffort('clean empty moaRoot', async () => {
|
|
410
|
-
if ((await fs.readdir(moaRoot)).length === 0) await fs.rmdir(moaRoot)
|
|
411
|
-
})
|
|
412
|
-
} else {
|
|
413
|
-
await bestEffort('cleanMoaWorkspaces', () => fs.rm(moaRoot, { recursive: true, force: true }))
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
async function getFilesRecursively(dir) {
|
|
418
|
-
const results = []
|
|
419
|
-
try {
|
|
420
|
-
const stat = await fs.stat(dir)
|
|
421
|
-
if (!stat.isDirectory()) return []
|
|
422
|
-
} catch {
|
|
423
|
-
return []
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
let realDir
|
|
427
|
-
try {
|
|
428
|
-
realDir = await fs.realpath(dir)
|
|
429
|
-
} catch {
|
|
430
|
-
realDir = path.resolve(dir)
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
async function scan(current, relPrefix = '') {
|
|
434
|
-
let list
|
|
435
|
-
try {
|
|
436
|
-
list = await fs.readdir(current, { withFileTypes: true })
|
|
437
|
-
for (const item of list) {
|
|
438
|
-
const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
|
|
439
|
-
const full = path.join(current, item.name)
|
|
440
|
-
|
|
441
|
-
if (item.isSymbolicLink()) {
|
|
442
|
-
try {
|
|
443
|
-
const realTarget = await fs.realpath(full)
|
|
444
|
-
if (realTarget === realDir || realTarget.startsWith(realDir + path.sep)) {
|
|
445
|
-
const st = await fs.stat(full)
|
|
446
|
-
if (st.isFile()) results.push(rel)
|
|
447
|
-
}
|
|
448
|
-
} catch (err) { logger.debug?.('[dsh-moa] symlink inspect skipped', err) }
|
|
449
|
-
continue
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if (item.isDirectory()) await scan(full, rel)
|
|
453
|
-
else if (item.isFile()) results.push(rel)
|
|
454
|
-
}
|
|
455
|
-
} catch {
|
|
456
|
-
return
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
await scan(dir)
|
|
460
|
-
return results
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Verifies the syntactic validity of generated code files.
|
|
465
|
-
*/
|
|
466
|
-
export function verifyFileSyntax(files = []) {
|
|
467
|
-
if (!Array.isArray(files)) return []
|
|
468
|
-
const warnings = []
|
|
469
|
-
|
|
470
|
-
for (const file of files) {
|
|
471
|
-
if (!file || typeof file.content !== 'string' || !file.relativePath) continue
|
|
472
|
-
const ext = path.extname(file.relativePath).toLowerCase()
|
|
473
|
-
|
|
474
|
-
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
|
475
|
-
try {
|
|
476
|
-
const transformed = file.content
|
|
477
|
-
.replace(/^([ \t]*)export\s+default\s+/gm, '$1')
|
|
478
|
-
.replace(/^([ \t]*)export\s*\{[^}]*\}\s*;?/gm, '$1')
|
|
479
|
-
.replace(/^([ \t]*)export\s+(const|let|var|function|class|async\s+function)\s+/gm, '$1$2 ')
|
|
480
|
-
.replace(/^([ \t]*)import\s+[^;\n]+;?/gm, '$1')
|
|
481
|
-
.replace(/^([ \t]*)export\s+\*\s+from\s+[^;\n]+;?/gm, '$1')
|
|
482
|
-
new vm.Script(transformed)
|
|
483
|
-
} catch (err) {
|
|
484
|
-
warnings.push({ file: file.relativePath, error: err?.message || String(err), line: err?.lineNumber || 1 })
|
|
485
|
-
}
|
|
486
|
-
} else if (ext === '.json') {
|
|
487
|
-
try {
|
|
488
|
-
JSON.parse(file.content)
|
|
489
|
-
} catch (err) {
|
|
490
|
-
warnings.push({
|
|
491
|
-
file: file.relativePath,
|
|
492
|
-
error: err?.message || String(err),
|
|
493
|
-
line: 1,
|
|
494
|
-
})
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
return warnings
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/**
|
|
502
|
-
* Reads all files from a candidate workspace (.moa/candidate-N, .moa/curator-synthesis, or scoped under runId).
|
|
503
|
-
*/
|
|
504
|
-
export async function readCandidateFiles(baseDir, candidateTarget, options = {}) {
|
|
505
|
-
if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
|
|
506
|
-
let targetDir
|
|
507
|
-
try {
|
|
508
|
-
targetDir = resolveCandidateFolder(baseDir, candidateTarget, options).candidateDir
|
|
509
|
-
} catch {
|
|
510
|
-
return []
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
try {
|
|
514
|
-
const relFiles = await getFilesRecursively(targetDir)
|
|
515
|
-
const result = []
|
|
516
|
-
const realTarget = await fs.realpath(targetDir).catch(() => targetDir)
|
|
517
|
-
|
|
518
|
-
for (const rel of relFiles) {
|
|
519
|
-
if (!isSafeRelativePath(rel)) continue
|
|
520
|
-
try {
|
|
521
|
-
const fullPath = assertPathContained(targetDir, rel)
|
|
522
|
-
const st = await fs.lstat(fullPath)
|
|
523
|
-
if (st.isSymbolicLink()) {
|
|
524
|
-
const realFile = await fs.realpath(fullPath)
|
|
525
|
-
if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep)) continue
|
|
526
|
-
}
|
|
527
|
-
const content = await fs.readFile(fullPath, 'utf8')
|
|
528
|
-
result.push({ relativePath: rel, content, size: Buffer.byteLength(content, 'utf8') })
|
|
529
|
-
} catch {
|
|
530
|
-
// file read error best-effort
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
return result
|
|
534
|
-
} catch {
|
|
535
|
-
return []
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* Computes a line-by-line diff between two text strings using LCS with
|
|
541
|
-
* common prefix and suffix trimming for optimal O(delta) performance.
|
|
542
|
-
*/
|
|
543
|
-
export function computeLineDiff(oldText = '', newText = '') {
|
|
544
|
-
const oldLines = typeof oldText === 'string' ? oldText.split(/\r?\n/) : []
|
|
545
|
-
const newLines = typeof newText === 'string' ? newText.split(/\r?\n/) : []
|
|
546
|
-
const n = oldLines.length, m = newLines.length
|
|
547
|
-
if (n === 0 && m === 0) return []
|
|
548
|
-
if (oldText === newText) {
|
|
549
|
-
return oldLines.map((line, idx) => ({ type: 'same', line, oldNum: idx + 1, newNum: idx + 1 }))
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
let start = 0
|
|
553
|
-
while (start < n && start < m && oldLines[start] === newLines[start]) start++
|
|
554
|
-
let oldEnd = n - 1, newEnd = m - 1
|
|
555
|
-
while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
|
|
556
|
-
oldEnd--
|
|
557
|
-
newEnd--
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const prefixItems = oldLines.slice(0, start).map((line, k) => ({ type: 'same', line, oldNum: k + 1, newNum: k + 1 }))
|
|
561
|
-
const suffixLen = (n - 1) - oldEnd
|
|
562
|
-
const suffixItems = oldLines.slice(oldEnd + 1, oldEnd + 1 + suffixLen).map((line, k) => ({ type: 'same', line, oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k }))
|
|
563
|
-
|
|
564
|
-
const subOld = oldLines.slice(start, oldEnd + 1)
|
|
565
|
-
const subNew = newLines.slice(start, newEnd + 1)
|
|
566
|
-
const subN = subOld.length, subM = subNew.length
|
|
567
|
-
let middleItems
|
|
568
|
-
if (subN === 0) {
|
|
569
|
-
middleItems = subNew.map((line, j) => ({ type: 'added', line, newNum: start + j + 1 }))
|
|
570
|
-
} else if (subM === 0) {
|
|
571
|
-
middleItems = subOld.map((line, i) => ({ type: 'removed', line, oldNum: start + i + 1 }))
|
|
572
|
-
} else if (subN > 2000 || subM > 2000) {
|
|
573
|
-
middleItems = new Array(subN + subM)
|
|
574
|
-
for (let i = 0; i < subN; i++) middleItems[i] = { type: 'removed', line: subOld[i], oldNum: start + i + 1 }
|
|
575
|
-
for (let j = 0; j < subM; j++) middleItems[subN + j] = { type: 'added', line: subNew[j], newNum: start + j + 1 }
|
|
576
|
-
} else {
|
|
577
|
-
const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
|
|
578
|
-
for (let i = 0; i < subN; i++) {
|
|
579
|
-
for (let j = 0; j < subM; j++) dp[i + 1][j + 1] = subOld[i] === subNew[j] ? dp[i][j] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
|
|
580
|
-
}
|
|
581
|
-
let i = subN, j = subM; const stack = []
|
|
582
|
-
while (i > 0 || j > 0) {
|
|
583
|
-
if (i > 0 && j > 0 && subOld[i - 1] === subNew[j - 1]) {
|
|
584
|
-
stack.push({ type: 'same', line: subOld[--i], oldNum: start + i + 1, newNum: start + j }); j--
|
|
585
|
-
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
586
|
-
stack.push({ type: 'added', line: subNew[--j], newNum: start + j + 1 })
|
|
587
|
-
} else if (i > 0) {
|
|
588
|
-
stack.push({ type: 'removed', line: subOld[--i], oldNum: start + i + 1 })
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
middleItems = stack.reverse()
|
|
592
|
-
}
|
|
593
|
-
return prefixItems.concat(middleItems, suffixItems)
|
|
594
|
-
}
|
|
2
|
+
* File Workspace Management and Path Safety
|
|
3
|
+
* Modularized into lib/workspace/ for maintainability and line budget compliance.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
isSafeRelativePath,
|
|
8
|
+
assertPathContained,
|
|
9
|
+
isLoopbackUrl,
|
|
10
|
+
} from './workspace/security.js'
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
verifyFileSyntax,
|
|
14
|
+
computeLineDiff,
|
|
15
|
+
} from './workspace/diff.js'
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
formatProjectContext,
|
|
19
|
+
collectProjectContext,
|
|
20
|
+
isRefinementTask,
|
|
21
|
+
} from './workspace/scanner.js'
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
createPrePromotionCheckpoint,
|
|
25
|
+
} from './workspace/checkpoint.js'
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
resolveCandidateFolder,
|
|
29
|
+
extractFileBlocks,
|
|
30
|
+
writeCandidateWorkspace,
|
|
31
|
+
promoteCandidateWorkspace,
|
|
32
|
+
cleanMoaWorkspaces,
|
|
33
|
+
readCandidateFiles,
|
|
34
|
+
} from './workspace/fs.js'
|