Skip to content

Commit f85bbf8

Browse files
committed
feat(filesystem): add in-memory file system for WASM-JS #453
Introduce WasmJsToolFileSystem providing a Linux-like in-memory file system for WASM-JS environments, along with comprehensive tests and integration into the platform agent factory.
1 parent e72f5fe commit f85bbf8

File tree

3 files changed

+479
-2
lines changed

3 files changed

+479
-2
lines changed
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
package cc.unitmesh.agent.tool.filesystem
2+
3+
import cc.unitmesh.agent.tool.ToolErrorType
4+
import cc.unitmesh.agent.tool.ToolException
5+
import kotlinx.datetime.Clock
6+
7+
/**
8+
* WASM-JS implementation of ToolFileSystem using in-memory file system
9+
*
10+
* This implementation provides a Linux-like in-memory file system for WASM-JS environments.
11+
* All files and directories are stored in memory and will be lost when the page reloads.
12+
*
13+
* Features:
14+
* - Full directory tree support with parent/child relationships
15+
* - File metadata (size, timestamps, permissions)
16+
* - Path normalization and resolution
17+
* - Pattern matching for file listing
18+
* - Recursive directory operations
19+
*/
20+
class WasmJsToolFileSystem(
21+
private val projectPath: String? = null
22+
) : ToolFileSystem {
23+
24+
// Root of the in-memory file system
25+
private val root = MemoryFSNode.Directory("/", mutableMapOf())
26+
27+
init {
28+
// Create project directory if specified
29+
projectPath?.let {
30+
val normalizedPath = normalizePath(it)
31+
if (normalizedPath != "/") {
32+
try {
33+
createDirectory(normalizedPath, createParents = true)
34+
} catch (e: Exception) {
35+
println("Failed to create project directory: ${e.message}")
36+
}
37+
}
38+
}
39+
}
40+
41+
override fun getProjectPath(): String? = projectPath
42+
43+
override suspend fun readFile(path: String): String? {
44+
return try {
45+
val resolvedPath = resolvePath(path)
46+
val normalizedPath = normalizePath(resolvedPath)
47+
val node = findNode(normalizedPath)
48+
49+
when (node) {
50+
is MemoryFSNode.File -> node.content
51+
is MemoryFSNode.Directory -> throw ToolException(
52+
"Path is a directory: $path",
53+
ToolErrorType.FILE_NOT_FOUND
54+
)
55+
null -> null
56+
}
57+
} catch (e: ToolException) {
58+
throw e
59+
} catch (e: Exception) {
60+
throw ToolException("Failed to read file: $path - ${e.message}", ToolErrorType.FILE_NOT_FOUND, e)
61+
}
62+
}
63+
64+
override suspend fun writeFile(path: String, content: String, createDirectories: Boolean) {
65+
try {
66+
val resolvedPath = resolvePath(path)
67+
val normalizedPath = normalizePath(resolvedPath)
68+
69+
if (createDirectories) {
70+
val parentPath = getParentPath(normalizedPath)
71+
if (parentPath != null && !exists(parentPath)) {
72+
createDirectory(parentPath, createParents = true)
73+
}
74+
}
75+
76+
val parentPath = getParentPath(normalizedPath) ?: "/"
77+
val fileName = getFileName(normalizedPath)
78+
val parentNode = findNode(parentPath) as? MemoryFSNode.Directory
79+
?: throw ToolException("Parent directory not found: $parentPath", ToolErrorType.FILE_ACCESS_DENIED)
80+
81+
val now = Clock.System.now().toEpochMilliseconds()
82+
val fileNode = MemoryFSNode.File(
83+
name = fileName,
84+
content = content,
85+
size = content.length.toLong(),
86+
lastModified = now,
87+
created = now
88+
)
89+
90+
parentNode.children[fileName] = fileNode
91+
} catch (e: ToolException) {
92+
throw e
93+
} catch (e: Exception) {
94+
throw ToolException("Failed to write file: $path - ${e.message}", ToolErrorType.FILE_ACCESS_DENIED, e)
95+
}
96+
}
97+
98+
override fun exists(path: String): Boolean {
99+
return try {
100+
val resolvedPath = resolvePath(path)
101+
val normalizedPath = normalizePath(resolvedPath)
102+
findNode(normalizedPath) != null
103+
} catch (e: Exception) {
104+
false
105+
}
106+
}
107+
108+
override fun listFiles(path: String, pattern: String?): List<String> {
109+
return try {
110+
val resolvedPath = resolvePath(path)
111+
val normalizedPath = normalizePath(resolvedPath)
112+
val node = findNode(normalizedPath) as? MemoryFSNode.Directory ?: return emptyList()
113+
114+
val files = node.children.filter { (_, child) -> child is MemoryFSNode.File }
115+
116+
val result = if (pattern != null) {
117+
val regex = convertGlobToRegex(pattern)
118+
files.filter { (name, _) -> regex.matches(name) }
119+
} else {
120+
files
121+
}
122+
123+
result.map { (name, _) ->
124+
if (normalizedPath == "/") "/$name" else "$normalizedPath/$name"
125+
}.sorted()
126+
} catch (e: Exception) {
127+
emptyList()
128+
}
129+
}
130+
131+
override fun resolvePath(relativePath: String): String {
132+
if (relativePath.startsWith("/")) {
133+
return relativePath
134+
}
135+
return if (projectPath != null) {
136+
"$projectPath/$relativePath"
137+
} else {
138+
"/$relativePath"
139+
}
140+
}
141+
142+
override fun getFileInfo(path: String): FileInfo? {
143+
return try {
144+
val resolvedPath = resolvePath(path)
145+
val normalizedPath = normalizePath(resolvedPath)
146+
val node = findNode(normalizedPath) ?: return null
147+
148+
when (node) {
149+
is MemoryFSNode.File -> FileInfo(
150+
path = normalizedPath,
151+
isDirectory = false,
152+
size = node.size,
153+
lastModified = node.lastModified,
154+
isReadable = true,
155+
isWritable = true
156+
)
157+
is MemoryFSNode.Directory -> FileInfo(
158+
path = normalizedPath,
159+
isDirectory = true,
160+
size = 0L,
161+
lastModified = node.lastModified,
162+
isReadable = true,
163+
isWritable = true
164+
)
165+
}
166+
} catch (e: Exception) {
167+
null
168+
}
169+
}
170+
171+
override fun createDirectory(path: String, createParents: Boolean) {
172+
try {
173+
val resolvedPath = resolvePath(path)
174+
val normalizedPath = normalizePath(resolvedPath)
175+
176+
if (normalizedPath == "/") return // Root already exists
177+
178+
val segments = normalizedPath.split("/").filter { it.isNotEmpty() }
179+
var currentPath = ""
180+
var currentNode = root
181+
182+
for (segment in segments) {
183+
currentPath = if (currentPath.isEmpty()) "/$segment" else "$currentPath/$segment"
184+
185+
val existing = currentNode.children[segment]
186+
when {
187+
existing is MemoryFSNode.Directory -> {
188+
currentNode = existing
189+
}
190+
existing is MemoryFSNode.File -> {
191+
throw ToolException(
192+
"Cannot create directory: file exists at $currentPath",
193+
ToolErrorType.FILE_ACCESS_DENIED
194+
)
195+
}
196+
existing == null -> {
197+
if (!createParents && currentPath != normalizedPath) {
198+
throw ToolException(
199+
"Parent directory does not exist: $currentPath",
200+
ToolErrorType.DIRECTORY_NOT_FOUND
201+
)
202+
}
203+
val newDir = MemoryFSNode.Directory(
204+
name = segment,
205+
children = mutableMapOf(),
206+
lastModified = Clock.System.now().toEpochMilliseconds()
207+
)
208+
currentNode.children[segment] = newDir
209+
currentNode = newDir
210+
}
211+
}
212+
}
213+
} catch (e: ToolException) {
214+
throw e
215+
} catch (e: Exception) {
216+
throw ToolException("Failed to create directory: $path - ${e.message}", ToolErrorType.FILE_ACCESS_DENIED, e)
217+
}
218+
}
219+
220+
override fun delete(path: String, recursive: Boolean) {
221+
try {
222+
val resolvedPath = resolvePath(path)
223+
val normalizedPath = normalizePath(resolvedPath)
224+
225+
if (normalizedPath == "/") {
226+
throw ToolException("Cannot delete root directory", ToolErrorType.FILE_ACCESS_DENIED)
227+
}
228+
229+
val parentPath = getParentPath(normalizedPath) ?: "/"
230+
val fileName = getFileName(normalizedPath)
231+
val parentNode = findNode(parentPath) as? MemoryFSNode.Directory
232+
?: throw ToolException("Parent directory not found: $parentPath", ToolErrorType.DIRECTORY_NOT_FOUND)
233+
234+
val node = parentNode.children[fileName]
235+
?: throw ToolException("File or directory not found: $path", ToolErrorType.FILE_NOT_FOUND)
236+
237+
if (node is MemoryFSNode.Directory && node.children.isNotEmpty() && !recursive) {
238+
throw ToolException("Directory not empty: $path", ToolErrorType.FILE_ACCESS_DENIED)
239+
}
240+
241+
parentNode.children.remove(fileName)
242+
} catch (e: ToolException) {
243+
throw e
244+
} catch (e: Exception) {
245+
throw ToolException("Failed to delete: $path - ${e.message}", ToolErrorType.FILE_ACCESS_DENIED, e)
246+
}
247+
}
248+
249+
// Private helper methods
250+
251+
private fun findNode(path: String): MemoryFSNode? {
252+
val normalizedPath = normalizePath(path)
253+
if (normalizedPath == "/") return root
254+
255+
val segments = normalizedPath.split("/").filter { it.isNotEmpty() }
256+
var currentNode: MemoryFSNode = root
257+
258+
for (segment in segments) {
259+
currentNode = when (currentNode) {
260+
is MemoryFSNode.Directory -> currentNode.children[segment] ?: return null
261+
is MemoryFSNode.File -> return null
262+
}
263+
}
264+
265+
return currentNode
266+
}
267+
268+
private fun normalizePath(path: String): String {
269+
if (path.isEmpty()) return "/"
270+
271+
val segments = mutableListOf<String>()
272+
for (segment in path.split("/")) {
273+
when (segment) {
274+
"", "." -> continue
275+
".." -> if (segments.isNotEmpty()) segments.removeAt(segments.size - 1)
276+
else -> segments.add(segment)
277+
}
278+
}
279+
280+
return if (segments.isEmpty()) "/" else "/${segments.joinToString("/")}"
281+
}
282+
283+
private fun getParentPath(path: String): String? {
284+
val normalizedPath = normalizePath(path)
285+
if (normalizedPath == "/") return null
286+
287+
val lastSlash = normalizedPath.lastIndexOf('/')
288+
return if (lastSlash == 0) "/" else normalizedPath.substring(0, lastSlash)
289+
}
290+
291+
private fun getFileName(path: String): String {
292+
val normalizedPath = normalizePath(path)
293+
return normalizedPath.substringAfterLast('/')
294+
}
295+
296+
private fun convertGlobToRegex(pattern: String): Regex {
297+
val regexPattern = pattern
298+
.replace(".", "\\.")
299+
.replace("*", ".*")
300+
.replace("?", ".")
301+
return Regex("^$regexPattern$")
302+
}
303+
}
304+
305+
/**
306+
* In-memory file system node (file or directory)
307+
*/
308+
private sealed class MemoryFSNode {
309+
abstract val name: String
310+
abstract val lastModified: Long
311+
312+
data class File(
313+
override val name: String,
314+
val content: String,
315+
val size: Long,
316+
override val lastModified: Long,
317+
val created: Long
318+
) : MemoryFSNode()
319+
320+
data class Directory(
321+
override val name: String,
322+
val children: MutableMap<String, MemoryFSNode>,
323+
override val lastModified: Long = Clock.System.now().toEpochMilliseconds()
324+
) : MemoryFSNode()
325+
}

0 commit comments

Comments
 (0)