FilestoreDir.kt
src/jvmMain/kotlin/afield/ai/knowledge/FilestoreDir.kt · 135 lines
package afield.ai.knowledge
import afield.runtime.completion.KotlinSourceIndex
import afield.ai.conversation.*
import afield.ai.models.*
import afield.graphics.render.*
import afield.app.diagnostics.logging.lprintln
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import kotlin.io.path.isRegularFile
Where Field's derived completion indexes live, resolved in one place for every index that needs it.
KotlinSourceIndex, afield.ai.tools.GlslIncludeLibraryIndex and the generated-completion-doc cache all read
their directory from FilestoreDir. The index is per checkout because KotlinSourceIndex.runSync indexes
src/{jvmMain,personal,generatedLinalg}/kotlin relative to user.dir: a single machine-wide directory
would let two checkouts, a docs build or a sibling worktree overwrite each other's index. Completion
ranking reads that index (a candidate's category comes from
KotlinSourceIndex.getCompletionCategoryForSymbol), so a shared directory would also make a test measure
whichever Field ran last rather than its own input.
Resolution order, highest first:
-Dafield.filestore.dir=<path>, which the build forwards to Gradle test workers along with the rest of theafield.namespace, so a test can pin a fixture index.AFIELD_FILESTORE_DIR=<path>, the same for a shell or a CI job.<user.dir>/.field/filestore, the per-checkout default.
On first use of (3) in a checkout that has none, the legacy ~/.afield/filestore is copied in if it
exists. Those caches are expensive to rebuild: filestore_cache.json maps content hashes to OpenAI file
IDs, and discarding it re-uploads every source file. The copy happens once, is logged, and is
best-effort; a failure leaves an empty directory, which means a slow first run, not a wrong answer.
FilestoreDirTest exercises the default, the explicit pin and the empty-directory case. See dev_docs/HERMETIC_COMPLETION_INPUTS.md.
object FilestoreDir
Resolves the filestore directory: system property, then environment variable, then the checkout default.
object FilestoreDir {
const val PROPERTY = "afield.filestore.dir"
const val ENV = "AFIELD_FILESTORE_DIR"
val legacyGlobal: Path
The machine-wide ~/.afield/filestore, which FilestoreDir copies from and never deletes.
val legacyGlobal: Path
get() = Paths.get(System.getProperty("user.home"), ".afield", "filestore")
@Volatile
private var pinned: Path? = null
@Volatile
private var seeded: Path? = null
val path: Path
The directory itself. Read late rather than captured in a val, so that a test pinning it
after some object's <clinit> has run is still obeyed; a value captured at class-load time
would keep answering with whatever was resolved first.
val path: Path
get() = pinned
?: System.getProperty(PROPERTY)?.trim()?.ifBlank { null }?.let(Paths::get)
?: System.getenv(ENV)?.trim()?.ifBlank { null }?.let(Paths::get)
?: perCheckout().also(::seedFromLegacyOnce)
fun resolve(name: String): Path = path.resolve(name)
fun ensure(): Path = path.also { runCatching { Files.createDirectories(it) } }
Create it if it is not there, and return it. Callers that write should use this.
private fun perCheckout(): Path =
Paths.get(System.getProperty("user.dir")).resolve(".field").resolve("filestore")
Copy the legacy global caches into a checkout that has none, once per JVM.
A copy, not a move: another Field, or an older build, may still be reading the global copy, and a half-migrated machine that answers slowly is better than one that answers wrongly.
private fun seedFromLegacyOnce(target: Path) {
if (seeded == target) return
synchronized(this) {
if (seeded == target) return
seeded = target
if (Files.exists(target)) return
val legacy = legacyGlobal
if (!Files.isDirectory(legacy)) return
runCatching {
Files.createDirectories(target)
var files = 0
var bytes = 0L
Files.list(legacy).use { stream ->
stream.filter { it.isRegularFile() }.forEach { src ->
Files.copy(src, target.resolve(src.fileName), StandardCopyOption.REPLACE_EXISTING)
files++
bytes += runCatching { Files.size(src) }.getOrDefault(0L)
}
}
lprintln(
"info,filestore",
"seeded $target from the legacy global filestore ($files file(s), ${bytes / 1024}KB); " +
"this checkout now has its own"
)
}.onFailure {
lprintln("warning,filestore", "could not seed $target from $legacy: ${it.message}")
}
}
}
fun <T> withDirectory(dir: Path, block: () -> T): T {
Run block with the filestore pinned to dir — how a test hands the completion path a known
index instead of whatever the machine last wrote.
Restores the previous pin rather than clearing it, so an inner pin leaves an outer one standing.
fun <T> withDirectory(dir: Path, block: () -> T): T {
val was = pinned
pinned = dir
return try {
block()
} finally {
pinned = was
}
}
fun describe(): String
What was resolved and why, for a banner or a bug report.
fun describe(): String = when {
pinned != null -> "$path (pinned in-process)"
!System.getProperty(PROPERTY).isNullOrBlank() -> "$path (-D$PROPERTY)"
!System.getenv(ENV).isNullOrBlank() -> "$path ($ENV)"
else -> "$path (per-checkout default)"
}
}