feature(event-sourcing): baseline for the architecture review fixes.
- fixed the tool overlay in tui. - fixed tool calling - added schemas. - getting all sessionIds via event store. - instead of sending all events on the replay - there's a new way for replaying. - session snapshot is now a thing getting sent for previously created sessions. - added logging to important parts. - revert workflowId from WorkflowCompletedEvent.
This commit is contained in:
+2
@@ -63,6 +63,8 @@ class InMemoryEventStore : EventStore {
|
||||
.flatMap { stream -> synchronized(stream) { stream.toList() } }
|
||||
.asSequence()
|
||||
|
||||
override fun allSessionIds(): Set<SessionId> = sequences.keys
|
||||
|
||||
private fun doAppend(event: NewEvent, stream: MutableList<StoredEvent>): StoredEvent {
|
||||
val seq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) }
|
||||
.incrementAndGet()
|
||||
|
||||
+21
-12
@@ -42,7 +42,7 @@ class SqliteEventStore(
|
||||
correlation_id TEXT,
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
""".trimIndent()
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ class SqliteEventStore(
|
||||
val s = StoredEvent(
|
||||
metadata = event.metadata,
|
||||
sequence = seq,
|
||||
payload = event.payload
|
||||
payload = event.payload,
|
||||
)
|
||||
|
||||
insert(s)
|
||||
@@ -91,7 +91,7 @@ class SqliteEventStore(
|
||||
val s = StoredEvent(
|
||||
metadata = event.metadata,
|
||||
sequence = seq,
|
||||
payload = event.payload
|
||||
payload = event.payload,
|
||||
)
|
||||
|
||||
insert(s)
|
||||
@@ -111,7 +111,7 @@ class SqliteEventStore(
|
||||
SELECT * FROM events
|
||||
WHERE session_id = ?
|
||||
ORDER BY sequence ASC
|
||||
""".trimIndent()
|
||||
""".trimIndent(),
|
||||
).use { ps ->
|
||||
ps.setString(1, sessionId.value)
|
||||
|
||||
@@ -131,7 +131,7 @@ class SqliteEventStore(
|
||||
WHERE session_id = ?
|
||||
AND sequence > ?
|
||||
ORDER BY sequence ASC
|
||||
""".trimIndent()
|
||||
""".trimIndent(),
|
||||
).use { ps ->
|
||||
ps.setString(1, sessionId.value)
|
||||
ps.setLong(2, fromSequence)
|
||||
@@ -151,7 +151,7 @@ class SqliteEventStore(
|
||||
SELECT MAX(sequence)
|
||||
FROM events
|
||||
WHERE session_id = ?
|
||||
""".trimIndent()
|
||||
""".trimIndent(),
|
||||
).use { ps ->
|
||||
ps.setString(1, sessionId.value)
|
||||
|
||||
@@ -169,7 +169,7 @@ class SqliteEventStore(
|
||||
"""
|
||||
SELECT * FROM events
|
||||
ORDER BY session_id ASC, sequence ASC
|
||||
""".trimIndent()
|
||||
""".trimIndent(),
|
||||
).use { ps ->
|
||||
ps.executeQuery().use { rs ->
|
||||
buildList {
|
||||
@@ -180,6 +180,15 @@ class SqliteEventStore(
|
||||
}
|
||||
}.asSequence()
|
||||
|
||||
override fun allSessionIds(): Set<SessionId> =
|
||||
mutableSetOf<SessionId>().apply {
|
||||
connection.prepareStatement("SELECT DISTINCT session_id FROM events").use { ps ->
|
||||
ps.executeQuery().use { rs ->
|
||||
while (rs.next()) add(SessionId(rs.getString("session_id")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private fun nextSequence(sessionId: SessionId): Long =
|
||||
@@ -188,7 +197,7 @@ class SqliteEventStore(
|
||||
SELECT COALESCE(MAX(sequence), 0)
|
||||
FROM events
|
||||
WHERE session_id = ?
|
||||
""".trimIndent()
|
||||
""".trimIndent(),
|
||||
).use { ps ->
|
||||
ps.setString(1, sessionId.value)
|
||||
ps.executeQuery().use { rs ->
|
||||
@@ -211,7 +220,7 @@ class SqliteEventStore(
|
||||
correlation_id,
|
||||
payload
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
""".trimIndent(),
|
||||
).use { ps ->
|
||||
ps.setString(1, event.metadata.eventId.value)
|
||||
ps.setString(2, event.metadata.sessionId.value)
|
||||
@@ -231,7 +240,7 @@ class SqliteEventStore(
|
||||
"""
|
||||
SELECT * FROM events
|
||||
WHERE event_id = ?
|
||||
""".trimIndent()
|
||||
""".trimIndent(),
|
||||
).use { ps ->
|
||||
ps.setString(1, eventId.value)
|
||||
|
||||
@@ -250,8 +259,8 @@ private fun ResultSet.toStoredEvent(jsonSerializer: JsonEventSerializer): Stored
|
||||
timestamp = Instant.parse(getString("timestamp")),
|
||||
schemaVersion = getInt("schema_version"),
|
||||
causationId = getString("causation_id")?.let { CausationId(it) },
|
||||
correlationId = getString("correlation_id")?.let { CorrelationId(it) }
|
||||
correlationId = getString("correlation_id")?.let { CorrelationId(it) },
|
||||
),
|
||||
sequence = getLong("sequence"),
|
||||
payload = jsonSerializer.deserialize(getString("payload"))
|
||||
payload = jsonSerializer.deserialize(getString("payload")),
|
||||
)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.correx.infrastructure
|
||||
|
||||
import com.correx.core.approvals.ApprovalProjector
|
||||
import com.correx.core.approvals.DefaultApprovalReducer
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||
import com.correx.core.artifacts.repository.ArtifactRepository
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.EventDispatcher
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.inference.DefaultInferenceRouter
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
import com.correx.core.inference.ProviderRegistry
|
||||
import com.correx.core.inference.RoutingStrategy
|
||||
import com.correx.core.router.DefaultRouterContextBuilder
|
||||
import com.correx.core.router.DefaultRouterFacade
|
||||
import com.correx.core.router.DefaultRouterReducer
|
||||
@@ -24,11 +24,8 @@ import com.correx.infrastructure.artifactscas.CasArtifactStore
|
||||
import com.correx.infrastructure.artifactscas.config.CasConfig
|
||||
import com.correx.infrastructure.artifactscas.index.SqliteArtifactIndex
|
||||
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
|
||||
import com.correx.infrastructure.inference.commons.ModelDescriptor
|
||||
import com.correx.infrastructure.inference.commons.ModelManager
|
||||
import com.correx.infrastructure.inference.commons.ResidencyMode
|
||||
import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager
|
||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
||||
import com.correx.infrastructure.persistence.SqliteEventStore
|
||||
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
||||
@@ -41,7 +38,6 @@ import com.correx.infrastructure.workflow.FileSystemPromptLoader
|
||||
import com.correx.infrastructure.workflow.PromptLoader
|
||||
import com.correx.infrastructure.workflow.TomlWorkflowLoader
|
||||
import com.correx.infrastructure.workflow.WorkflowLoader
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
@@ -71,25 +67,9 @@ object InfrastructureModule {
|
||||
return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) }
|
||||
}
|
||||
|
||||
fun createModelManager(
|
||||
eventStore: EventStore,
|
||||
httpClient: HttpClient,
|
||||
eventDispatcher: EventDispatcher,
|
||||
): ModelManager = DefaultModelManager(
|
||||
eventStore = eventStore,
|
||||
httpClient = httpClient,
|
||||
eventDispatcher = eventDispatcher,
|
||||
)
|
||||
|
||||
fun createToolRegistry(config: ToolConfig): ToolRegistry =
|
||||
DefaultToolRegistry.build(config.buildTools())
|
||||
|
||||
fun createInferenceRouter(
|
||||
providers: List<InferenceProvider> = emptyList(),
|
||||
registry: ProviderRegistry = DefaultProviderRegistry(providers),
|
||||
strategy: RoutingStrategy = FirstAvailableRoutingStrategy(),
|
||||
): InferenceRouter = DefaultInferenceRouter(registry, strategy)
|
||||
|
||||
fun createLlamaCppProvider(
|
||||
modelId: String,
|
||||
modelPath: String,
|
||||
@@ -111,6 +91,16 @@ object InfrastructureModule {
|
||||
fun createArtifactRepository(eventStore: EventStore): ArtifactRepository =
|
||||
LiveArtifactRepository(eventStore, DefaultArtifactReducer())
|
||||
|
||||
fun createApprovalRepository(eventStore: EventStore): DefaultApprovalRepository =
|
||||
DefaultApprovalRepository(
|
||||
DefaultEventReplayer(
|
||||
eventStore,
|
||||
ApprovalProjector(
|
||||
DefaultApprovalReducer(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun createWorkflowLoader(): WorkflowLoader = TomlWorkflowLoader()
|
||||
|
||||
fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()
|
||||
|
||||
+33
-1
@@ -13,7 +13,11 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.InvalidPathException
|
||||
@@ -39,7 +43,35 @@ class FileEditTool(
|
||||
|
||||
override val name: String = "file_edit"
|
||||
override val description: String = "Edit content in a file at the specified path"
|
||||
override val parametersSchema: JsonObject = buildJsonObject {}
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("path") {
|
||||
put("type", "string")
|
||||
put("description", "Relative path to the file to edit")
|
||||
}
|
||||
putJsonObject("operation") {
|
||||
put("type", "string")
|
||||
put("description", "Either 'append', 'replace', or 'patch'")
|
||||
}
|
||||
putJsonObject("content") {
|
||||
put("type", "string")
|
||||
put("description", "Content to write. Used for 'append' and 'replace'.")
|
||||
}
|
||||
putJsonObject("old_str") {
|
||||
put("type", "string")
|
||||
put("description", "Exact string to find. Required for 'patch'.")
|
||||
}
|
||||
putJsonObject("new_str") {
|
||||
put("type", "string")
|
||||
put("description", "Replacement string. Required for 'patch'.")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray {
|
||||
add(JsonPrimitive("path"))
|
||||
add(JsonPrimitive("operation"))
|
||||
})
|
||||
}
|
||||
override val tier: Tier = Tier.T3
|
||||
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
|
||||
|
||||
|
||||
+80
-61
@@ -7,11 +7,14 @@ import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.InvalidPathException
|
||||
@@ -24,76 +27,92 @@ class FileReadTool(
|
||||
|
||||
override val name: String = "file_read"
|
||||
override val description: String = "Read content from a file at the specified path"
|
||||
override val parametersSchema: JsonObject = buildJsonObject {}
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("path") {
|
||||
put("type", "string")
|
||||
put("description", "Relative path to the file to read")
|
||||
}
|
||||
putJsonObject("start_line") {
|
||||
put("type", "integer")
|
||||
put("description", "First line to read, 1-indexed inclusive. Omit to read from beginning.")
|
||||
}
|
||||
putJsonObject("end_line") {
|
||||
put("type", "integer")
|
||||
put("description", "Last line to read, 1-indexed inclusive. Omit to read to end of file.")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("path")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T1
|
||||
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult =
|
||||
(request.parameters["path"] as? String)?.let { pathString ->
|
||||
runCatching {
|
||||
val path = Paths.get(pathString).normalize().toAbsolutePath()
|
||||
if (allowedPaths.none { path.startsWith(it.normalize().toAbsolutePath()) }) {
|
||||
return@runCatching ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
|
||||
}
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
val pathString = request.parameters["path"] as? String
|
||||
?: return ValidationResult.Invalid("Missing or invalid 'path' parameter. Expected String.")
|
||||
val startLine = request.parameters["start_line"]?.toString()?.toIntOrNull()
|
||||
val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
|
||||
|
||||
if (startLine != null && startLine < 1)
|
||||
return ValidationResult.Invalid("'start_line' must be >= 1.")
|
||||
if (endLine != null && endLine < 1)
|
||||
return ValidationResult.Invalid("'end_line' must be >= 1.")
|
||||
if (startLine != null && endLine != null && endLine < startLine)
|
||||
return ValidationResult.Invalid("'end_line' must be >= 'start_line'.")
|
||||
|
||||
return runCatching {
|
||||
val path = Paths.get(pathString).normalize().toAbsolutePath()
|
||||
if (allowedPaths.isNotEmpty() && allowedPaths.none { path.startsWith(it.normalize().toAbsolutePath()) })
|
||||
ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
|
||||
else
|
||||
ValidationResult.Valid
|
||||
}.getOrElse {
|
||||
when (it) {
|
||||
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
|
||||
is IOException -> ValidationResult.Invalid("IO error: ${it.message}")
|
||||
is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}")
|
||||
else -> ValidationResult.Invalid(it.message ?: "Unknown error occured")
|
||||
}
|
||||
}.getOrElse {
|
||||
when (it) {
|
||||
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
|
||||
is IOException -> ValidationResult.Invalid("IO error: ${it.message}")
|
||||
is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}")
|
||||
else -> ValidationResult.Invalid(it.message ?: "Unknown error occurred")
|
||||
}
|
||||
} ?: ValidationResult.Invalid("Missing or invalid 'path' parameter. Expected String.")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
|
||||
validateRequest(request).run {
|
||||
takeIf { it is ValidationResult.Valid }?.let {
|
||||
val pathString = request.parameters["path"] as String
|
||||
val path = Paths.get(pathString).normalize().toAbsolutePath()
|
||||
if (Files.exists(path)) {
|
||||
readStringCatching(request, path, pathString)
|
||||
} else {
|
||||
ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "File not found: $pathString",
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
} ?: ToolResult.Failure(
|
||||
val validation = validateRequest(request)
|
||||
if (validation is ValidationResult.Invalid)
|
||||
return@withContext ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = (this as ValidationResult.Invalid).reason,
|
||||
reason = validation.reason,
|
||||
recoverable = false,
|
||||
)
|
||||
|
||||
val pathString = request.parameters["path"] as String
|
||||
val path = Paths.get(pathString).normalize().toAbsolutePath()
|
||||
val startLine = request.parameters["start_line"]?.toString()?.toIntOrNull()
|
||||
val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
|
||||
|
||||
if (!Files.exists(path))
|
||||
return@withContext ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "File not found: $pathString",
|
||||
recoverable = false,
|
||||
)
|
||||
|
||||
runCatching {
|
||||
val lines = Files.readAllLines(path)
|
||||
val start = ((startLine ?: 1) - 1).coerceAtLeast(0)
|
||||
val end = (endLine ?: lines.size).coerceAtMost(lines.size)
|
||||
val content = lines.subList(start, end).joinToString("\n")
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = content,
|
||||
)
|
||||
}.getOrElse {
|
||||
ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "Failed to read file: ${it.message}",
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readStringCatching(request: ToolRequest, path: Path, pathString: String): ToolResult =
|
||||
runCatching {
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = Files.readString(path),
|
||||
)
|
||||
}.getOrElse {
|
||||
when (it) {
|
||||
is CancellationException -> throw it
|
||||
is SecurityException -> ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "Access denied: $pathString, ${it.message}",
|
||||
recoverable = false,
|
||||
)
|
||||
|
||||
is IOException -> ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "IO error: ${it.message}",
|
||||
recoverable = false,
|
||||
)
|
||||
|
||||
else -> ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = it.message ?: "Unknown error occurred while reading file",
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-4
@@ -12,8 +12,13 @@ import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.nio.file.Path
|
||||
|
||||
class ShellTool(
|
||||
@@ -23,18 +28,36 @@ class ShellTool(
|
||||
) : Tool, ToolExecutor {
|
||||
override val name: String = "shell"
|
||||
override val description: String = "Execute a shell command"
|
||||
override val parametersSchema: JsonObject = buildJsonObject {}
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("argv") {
|
||||
put("type", "array")
|
||||
putJsonObject("items") { put("type", "string") }
|
||||
put("description", "Command and arguments as a list. Example: [\"bash\", \"script.sh\"]")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("argv")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> =
|
||||
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
|
||||
val argv = when (val raw = request.parameters["argv"]) {
|
||||
is List<*> -> raw.filterIsInstance<String>()
|
||||
is String -> runCatching {
|
||||
Json.decodeFromString<List<String>>(raw)
|
||||
}.getOrElse { emptyList() }
|
||||
else -> emptyList()
|
||||
}
|
||||
return when {
|
||||
argv.isNullOrEmpty() ->
|
||||
argv.isEmpty() ->
|
||||
ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.")
|
||||
|
||||
allowedExecutables.isNotEmpty() && argv[0] !in allowedExecutables ->
|
||||
ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.")
|
||||
|
||||
else -> ValidationResult.Valid
|
||||
}
|
||||
}
|
||||
@@ -42,7 +65,13 @@ class ShellTool(
|
||||
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
|
||||
validateRequest(request).run {
|
||||
takeIf { this is ValidationResult.Valid }?.let {
|
||||
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
|
||||
val argv = when (val raw = request.parameters["argv"]) {
|
||||
is List<*> -> raw.filterIsInstance<String>()
|
||||
is String -> runCatching {
|
||||
Json.decodeFromString<List<String>>(raw)
|
||||
}.getOrElse { emptyList() }
|
||||
else -> emptyList()
|
||||
}
|
||||
val process = ProcessBuilder(argv).apply {
|
||||
workingDir?.let { directory(it.toFile()) }
|
||||
}.start()
|
||||
|
||||
Reference in New Issue
Block a user