The Arcology web publishing system is a "sync-to-publish" platform: files edited in Emacs or on the phone flow to the file host via Syncthing and appear on the web without git commits, and without an org-export pass.
The sync subcommand will automatically update the local database by connecting to the local Syncthing node and polling it for changes. It connects to the local Syncthing REST API, finds the folder that maps to the org directory, long-polls the event stream, and incrementally indexes every changed org file.
The design keeps the platform boundary that The Indexer Platform Layer established, so that it can be used in other parts of the stack like the Android application:
SyncCommand.kt(jvmMain) — the Clikt subcommand that wires everything together in to The Arcology CLISyncthingWatchService.kt(commonMain) — pure poll/dispatch logic, testable without a serverSyncthingClient.kt(commonMain) — interface + event/folder DTOs, no HTTP dependencyKtorSyncthingClient.kt(jvmMain) — the Ktor CIO implementation of the interface
The watcher writes arcology.db while arcology serve reads it, so the shared DatabaseFactory sets busy_timeout and =journal_mode=WAL= and there is not a huge concern of write contention. See roam/models.org.
SyncCommand.kt
Mirrors =ServeCommand='s option layout so the two commands run side by side against the same database and directory. The indexer comes from FlowFileIndexerFactory so every plugin (publishing routes, feeds, attachments, quiz, tasks, arroyo) stays in sync with file changes.
package computer.whatthefuck.arcology.indexer
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.CliktError
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.int
import kotlinx.coroutines.runBlocking
import java.io.File
class SyncCommand : CliktCommand(
name = "sync",
help = "Watch Syncthing for org file changes and keep the index up to date"
) {
private val dbPath: String by option("--db", help = "Path to SQLite database").default("~/org/arcology.db")
private val orgDir: String by option("--org-dir", help = "Root org-mode directory").default("~/org")
private val apiUrl: String by option("--api-url", help = "Syncthing REST API base URL").default("http://127.0.0.1:8384")
private val apiKeyOption: String? by option("--api-key", help = "Syncthing REST API key (defaults to \$ARCOLOGY_SYNCTHING_API_KEY)")
private val folderId: String? by option("--folder", help = "Explicit Syncthing folder ID (overrides path matching)")
private val pollTimeoutSeconds: Int by option("--poll-timeout", help = "Event long-poll timeout in seconds").int().default(60)
override fun run() {
val apiKey = apiKeyOption ?: System.getenv("ARCOLOGY_SYNCTHING_API_KEY")
if (apiKey.isNullOrBlank()) {
throw CliktError("--api-key or ARCOLOGY_SYNCTHING_API_KEY must be set")
}
val expandedDb = dbPath.replace("~", System.getProperty("user.home"))
val expandedOrgDir = orgDir.replace("~", System.getProperty("user.home"))
val orgPath = File(expandedOrgDir).absolutePath
println("Starting Syncthing watch...")
println(" Database: $expandedDb")
println(" Org dir: $orgPath")
println(" Syncthing API: $apiUrl")
println(" Folder: ${folderId ?: "(resolved from --org-dir)"}")
val indexer = createIndexingService(expandedDb, IndexingConfig(), orgPath)
val client = KtorSyncthingClient(apiUrl, apiKey, pollTimeoutSeconds)
val service = SyncthingWatchService(
client = client,
indexer = indexer,
orgDir = orgPath,
folderId = folderId,
pollTimeoutSeconds = pollTimeoutSeconds,
)
runBlocking { service.watch() }
}
}SyncthingWatchService.kt
The watch service is pure logic: folder resolution, cursor management, event filtering, and dispatch into the indexer. It never touches HTTP or SQL directly, those are farmed out to the SyncthingClient and the FileIndexingService.
package computer.whatthefuck.arcology.indexer
import kotlinx.coroutines.delay
const val SYNCTHING_LOCAL_CHANGE = "LocalChangeDetected"
const val SYNCTHING_REMOTE_CHANGE = "RemoteChangeDetected"
<<watcher-helpers>>
/**
* Watches one Syncthing folder and keeps the index current with the files
* on disk. Call [startup] once, then run [watch]; it long-polls the event
* stream forever (or for [watch]'s [maxIterations] polls, for tests) and
* feeds each change to the indexer's single-file path, which runs all
* IndexerPlugins and skips unchanged content by hash.
*/
class SyncthingWatchService(
private val client: SyncthingClient,
private val indexer: FileIndexingService,
private val orgDir: String,
private val folderId: String? = null,
private val pollTimeoutSeconds: Int = 60,
private val initialBackoffMs: Long = 10_000,
private val maxBackoffMs: Long = 300_000,
private val log: (String) -> Unit = { println(it) },
) {
private var cursor: Long = 0L
fun currentCursor(): Long = cursor
<<watcher-service-methods>>
}Startup looks like this:
GET /rest/config/folderslists the folders Syncthing shares. We pick the folder whose path equals the--org-diror contains it as a descendant; when several nested folders match (folder-in-folder sharing), the most specific (longest) path wins.--folderoverrides with an explicit folder ID.One full indexer pass runs at startup. The indexer's hash-skip makes this cheap, and it backfills anything missed while the watcher was down.
The event cursor is seeded with =GET /rest/events?since=0&limit=1= — the ID of the most recent buffered event. Only events newer than that get processed, so the watcher doesn't re-chew history the startup pass already covered.
/**
* Resolve the folder, run one full indexing pass, then seed the event
* cursor so only future events are processed.
*/
suspend fun startup(): SyncthingFolder {
val folder = resolveSyncthingFolder(client.folders(), orgDir, folderId)
log("Watching Syncthing folder ${folder.id} (${folder.label ?: "unlabeled"}) at ${folder.path}")
val result = indexer.indexDirectory(orgDir)
log("Startup index: ${result.successful} indexed, ${result.skipped} unchanged, ${result.failed} failed")
cursor = client.latestEventId() ?: 0L
log("Watching for events after id $cursor")
return folder
}And polling looks like this:
Each iteration long-polls =GET /rest/events?events=LocalChangeDetected,RemoteChangeDetected&since=<cursor>&timeout=<n>=.
The disk events fire at scan time:
LocalChangeDetectedfor files changed on this node,RemoteChangeDetectedfor files pulled from remote peers.The
actionfield distinguishes =added=/=modified= fromdeleted(past tense);typefilters out directory events;modifiedBynames the peer that changed a remote file.There is no
errorfield on disk events — a failed pull simply produces no event, and the startup scan heals any drift.Connection failures log a warning and retry with exponential backoff; a 403 is fatal, since no amount of retrying fixes a bad API key.
Disk events are a sparse subset of the global event log: every other event type occupies intervening IDs, so gaps between consecutive disk-event IDs are normal and no gap-detection is attempted. The startup scan is the only full indexing pass — the watcher trusts the connection to not skip disk events, and a watcher that dies simply rescans when it restarts.
/**
* Run the poll loop. Each iteration is one long-poll; transient failures
* back off and retry with the cursor intact, and auth failures abort.
* Disk events are a sparse subset of the global ID space so no ID-gap
* detection is attempted — the startup scan is the safety net, and a
* restarted watcher rescans on startup.
*/
suspend fun watch(maxIterations: Int = Int.MAX_VALUE) {
val folder = startup()
var backoff = initialBackoffMs
repeat(maxIterations) {
val events = try {
client.events(cursor, pollTimeoutSeconds)
} catch (e: SyncthingAuthException) {
throw SyncthingAuthException("Syncthing rejected the API key: ${e.message}", e)
} catch (e: Exception) {
log("Syncthing API unreachable (${e.message}); retrying in ${backoff / 1000}s")
delay(backoff)
backoff = (backoff * 2).coerceAtMost(maxBackoffMs)
return@repeat
}
backoff = initialBackoffMs
if (events.isEmpty()) return@repeat
processBatch(events, folder)
cursor = events.last().id
}
}/**
* Turn one batch of disk events into indexer calls. Both event types are
* deduped per path with delete-wins semantics; only .org files reach the
* indexer and directories are skipped.
*/
suspend fun processBatch(events: List<SyncthingEvent>, folder: SyncthingFolder) {
val actions = LinkedHashMap<String, String>()
for (event in events) {
if (event.type != SYNCTHING_LOCAL_CHANGE && event.type != SYNCTHING_REMOTE_CHANGE) continue
val data = event.data ?: continue
val path = data.path ?: continue
if (folder.id.isNotBlank() && data.folder != null && data.folder != folder.id) continue
if (data.itemType != null && data.itemType != "file") continue
val existing = actions[path]
actions[path] = when {
data.action == "deleted" -> "delete"
existing == "delete" -> "delete"
else -> "update"
}
}
for ((item, action) in actions) {
if (!isIndexableOrgItem(item)) continue
if (isSyncConflictItem(item)) {
log("Conflict file detected, indexing as-is (resolve it manually): $item")
}
val resolved = resolveSyncthingItemPath(item, folder.path)
when (action) {
"delete" -> {
indexer.removeFile(resolved)
log("Removed from index: $resolved")
}
else -> {
when (val result = indexer.indexFile(resolved)) {
is FileIndexResult.Success ->
log("Indexed ${result.filePath} (${result.nodesCount} nodes)")
is FileIndexResult.Error ->
log("Index error for $resolved: ${result.message}")
is FileIndexResult.Skipped ->
log("Unchanged, skipped: $resolved")
}
}
}
}
}/**
* Pick the Syncthing folder that backs [orgDir]. An explicit [folderId]
* wins; otherwise any folder whose path equals orgDir or contains it
* matches, and the most specific (longest) path wins when folders are
* nested inside each other.
*/
fun resolveSyncthingFolder(
folders: List<SyncthingFolder>,
orgDir: String,
folderId: String? = null,
): SyncthingFolder {
if (folderId != null) {
return folders.firstOrNull { it.id.equals(folderId, ignoreCase = true) }
?: throw SyncthingClientException(
"No Syncthing folder with id '$folderId' (have: ${folders.map { it.id }})"
)
}
val normalized = orgDir.trimEnd('/')
val matches = folders.filter { folder ->
val path = folder.path.trimEnd('/')
path == normalized || normalized.startsWith("$path/")
}
if (matches.isEmpty()) {
throw SyncthingClientException(
"No Syncthing folder contains $orgDir; folder paths are: ${folders.map { it.path }}"
)
}
return matches.maxBy { it.path.length }
}
/**
,* True for .org files the indexer should see. Syncthing noise — dotfiles,
,* .stfolder, .syncthing.*.tmp scratch files — is filtered here.
,*/
fun isIndexableOrgItem(item: String): Boolean {
if (!item.lowercase().endsWith(".org")) return false
return item.split('/').none { it.startsWith(".") }
}
fun isSyncConflictItem(item: String): Boolean =
item.substringAfterLast('/').contains(".sync-conflict-")
fun resolveSyncthingItemPath(item: String, folderPath: String): String =
if (item.startsWith("/")) item
else "${folderPath.trimEnd('/')}/$item"SyncthingWatchServiceTest
The tests run against fakes so no Syncthing instance is needed: a FakeSyncthingClient programmed with a script of emitted batches and failures, and a RecordingIndexer that records single-file dispatch.
package computer.whatthefuck.arcology.indexer
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
private class FakeSyncthingClient(
var foldersList: List<SyncthingFolder> = emptyList(),
) : SyncthingClient {
private sealed interface Step
private class EmitStep(val events: List<SyncthingEvent>) : Step
private class FailStep(val error: Exception) : Step
private val steps = ArrayDeque<Step>()
val eventCalls = mutableListOf<Pair<Long, Int>>()
var seededEventId: Long? = null
fun emit(vararg events: SyncthingEvent) {
steps.addLast(EmitStep(events.toList()))
}
fun fail(error: Exception) {
steps.addLast(FailStep(error))
}
override suspend fun folders(): List<SyncthingFolder> = foldersList
override suspend fun events(since: Long, timeoutSeconds: Int): List<SyncthingEvent> {
eventCalls.add(since to timeoutSeconds)
return when (val step = steps.removeFirstOrNull()) {
is EmitStep -> step.events
is FailStep -> throw step.error
null -> emptyList()
}
}
override suspend fun latestEventId(): Long? = seededEventId
}
private class RecordingIndexer : FileIndexingService {
val indexed = mutableListOf<String>()
val removed = mutableListOf<String>()
var directoryPasses = 0
override suspend fun indexFile(filePath: String): FileIndexResult {
indexed.add(filePath)
return FileIndexResult.Success(filePath, nodesCount = 1, linksCount = 0, tagsCount = 0)
}
override suspend fun removeFile(filePath: String): Boolean {
removed.add(filePath)
return true
}
override suspend fun indexDirectory(path: String, recursive: Boolean): IndexResult {
directoryPasses++
return IndexResult(totalFiles = 0, successful = 0, failed = 0, skipped = 0)
}
override fun indexDirectoryFlow(path: String, recursive: Boolean): Flow<IndexProgress> =
emptyFlow()
}
private fun diskEvent(
id: Long,
path: String,
action: String = "modified",
folder: String? = "abc-def",
itemType: String? = "file",
modifiedBy: String? = null,
) = SyncthingEvent(
id = id,
type = if (modifiedBy == null) SYNCTHING_LOCAL_CHANGE else SYNCTHING_REMOTE_CHANGE,
data = SyncthingEventData(
path = path,
folder = folder,
action = action,
itemType = itemType,
modifiedBy = modifiedBy,
),
)
private fun watchService(
client: SyncthingClient,
indexer: FileIndexingService,
orgDir: String = "/home/rrix/org",
pollTimeoutSeconds: Int = 60,
) = SyncthingWatchService(
client = client,
indexer = indexer,
orgDir = orgDir,
pollTimeoutSeconds = pollTimeoutSeconds,
initialBackoffMs = 1,
maxBackoffMs = 4,
log = {},
)
class SyncthingWatchServiceTest {
@Test
fun `resolveFolder matches exact path`() {
val folders = listOf(
SyncthingFolder("abc-def", "Org", "/home/rrix/org"),
SyncthingFolder("other", "Other", "/home/other"),
)
assertEquals("abc-def", resolveSyncthingFolder(folders, "/home/rrix/org").id)
}
@Test
fun `resolveFolder matches folder containing orgDir`() {
val folders = listOf(SyncthingFolder("wide", null, "/home/rrix"))
assertEquals("wide", resolveSyncthingFolder(folders, "/home/rrix/org").id)
}
@Test
fun `resolveFolder prefers most specific nested folder`() {
val folders = listOf(
SyncthingFolder("wide", null, "/home/rrix"),
SyncthingFolder("specific", null, "/home/rrix/org"),
)
assertEquals("specific", resolveSyncthingFolder(folders, "/home/rrix/org").id)
}
@Test
fun `resolveFolder explicit id overrides path matching`() {
val folders = listOf(SyncthingFolder("abc-def", "Org", "/somewhere/else"))
assertEquals("abc-def", resolveSyncthingFolder(folders, "/home/rrix/org", folderId = "abc-def").id)
}
@Test
fun `resolveFolder throws when nothing matches`() {
val folders = listOf(SyncthingFolder("other", null, "/home/other"))
assertFailsWith<SyncthingClientException> {
resolveSyncthingFolder(folders, "/home/rrix/org")
}
}
@Test
fun `resolveFolder throws for unknown explicit id`() {
assertFailsWith<SyncthingClientException> {
resolveSyncthingFolder(emptyList(), "/home/rrix/org", folderId = "nope")
}
}
@Test
fun `indexable items are org files without dotfile segments`() {
assertTrue(isIndexableOrgItem("journals/2026/journal.org"))
assertTrue(isIndexableOrgItem("index.org"))
assertFalse(isIndexableOrgItem("notes.md"))
assertFalse(isIndexableOrgItem(".stfolder"))
assertFalse(isIndexableOrgItem(".stfolder/notes.org"))
assertFalse(isIndexableOrgItem(".syncthing.journal.org.tmp"))
assertFalse(isIndexableOrgItem("journals/.hidden.org"))
}
@Test
fun `conflict files are detected`() {
assertTrue(isSyncConflictItem("journals/foo.sync-conflict-20260909-120000.org"))
assertFalse(isSyncConflictItem("journals/foo.org"))
}
@Test
fun `item paths resolve relative to folder`() {
assertEquals("/home/rrix/org/a.org", resolveSyncthingItemPath("a.org", "/home/rrix/org"))
assertEquals("/abs/a.org", resolveSyncthingItemPath("/abs/a.org", "/home/rrix/org"))
assertEquals("/home/rrix/org/a.org", resolveSyncthingItemPath("a.org", "/home/rrix/org/"))
}
@Test
fun `startup resolves folder, indexes directory, seeds cursor`() = runTest {
val client = FakeSyncthingClient(
listOf(SyncthingFolder("abc-def", "Org", "/home/rrix/org"))
).apply { seededEventId = 42 }
val indexer = RecordingIndexer()
val service = watchService(client, indexer)
val folder = service.startup()
assertEquals("abc-def", folder.id)
assertEquals(1, indexer.directoryPasses)
assertEquals(42, service.currentCursor())
}
@Test
fun `processBatch indexes updates and resolves paths`() = runTest {
val client = FakeSyncthingClient()
val indexer = RecordingIndexer()
val service = watchService(client, indexer)
val folder = SyncthingFolder("abc-def", null, "/home/rrix/org")
service.processBatch(
listOf(
diskEvent(1, "journals/j.org"),
diskEvent(2, "notes.md"),
diskEvent(3, "images/pic.png"),
),
folder,
)
assertEquals(listOf("/home/rrix/org/journals/j.org"), indexer.indexed)
assertTrue(indexer.removed.isEmpty())
}
@Test
fun `processBatch dispatches deletes to removeFile`() = runTest {
val client = FakeSyncthingClient()
val indexer = RecordingIndexer()
val service = watchService(client, indexer)
val folder = SyncthingFolder("abc-def", null, "/home/rrix/org")
service.processBatch(
listOf(
diskEvent(1, "journals/j.org", action = "deleted"),
),
folder,
)
assertEquals(listOf("/home/rrix/org/journals/j.org"), indexer.removed)
assertTrue(indexer.indexed.isEmpty())
}
@Test
fun `processBatch dedupes with delete-wins`() = runTest {
val client = FakeSyncthingClient()
val indexer = RecordingIndexer()
val service = watchService(client, indexer)
val folder = SyncthingFolder("abc-def", null, "/home/rrix/org")
service.processBatch(
listOf(
diskEvent(1, "journals/j.org"),
diskEvent(2, "journals/j.org", action = "deleted"),
),
folder,
)
assertEquals(listOf("/home/rrix/org/journals/j.org"), indexer.removed)
assertTrue(indexer.indexed.isEmpty())
}
@Test
fun `processBatch ignores other folders, errors, and directories`() = runTest {
val client = FakeSyncthingClient()
val indexer = RecordingIndexer()
val service = watchService(client, indexer)
val folder = SyncthingFolder("abc-def", null, "/home/rrix/org")
service.processBatch(
listOf(
diskEvent(1, "j.org", folder = "other-folder"),
diskEvent(2, "subdir", itemType = "dir"),
diskEvent(3, "keep.org", modifiedBy = "BPDFDTU"),
),
folder,
)
assertEquals(listOf("/home/rrix/org/keep.org"), indexer.indexed)
}
@Test
fun `watch processes batches and advances cursor`() = runTest {
val client = FakeSyncthingClient(
listOf(SyncthingFolder("abc-def", null, "/home/rrix/org"))
).apply {
seededEventId = 0
emit(diskEvent(1, "a.org"))
emit()
}
val indexer = RecordingIndexer()
val service = watchService(client, indexer)
service.watch(maxIterations = 2)
assertEquals(listOf("/home/rrix/org/a.org"), indexer.indexed)
assertEquals(1, service.currentCursor())
assertEquals(1, indexer.directoryPasses)
}
@Test
fun `watch handles sparse event ids without rescanning`() = runTest {
val client = FakeSyncthingClient(
listOf(SyncthingFolder("abc-def", null, "/home/rrix/org"))
).apply {
seededEventId = 0
emit(diskEvent(1001, "a.org"), diskEvent(1042, "b.org"))
}
val indexer = RecordingIndexer()
val service = watchService(client, indexer)
service.watch(maxIterations = 1)
assertEquals(1, indexer.directoryPasses)
assertEquals(1042, service.currentCursor())
assertEquals(
listOf("/home/rrix/org/a.org", "/home/rrix/org/b.org"),
indexer.indexed
)
}
@Test
fun `watch survives transient failures with backoff`() = runTest {
val client = FakeSyncthingClient(
listOf(SyncthingFolder("abc-def", null, "/home/rrix/org"))
).apply {
seededEventId = 0
fail(RuntimeException("connection refused"))
emit(diskEvent(1, "a.org"))
}
val indexer = RecordingIndexer()
val service = watchService(client, indexer)
service.watch(maxIterations = 2)
assertEquals(listOf("/home/rrix/org/a.org"), indexer.indexed)
assertEquals(1, service.currentCursor())
}
@Test
fun `watch aborts on auth failure`() = runTest {
val client = FakeSyncthingClient(
listOf(SyncthingFolder("abc-def", null, "/home/rrix/org"))
).apply { seededEventId = 0; fail(SyncthingAuthException("403")) }
val indexer = RecordingIndexer()
val service = watchService(client, indexer)
assertFailsWith<SyncthingAuthException> {
service.watch(maxIterations = 1)
}
}
}SyncthingClient.kt
The interface returns plain data classes so the watch service compiles in commonMain and each platform supplies its own transport (Ktor on the JVM, OkHttp on Android).
package computer.whatthefuck.arcology.indexer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A folder Syncthing shares, as reported by /rest/config/folders. Only the
* fields the watcher needs are modeled; the DTOs tolerate unknown keys so
* newer Syncthing versions don't break parsing.
*/
@Serializable
data class SyncthingFolder(
val id: String,
val label: String? = null,
val path: String,
)
@Serializable
data class SyncthingEvent(
val id: Long,
@SerialName("globalID") val globalId: Long? = null,
val time: String? = null,
val type: String,
val data: SyncthingEventData? = null,
)
@Serializable
data class SyncthingEventData(
val path: String? = null,
val folder: String? = null,
val action: String? = null,
@SerialName("modifiedBy") val modifiedBy: String? = null,
@SerialName("type") val itemType: String? = null,
)
/**
* Thrown for transport-level failures the watch loop can retry.
*/
open class SyncthingClientException(message: String, cause: Throwable? = null) :
Exception(message, cause)
/**
* Thrown when Syncthing rejects the API key. The watch loop treats this as
* fatal: retrying with the same key cannot succeed.
*/
class SyncthingAuthException(message: String, cause: Throwable? = null) :
SyncthingClientException(message, cause)
/**
* Minimal interface over the Syncthing REST API so the watch service stays
* platform-independent. Platform implementations live in jvmMain (Ktor) and,
* eventually, androidMain (OkHttp).
*/
interface SyncthingClient {
suspend fun folders(): List<SyncthingFolder>
/**
* Long-poll the event stream. Pass [timeoutSeconds] as the server-side
* long-poll window; returns an empty list if it expires with no events.
*/
suspend fun events(since: Long, timeoutSeconds: Int): List<SyncthingEvent>
/**
* The ID of the most recent buffered event, or null if none exist.
* Implementations should use GET /rest/events?since=0&limit=1.
*/
suspend fun latestEventId(): Long?
}KtorSyncthingClient.kt
The JVM implementation. The HTTP request timeout must exceed the server-side long-poll window (60s default) so a quiet event stream reads as an empty array, not a client timeout. Responses are read as text and decoded with ignoreUnknownKeys so Syncthing can add fields across versions without breaking us.
package computer.whatthefuck.arcology.indexer
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.json.Json
class KtorSyncthingClient(
private val baseUrl: String,
private val apiKey: String,
pollTimeoutSeconds: Int = 60,
) : SyncthingClient {
private val json = Json { ignoreUnknownKeys = true }
private val client = HttpClient(CIO) {
expectSuccess = true
install(HttpTimeout) {
requestTimeoutMillis = (pollTimeoutSeconds + 30) * 1000L
}
}
private suspend fun request(
path: String,
configure: HttpRequestBuilder.() -> Unit = {},
): String =
try {
client.get("$baseUrl$path") {
header("X-API-Key", apiKey)
configure()
}.bodyAsText()
} catch (e: ClientRequestException) {
if (e.response.status.value == 403) {
throw SyncthingAuthException("Syncthing returned 403 — check the API key", e)
}
throw SyncthingClientException("Syncthing request to $path failed: ${e.message}", e)
}
override suspend fun folders(): List<SyncthingFolder> =
json.decodeFromString<List<SyncthingFolder>>(request("/rest/config/folders"))
override suspend fun events(since: Long, timeoutSeconds: Int): List<SyncthingEvent> =
json.decodeFromString<List<SyncthingEvent>>(
request("/rest/events") {
parameter("events", "$SYNCTHING_LOCAL_CHANGE,$SYNCTHING_REMOTE_CHANGE")
parameter("since", since)
parameter("timeout", timeoutSeconds)
}
)
override suspend fun latestEventId(): Long? =
json.decodeFromString<List<SyncthingEvent>>(
request("/rest/events") {
parameter("since", 0)
parameter("limit", 1)
}
).lastOrNull()?.id
}Related Modules
roam/indexer.org — the FlowFileIndexer this service drives; its single-file path runs every plugin
roam/indexer-platform.org — the platform layer pattern this file follows
web/index.org — Phase 6 of the publishing roadmap;
arcology servereads the DB this keeps currentapp/cli.org — the
arcologyCLI thissyncsubcommand registers intoSyncthing events API, LocalChangeDetected and RemoteChangeDetected documentation