134 lines
7.8 KiB
TypeScript
134 lines
7.8 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto'
|
|
import { chmod, lstat, readdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'
|
|
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
import { safeAgentPath, type AgentCheckedChange, type AgentToolRequest } from '../shared/agentTools'
|
|
|
|
const textFile = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$/i
|
|
export const agentFileHash = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex')
|
|
|
|
async function checkedPath(root: string, path: string, allowMissing = false): Promise<string> {
|
|
safeAgentPath(path)
|
|
const base = await realpath(root)
|
|
const absolute = resolve(base, path)
|
|
if (!absolute.startsWith(base + sep)) throw new Error('모듈 경로를 벗어날 수 없습니다.')
|
|
let current = base
|
|
const parts = relative(base, absolute).split(sep)
|
|
for (let i = 0; i < parts.length; i++) {
|
|
current = join(current, parts[i])
|
|
try {
|
|
const info = await lstat(current)
|
|
if (info.isSymbolicLink() || (i < parts.length - 1 && !info.isDirectory())) throw new Error('심볼릭 링크와 일반 파일이 아닌 경로는 사용할 수 없습니다.')
|
|
} catch (error) {
|
|
if (allowMissing && i === parts.length - 1 && (error as NodeJS.ErrnoException).code === 'ENOENT') return absolute
|
|
throw error
|
|
}
|
|
}
|
|
return absolute
|
|
}
|
|
|
|
async function readText(root: string, path: string): Promise<string> {
|
|
if (!textFile.test(path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 조회·수정할 수 있습니다.')
|
|
const absolute = await checkedPath(root, path)
|
|
const info = await lstat(absolute)
|
|
if (!info.isFile() || info.size > 1_048_576) throw new Error('일반 텍스트 파일(1MB 이하)만 조회할 수 있습니다.')
|
|
const text = await readFile(absolute, 'utf8')
|
|
if (text.includes('\0') || text.includes('\uFFFD')) throw new Error('UTF-8 텍스트 파일이 아닙니다.')
|
|
return text
|
|
}
|
|
|
|
export async function runAgentFileTool(root: string, request: AgentToolRequest): Promise<{ data: unknown; truncated: boolean }> {
|
|
const args = request.args ?? {}
|
|
if (request.name === 'files.read') {
|
|
const path = safeAgentPath(args.path)
|
|
try {
|
|
const content = await readText(root, path)
|
|
const lines = content.split('\n')
|
|
const partial = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
|
|
const startLine = args.startLine ?? 1
|
|
const selected = partial ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n').slice(0, 40_000) : content
|
|
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete: !partial, startLine, totalLines: lines.length }, truncated: partial }
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
// A missing leaf in an existing directory is evidence for a new file.
|
|
await checkedPath(root, path, true)
|
|
return { data: { path, exists: false, hash: null, content: '', complete: true }, truncated: false }
|
|
}
|
|
}
|
|
const prefix = safeAgentPath(args.path, true)
|
|
if (prefix) await checkedPath(root, prefix)
|
|
const paths: string[] = []
|
|
const matches: Array<{ path: string; line: number; text: string }> = []
|
|
let visited = 0
|
|
let scanned = 0
|
|
let truncated = false
|
|
const visit = async (directory: string, depth: number): Promise<void> => {
|
|
if (depth > 10 || visited >= 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; return }
|
|
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
|
|
for (const entry of entries) {
|
|
const path = relative(root, join(directory, entry.name)).split(sep).join('/')
|
|
if (prefix && path !== prefix && !path.startsWith(prefix + '/') && !prefix.startsWith(path + '/')) continue
|
|
if (++visited > 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; break }
|
|
try { safeAgentPath(path) } catch { continue }
|
|
if (entry.isSymbolicLink()) continue
|
|
if (entry.isDirectory()) { await visit(join(directory, entry.name), depth + 1); continue }
|
|
if (!entry.isFile() || !textFile.test(entry.name)) continue
|
|
if (prefix && path !== prefix && !path.startsWith(prefix + '/')) continue
|
|
if (request.name === 'files.list') { paths.push(path); continue }
|
|
let content: string
|
|
try { content = await readText(root, path) } catch { continue }
|
|
scanned += content.length
|
|
for (const [index, line] of content.split('\n').entries()) {
|
|
if (line.toLowerCase().includes(args.query!.toLowerCase())) {
|
|
matches.push({ path, line: index + 1, text: line.slice(0, 500) })
|
|
if (matches.length >= 80) { truncated = true; break }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await visit(root, 0)
|
|
return { data: request.name === 'files.list' ? { paths } : { query: args.query, matches }, truncated }
|
|
}
|
|
|
|
export async function applyAgentFiles(changes: Array<AgentCheckedChange & { root: string }>): Promise<void> {
|
|
if (!changes.length || changes.length > 24 || changes.reduce((n, c) => n + c.content.length, 0) > 1_000_000) throw new Error('변경 파일 용량을 확인하세요.')
|
|
const prepared: Array<{ path: string; temp: string; original: string | null; mode: number }> = []
|
|
const committed: typeof prepared = []
|
|
const seen = new Set<string>()
|
|
try {
|
|
for (const change of changes) {
|
|
if (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash)) throw new Error('원본 파일 조회 정보가 없습니다. 다시 분석해주세요.')
|
|
if (!textFile.test(change.path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 수정할 수 있습니다.')
|
|
const path = await checkedPath(change.root, change.path, true)
|
|
if (seen.has(path)) throw new Error('중복된 변경 파일입니다.')
|
|
seen.add(path)
|
|
let original: string | null = null
|
|
let mode = 0o644
|
|
try { original = await readText(change.root, change.path); mode = (await lstat(path)).mode } catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
}
|
|
if ((original === null ? null : agentFileHash(original)) !== change.expectedHash) throw new Error(`분석 이후 파일이 변경되었습니다: ${change.moduleName}/${change.path}. 다시 분석해주세요.`)
|
|
prepared.push({ path, original, mode, temp: join(dirname(path), `.lms-agent-${randomUUID()}.tmp`) })
|
|
}
|
|
for (const [index, file] of prepared.entries()) {
|
|
await writeFile(file.temp, changes[index].content, { encoding: 'utf8', mode: file.mode, flag: 'wx' })
|
|
await chmod(file.temp, file.mode & 0o777)
|
|
}
|
|
// Recheck the entire batch immediately before committing any file.
|
|
for (const file of prepared) {
|
|
let latest: string | null = null
|
|
try { latest = await readFile(file.path, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
if (latest !== file.original) throw new Error('적용 직전 파일이 변경되었습니다. 다시 분석해주세요.')
|
|
}
|
|
for (const file of prepared) { await rename(file.temp, file.path); committed.push(file) }
|
|
} catch (error) {
|
|
const failures: string[] = []
|
|
for (const file of committed.reverse()) {
|
|
try { if (file.original === null) await unlink(file.path); else await writeFile(file.path, file.original, { mode: file.mode }) } catch { failures.push(file.path) }
|
|
}
|
|
if (failures.length) throw new Error(`일부 파일 복구에 실패했습니다: ${failures.join(', ')}. ${String(error)}`)
|
|
throw error
|
|
} finally {
|
|
await Promise.all(prepared.map(file => unlink(file.temp).catch(() => undefined)))
|
|
}
|
|
}
|