watch: слежение за файлами (WatchService + debounce + реконсиляция)
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по MCP-серверу.
|
||||||
|
|
||||||
|
Создать:
|
||||||
|
memo-mcp/build.gradle.kts (kotlin("jvm") + application + зависимости на :memo-core и kotlinx-serialization-json)
|
||||||
|
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt
|
||||||
|
memo-mcp/src/test/kotlin/memo/mcp/McpProtocolTest.kt
|
||||||
|
|
||||||
|
## Зависимости модуля memo-mcp
|
||||||
|
|
||||||
|
plugins { kotlin("jvm") application; kotlin("plugin.serialization") version "2.4.10" }
|
||||||
|
application { mainClass.set("memo.mcp.McpServerKt") }
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":memo-core"))
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
}
|
||||||
|
|
||||||
|
## McpServer.kt — протокол
|
||||||
|
|
||||||
|
fun main(args: Array<String>) — читает stdin построчно, отвечает в stdout ОДНОЙ строкой JSON-RPC 2.0 на запрос.
|
||||||
|
НИЧЕГО кроме JSON-RPC в stdout не печатать (диагностика — только в stderr).
|
||||||
|
Поддерживаемые методы:
|
||||||
|
- `initialize` → result: {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"memo","version":"0.1.0"}}
|
||||||
|
- `notifications/initialized` → уведомление, ответ не отправлять
|
||||||
|
- `tools/list` → result: {"tools":[ ... ]} с тремя инструментами:
|
||||||
|
* memo_search: {"path":string,"query":string,"k":integer(по умолч. 8),"mode":string("hybrid"|"lex"|"vec")}
|
||||||
|
обязательные: path, query
|
||||||
|
* memo_status: {"path":string} — обязательный: path
|
||||||
|
* memo_reindex: {"path":string} — обязательный: path
|
||||||
|
- `tools/call` с params {"name":..., "arguments":{...}} →
|
||||||
|
result: {"content":[{"type":"text","text":"<строка>"}],"isError":<bool>}
|
||||||
|
Ошибка (нет обязательного аргумента, неизвестный инструмент, исключение) → result с "isError":true
|
||||||
|
и текстом ошибки, а НЕ JSON-RPC error.
|
||||||
|
- неизвестный метод → {"jsonrpc":"2.0","id":<id>,"error":{"code":-32601,"message":"Method not found"}}
|
||||||
|
|
||||||
|
Реализацию инструментов вынести в функции, тестируемые без процесса:
|
||||||
|
fun toolSearch(path: String, query: String, k: Int, mode: SearchMode): String
|
||||||
|
fun toolStatus(path: String): String
|
||||||
|
fun toolReindex(path: String): String
|
||||||
|
fun handleRequest(line: String): String? // null для уведомлений
|
||||||
|
|
||||||
|
Поведение:
|
||||||
|
- Модель: MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2. Пути к БД — как в CLI
|
||||||
|
(<коллекция>/.memo/index.db); определение коллекций: каталог с *.md на глубине до 2.
|
||||||
|
- toolSearch: поиск через Searcher с refresh-хуком (переиндексация перед поиском).
|
||||||
|
Текст ответа — человекочитаемый список: `<path>:<line> <heading>\n<text>\n` для каждого хита.
|
||||||
|
Пустой результат → строка "ничего не найдено".
|
||||||
|
- toolStatus: по коллекции — `файлов <N>, чанков <M>, индекс <ISO-дата>`.
|
||||||
|
- toolReindex: полная переиндексация: удалить каталог `<коллекция>/.memo` и создать заново, затем
|
||||||
|
indexTree. Вернуть `переиндексировано файлов: <N>`.
|
||||||
|
- memo_reindex через tools/call должен РАБОТАТЬ (не isError).
|
||||||
|
- Неизвестный инструмент → isError true с текстом "unknown tool: <name>".
|
||||||
|
|
||||||
|
## McpProtocolTest.kt — ровно 5 тестов, имена ровно такие
|
||||||
|
|
||||||
|
1. `initializeHandshake` — handleRequest(строка JSON с initialize) → ответ содержит "2024-11-05" и "memo".
|
||||||
|
2. `toolsListHasThreeTools` — ответ на tools/list содержит имена memo_search, memo_status, memo_reindex.
|
||||||
|
3. `unknownMethodReturns32601` — ответ содержит "-32601".
|
||||||
|
4. `searchWithoutPathIsError` — tools/call memo_search с пустыми arguments → в ответе "isError":true.
|
||||||
|
5. `notificationProducesNoResponse` — handleRequest("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}") == null.
|
||||||
|
|
||||||
|
После: ./gradlew :memo-mcp:test --rerun-tasks — зелёные; ./gradlew :memo-mcp:installDist — собирается;
|
||||||
|
бинарь: memo-mcp/build/install/memo-mcp/bin/memo-mcp
|
||||||
|
Коммит: git add -A && git commit -m "mcp: сервер memo_search/memo_status/memo_reindex"
|
||||||
|
|
||||||
|
СТРОГИЕ ЗАПРЕТЫ:
|
||||||
|
- Не выводить план текстом; сразу создавай файлы.
|
||||||
|
- Не трогать memo-core, memo-cli, memo-watch.
|
||||||
|
- Ничего не печатать в stdout, кроме строк JSON-RPC.
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm")
|
kotlin("jvm")
|
||||||
|
application
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("memo.watch.WatchMainKt")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":memo-core"))
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package memo.watch
|
||||||
|
|
||||||
|
import memo.core.Db
|
||||||
|
import memo.core.Embedder
|
||||||
|
import memo.core.Indexer
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
if (args.isEmpty()) {
|
||||||
|
System.err.println("usage: watch <path>")
|
||||||
|
exitProcess(2)
|
||||||
|
}
|
||||||
|
val raw = File(args[0])
|
||||||
|
val base = if (raw.name == ".memo") raw.parentFile ?: raw else raw
|
||||||
|
val collections = discoverCollections(base)
|
||||||
|
if (collections.isEmpty()) {
|
||||||
|
System.err.println("коллекции не найдены в ${base.absolutePath}")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||||
|
val modelPath = "$modelDir/text_model_int8.onnx"
|
||||||
|
val tokenizerPath = "$modelDir/tokenizer.model"
|
||||||
|
|
||||||
|
val ctxList = collections.map { coll ->
|
||||||
|
val memoDir = File(coll, ".memo")
|
||||||
|
memoDir.mkdirs()
|
||||||
|
val db = Db(File(memoDir, "index.db").absolutePath)
|
||||||
|
db.init()
|
||||||
|
val embedder = Embedder(modelPath, tokenizerPath)
|
||||||
|
val indexer = Indexer(db, embedder)
|
||||||
|
CollCtx(coll, db, embedder, indexer)
|
||||||
|
}
|
||||||
|
|
||||||
|
val indexersByCollection = ctxList.associateBy { it.root }
|
||||||
|
val watcher = Watcher(
|
||||||
|
collections = ctxList.map { it.root },
|
||||||
|
index = { coll -> indexersByCollection.getValue(coll).indexer.indexTree(coll) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Runtime.getRuntime().addShutdownHook(
|
||||||
|
Thread({
|
||||||
|
try { watcher.close() } catch (_: Throwable) {}
|
||||||
|
for (ctx in ctxList) {
|
||||||
|
try { ctx.embedder.close() } catch (_: Throwable) {}
|
||||||
|
try { ctx.db.close() } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
}, "memo-watch-shutdown")
|
||||||
|
)
|
||||||
|
|
||||||
|
watcher.start()
|
||||||
|
for (ctx in ctxList) {
|
||||||
|
println("наблюдаю: ${ctx.root.absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
CountDownLatch(1).await()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CollCtx(
|
||||||
|
val root: File,
|
||||||
|
val db: Db,
|
||||||
|
val embedder: Embedder,
|
||||||
|
val indexer: Indexer,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun discoverCollections(base: File): List<File> {
|
||||||
|
if (!base.isDirectory) return emptyList()
|
||||||
|
val candidates = LinkedHashSet<File>()
|
||||||
|
candidates.add(base)
|
||||||
|
val q = ArrayDeque<Pair<File, Int>>()
|
||||||
|
q.addLast(base to 0)
|
||||||
|
while (q.isNotEmpty()) {
|
||||||
|
val (d, depth) = q.removeFirst()
|
||||||
|
if (depth >= 2) continue
|
||||||
|
val children = d.listFiles() ?: continue
|
||||||
|
for (c in children) {
|
||||||
|
if (c.isDirectory && !c.name.startsWith(".")) {
|
||||||
|
candidates.add(c)
|
||||||
|
q.addLast(c to depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates.filter { d ->
|
||||||
|
d.walkTopDown()
|
||||||
|
.maxDepth(8)
|
||||||
|
.any { it.isFile && it.extension == "md" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package memo.watch
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.ClosedWatchServiceException
|
||||||
|
import java.nio.file.FileSystems
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.StandardWatchEventKinds
|
||||||
|
import java.nio.file.WatchKey
|
||||||
|
import java.nio.file.WatchService
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
class Watcher(
|
||||||
|
private val collections: List<File>,
|
||||||
|
private val index: (File) -> Unit,
|
||||||
|
private val debounceMillis: Long = 500,
|
||||||
|
private val reconcileMillis: Long = 10 * 60 * 1000,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
private val watchService: WatchService = FileSystems.getDefault().newWatchService()
|
||||||
|
private val keyToCollection = ConcurrentHashMap<WatchKey, File>()
|
||||||
|
private val running = AtomicBoolean(false)
|
||||||
|
private val closed = AtomicBoolean(false)
|
||||||
|
private val _indexCalls = AtomicInteger(0)
|
||||||
|
|
||||||
|
val indexCalls: Int get() = _indexCalls.get()
|
||||||
|
|
||||||
|
private val thread: Thread = Thread({ runLoop() }, "memo-watcher").apply { isDaemon = true }
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (!running.compareAndSet(false, true)) return
|
||||||
|
for (coll in collections) {
|
||||||
|
try {
|
||||||
|
registerRecursive(coll, coll)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
System.err.println("watcher: register ${coll.absolutePath} failed: ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thread.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerRecursive(dir: File, collection: File) {
|
||||||
|
if (!dir.isDirectory) return
|
||||||
|
if (dir.name.startsWith(".")) return
|
||||||
|
val key = dir.toPath().register(
|
||||||
|
watchService,
|
||||||
|
StandardWatchEventKinds.ENTRY_CREATE,
|
||||||
|
StandardWatchEventKinds.ENTRY_MODIFY,
|
||||||
|
StandardWatchEventKinds.ENTRY_DELETE,
|
||||||
|
)
|
||||||
|
keyToCollection[key] = collection
|
||||||
|
val children = dir.listFiles() ?: return
|
||||||
|
for (child in children) {
|
||||||
|
if (child.isDirectory && !child.name.startsWith(".")) {
|
||||||
|
registerRecursive(child, collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (!closed.compareAndSet(false, true)) return
|
||||||
|
running.set(false)
|
||||||
|
thread.interrupt()
|
||||||
|
try { watchService.close() } catch (_: Throwable) {}
|
||||||
|
try { thread.join(2000) } catch (_: InterruptedException) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runLoop() {
|
||||||
|
var nextReconcile = System.currentTimeMillis() + reconcileMillis
|
||||||
|
while (running.get()) {
|
||||||
|
try {
|
||||||
|
val firstKey = watchService.poll(debounceMillis, TimeUnit.MILLISECONDS)
|
||||||
|
if (firstKey != null) {
|
||||||
|
val affected = HashSet<File>()
|
||||||
|
processKey(firstKey, affected)
|
||||||
|
val endDeadline = System.currentTimeMillis() + debounceMillis
|
||||||
|
while (running.get()) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now >= endDeadline) break
|
||||||
|
val remaining = endDeadline - now
|
||||||
|
val nextKey = watchService.poll(remaining, TimeUnit.MILLISECONDS) ?: break
|
||||||
|
processKey(nextKey, affected)
|
||||||
|
}
|
||||||
|
for (coll in affected) {
|
||||||
|
callIndex(coll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (System.currentTimeMillis() >= nextReconcile) {
|
||||||
|
nextReconcile = System.currentTimeMillis() + reconcileMillis
|
||||||
|
for (coll in collections) {
|
||||||
|
callIndex(coll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: ClosedWatchServiceException) {
|
||||||
|
break
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
if (!running.get()) break
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
System.err.println("watcher loop: ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processKey(key: WatchKey, affected: MutableSet<File>) {
|
||||||
|
val collection = keyToCollection[key] ?: return
|
||||||
|
for (event in key.pollEvents()) {
|
||||||
|
val kind = event.kind()
|
||||||
|
if (kind === StandardWatchEventKinds.OVERFLOW) continue
|
||||||
|
val ctx = event.context() as? Path ?: continue
|
||||||
|
val watchable = key.watchable() as? Path ?: continue
|
||||||
|
val ev = watchable.resolve(ctx).toFile()
|
||||||
|
when (kind) {
|
||||||
|
StandardWatchEventKinds.ENTRY_CREATE -> {
|
||||||
|
affected.add(collection)
|
||||||
|
if (ev.isDirectory && !ev.name.startsWith(".")) {
|
||||||
|
try {
|
||||||
|
registerRecursive(ev, collection)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
System.err.println("watcher: registerRecursive ${ev.absolutePath} failed: ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StandardWatchEventKinds.ENTRY_MODIFY,
|
||||||
|
StandardWatchEventKinds.ENTRY_DELETE -> affected.add(collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val valid = key.reset()
|
||||||
|
if (!valid) keyToCollection.remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun callIndex(coll: File) {
|
||||||
|
_indexCalls.incrementAndGet()
|
||||||
|
try {
|
||||||
|
index(coll)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
System.err.println("watcher: index ${coll.absolutePath} failed: ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package memo.watch
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class WatcherTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modifyTriggersSingleIndexCall() {
|
||||||
|
val tempDir = Files.createTempDirectory("memo-watch-").toFile()
|
||||||
|
tempDir.deleteOnExit()
|
||||||
|
val note = File(tempDir, "note.md")
|
||||||
|
note.writeText("# initial")
|
||||||
|
note.deleteOnExit()
|
||||||
|
val counter = AtomicInteger(0)
|
||||||
|
val watcher = Watcher(
|
||||||
|
collections = listOf(tempDir),
|
||||||
|
index = { counter.incrementAndGet() },
|
||||||
|
debounceMillis = 200,
|
||||||
|
reconcileMillis = TimeUnit.HOURS.toMillis(1),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
watcher.start()
|
||||||
|
Thread.sleep(300)
|
||||||
|
note.appendText("\nappended")
|
||||||
|
assertTrue(
|
||||||
|
waitFor(5_000, 100) { counter.get() >= 1 },
|
||||||
|
"index должен сработать в течение 5с, получено ${counter.get()}",
|
||||||
|
)
|
||||||
|
Thread.sleep(1500)
|
||||||
|
assertEquals(1, counter.get(), "ожидался ровно 1 вызов index, получено ${counter.get()}")
|
||||||
|
} finally {
|
||||||
|
watcher.close()
|
||||||
|
tempDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unchangedFileDoesNotTriggerIndex() {
|
||||||
|
val tempDir = Files.createTempDirectory("memo-watch-").toFile()
|
||||||
|
tempDir.deleteOnExit()
|
||||||
|
val note = File(tempDir, "note.md")
|
||||||
|
note.writeText("# initial")
|
||||||
|
note.deleteOnExit()
|
||||||
|
val counter = AtomicInteger(0)
|
||||||
|
val watcher = Watcher(
|
||||||
|
collections = listOf(tempDir),
|
||||||
|
index = { counter.incrementAndGet() },
|
||||||
|
debounceMillis = 200,
|
||||||
|
reconcileMillis = TimeUnit.HOURS.toMillis(1),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
watcher.start()
|
||||||
|
Thread.sleep(300)
|
||||||
|
Thread.sleep(1500)
|
||||||
|
assertEquals(0, counter.get(), "не должно быть вызовов index, получено ${counter.get()}")
|
||||||
|
} finally {
|
||||||
|
watcher.close()
|
||||||
|
tempDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun closeIsIdempotentAndStopsThread() {
|
||||||
|
val tempDir = Files.createTempDirectory("memo-watch-").toFile()
|
||||||
|
tempDir.deleteOnExit()
|
||||||
|
val watcher = Watcher(
|
||||||
|
collections = listOf(tempDir),
|
||||||
|
index = {},
|
||||||
|
)
|
||||||
|
watcher.start()
|
||||||
|
watcher.close()
|
||||||
|
watcher.close()
|
||||||
|
tempDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitFor(timeoutMs: Long, stepMs: Long, condition: () -> Boolean): Boolean {
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
if (condition()) return true
|
||||||
|
Thread.sleep(stepMs)
|
||||||
|
}
|
||||||
|
return condition()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user