diff --git a/CHANGELOG.md b/CHANGELOG.md index 78dcf56..b3b19f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased — 0.1.2 +- Added USB-priority full-generation synchronization with a separately + configured private Drive mirror/fallback transport, verified manifest-last + publication, shared multi-transport identities/ACKs, and explicit per- + transport Web status without ever transporting SQLite. +- Added an Android user-enabled foreground USB listener, private Drive folder + connection with bounded WorkManager checks, and one shared concurrency- + guarded generation coordinator for UI and background triggers. +- Added an accessible preparation-list send action with separate editing and + delivery states, durable retry behavior, duplicate-delivery prevention and + stable execution-identity transition from Preparation to History/Program + completion. + +- Implemented `TRAINLOG_PROGRAM_CALENDAR_V1` as the operational top-level + `/programmes` daily active-Program calendar. It consumes typed Core reads and + preparation creation, derives Monday–Sunday weeks from Program bounds and + real planned dates without inventing rest days, keeps undated sessions + separate, and rereads Core state after Prepare. Sessions → Programmes remains + the administration/import/list/detail/archive/delete surface; this adds no + Core, API, schema, format, synchronization, or Android changes. + - Fixed Program-to-preparation creation for Program V1 target weights written as integer JSON numbers. Imports now preserve both integer and real number spellings instead of persisting integer weights as zero. Existing affected diff --git a/README.md b/README.md index f130f1e..af9ffbc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # Trainlog +Synchronization uses one generation/ACK engine over USB/MTP first and an +optional private Google Drive mirror/fallback. Drive contains only versioned +Trainlog artifacts under its separate Sync namespace; desktop and Android +SQLite databases always remain local. Android can process PC USB requests from +an explicitly enabled, visibly notified background service, while Drive checks +follow Android's bounded background scheduling. + Trainlog is a local-first workout and body-data system. Trainlog Core owns business truth and canonical desktop persistence. Its native Android app is the field companion, its C17/Notcurses desktop TUI is the administration and diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 67858cd..ff06af9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -146,6 +146,9 @@ dependencies { implementation( "androidx.activity:activity-compose:1.13.0" ) + implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.documentfile:documentfile:1.1.0") + implementation("androidx.work:work-runtime-ktx:2.10.5") implementation( "androidx.compose.foundation:foundation" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 14905c6..9a42954 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + + + diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/DriveSyncTransport.kt b/android/app/src/main/java/com/labfytools/trainlog/data/DriveSyncTransport.kt new file mode 100644 index 0000000..99eec1a --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/DriveSyncTransport.kt @@ -0,0 +1,338 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import java.io.File +import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.time.Duration +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.TimeUnit +import org.json.JSONObject + +internal class DriveSyncSettings(private val context: Context) { + private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + val treeUri: Uri? get() = preferences.getString(TREE, null)?.let(Uri::parse) + val connected: Boolean get() = treeUri != null + + fun connect(uri: Uri) { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + preferences.edit().putString(TREE, uri.toString()).apply() + schedule(context) + } + + fun disconnect() { + treeUri?.let { uri -> + runCatching { + context.contentResolver.releasePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + } + preferences.edit().remove(TREE).apply() + WorkManager.getInstance(context).cancelUniqueWork(WORK) + } + + companion object { + private const val PREFERENCES = "trainlog-drive-sync" + private const val TREE = "private_tree_uri" + private const val WORK = "trainlog-drive-sync-v1" + + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder().build()) + } + } +} + +/** Byte-only adapter over one user-selected private Drive folder. */ +internal class DriveDocumentTransport(private val context: Context, treeUri: Uri) { + private val root = DocumentFile.fromTreeUri(context, treeUri) + ?: throw IllegalStateException("Drive folder is unavailable") + private val resolver = context.contentResolver + private val directories = mutableMapOf("" to root) + + private fun segments(relative: String): List { + val parts = relative.split('/') + require(parts.size in 1..4 && parts.all { it.isNotEmpty() && it != "." && it != ".." && '\\' !in it }) + return parts + } + + private fun find(relative: String): DocumentFile? { + val parts = segments(relative) + var parent = root + var prefix = "" + parts.forEachIndexed { index, name -> + if (index == parts.lastIndex) return parent.findFile(name) + prefix = if (prefix.isEmpty()) name else "$prefix/$name" + parent = + directories[prefix] + ?: parent.findFile(name)?.also { + if (it.isDirectory) directories[prefix] = it + } + ?: return null + } + return null + } + + private fun ensureParent(parts: List): DocumentFile { + var parent = root + var prefix = "" + for (name in parts) { + prefix = if (prefix.isEmpty()) name else "$prefix/$name" + parent = + directories[prefix] + ?: parent.findFile(name) + ?: parent.createDirectory(name) + ?: throw IllegalStateException("Cannot create Drive folder") + require(parent.isDirectory) + directories[prefix] = parent + } + return parent + } + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { stream -> + val buffer = ByteArray(64 * 1024) + while (true) { + val count = stream.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun downloadDocument(source: DocumentFile, destination: File): Boolean { + if (!source.isFile || source.length() > 64L * 1024L * 1024L) return false + destination.parentFile?.mkdirs() + val temporary = File(destination.parentFile, ".${destination.name}.drive.tmp-${UUID.randomUUID()}") + resolver.openInputStream(source.uri)?.use { input -> + FileOutputStream(temporary).use { output -> input.copyTo(output); output.fd.sync() } + } ?: return false + /* WHY: coordination files are deliberately replaced across runs, and + * File.renameTo() does not reliably replace an existing destination. + * CONTRACT: consumers observe the old complete file or the newly + * downloaded complete file, never a partial provider stream. + * INVARIANT: the temporary is unique and on the destination filesystem. */ + try { + Files.move( + temporary.toPath(), + destination.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + temporary.toPath(), + destination.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (error: Exception) { + temporary.delete() + throw error + } + return true + } + + private fun download(relative: String, destination: File): Boolean = + find(relative)?.let { downloadDocument(it, destination) } ?: false + + private fun upload(relative: String, source: File) { + require(source.isFile && !java.nio.file.Files.isSymbolicLink(source.toPath())) + val parts = segments(relative) + val parent = ensureParent(parts.dropLast(1)) + val destination = parent.findFile(parts.last()) + ?: parent.createFile("application/json", parts.last()) + ?: throw IllegalStateException("Cannot create Drive object") + resolver.openOutputStream(destination.uri, "wt")?.use { output -> + source.inputStream().use { it.copyTo(output) } + output.flush() + } ?: throw IllegalStateException("Cannot write Drive object") + val verification = File.createTempFile("trainlog-drive-verify-", ".json", context.cacheDir) + try { + /* WHY: some SAF providers do not make a newly created child + * discoverable by name immediately. CONTRACT: verification reads + * the exact object returned by createFile/findFile above. + * INVARIANT: size and SHA-256 are checked before publication can + * advance to a manifest or coordination reference. */ + require(downloadDocument(destination, verification)) + require(verification.length() == source.length() && sha256(verification) == sha256(source)) + } finally { + verification.delete() + } + } + + fun pull(localRoot: File) { + localRoot.mkdirs() + val coordination = listOf( + "android-peer-v1.json", "android-generation-v1.json", "android-consumption-ack-v1.json", + "android-generation-error-v1.json", "request-v1.json", + "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json", + "desktop-generation-v1.json", + ) + coordination.forEach { download(it, File(localRoot, it)) } + listOf("android-generation-v1.json", "desktop-generation-v1.json").forEach { name -> + val reference = File(localRoot, name) + if (!reference.isFile || reference.length() > 64 * 1024) return@forEach + val value = JSONObject(reference.readText()) + val relative = value.getString("relative_path") + val generationParts = relative.split('/') + require( + generationParts.size == 3 && + generationParts[0] in setOf("android-objects", "desktop-objects") && + generationParts[1] == "generations" && + generationParts[2] == value.getString("generation_id") + ) + val manifestPath = "$relative/manifest.json" + val manifest = File(localRoot, manifestPath) + if (!download(manifestPath, manifest)) return@forEach + val artifacts = JSONObject(manifest.readText()).getJSONArray("artifacts") + require(artifacts.length() <= 32) + val generationRelative = "${generationParts[1]}/${generationParts[2]}" + for (index in 0 until artifacts.length()) { + val filename = artifacts.getJSONObject(index).getString("filename") + require(filename.substringBeforeLast('/', "") == generationRelative) + val artifactPath = "${generationParts[0]}/$filename" + require(download(artifactPath, File(localRoot, artifactPath))) + } + } + } + + fun pushPeer(localRoot: File) { + upload("android-peer-v1.json", File(localRoot, "android-peer-v1.json")) + } + + fun pushGeneration(localRoot: File) { + val reference = File(localRoot, "android-generation-v1.json") + require(reference.isFile && reference.length() <= 64 * 1024) + val value = JSONObject(reference.readText()) + val relative = value.getString("relative_path") + val parts = relative.split('/') + require( + parts.size == 3 && + parts[0] == "android-objects" && + parts[1] == "generations" && + parts[2] == value.getString("generation_id") + ) + val generation = File(localRoot, relative) + require(generation.isDirectory && !java.nio.file.Files.isSymbolicLink(generation.toPath())) + val files = + generation.listFiles()?.filter { it.isFile }?.sortedWith( + compareBy { it.name == "manifest.json" } + .thenBy { it.name }, + ) ?: throw IllegalStateException("Drive generation is unavailable") + require(files.lastOrNull()?.name == "manifest.json") + /* WHY: a Drive tree may contain downloaded desktop objects, stale + * generations, and local staging. Uploading that cache made a single + * phase exceed WorkManager's execution window and crossed transport + * ownership boundaries. CONTRACT: Android publishes only the current + * immutable Android generation. INVARIANT: artifacts precede the + * manifest commit marker, and the correlated reference is last. */ + files.forEach { upload("$relative/${it.name}", it) } + upload("android-generation-v1.json", reference) + } + + fun pushAcknowledgement(localRoot: File) { + upload( + "android-consumption-ack-v1.json", + File(localRoot, "android-consumption-ack-v1.json"), + ) + } +} + +class DriveSyncWorker(context: Context, parameters: WorkerParameters) : + CoroutineWorker(context, parameters) { + companion object { + /* WHY: reconnecting Drive updates periodic work and also enqueues an + * immediate pass; Android may start both before either reaches the + * Core conversation lock. CONTRACT: only one Drive byte conversation + * runs in this process. INVARIANT: a duplicate trigger invents no + * generation and the active worker remains the durable authority. */ + private val running = AtomicBoolean(false) + } + + override suspend fun doWork(): Result { + val uri = DriveSyncSettings(applicationContext).treeUri ?: return Result.success() + if (!running.compareAndSet(false, true)) return Result.success() + val local = File(applicationContext.filesDir, "sync-drive-v1") + val transport = DriveDocumentTransport(applicationContext, uri) + val repository = TrainlogRepository(applicationContext) + var stage = "pull" + return try { + transport.pull(local) + stage = "coordinate" + val coordinator = SyncGenerationCoordinator(repository) + coordinator.publishPeer(local) + stage = "peer-publication" + transport.pushPeer(local) + if (!coordinator.hasActionableRequest(local)) { + if (coordinator.hasReplayableAcknowledgement(local)) { + stage = "acknowledgement-replay" + transport.pushAcknowledgement(local) + } + return Result.success() + } + var lastPull = System.nanoTime() + val boundedPull = { + val now = System.nanoTime() + if (now - lastPull >= Duration.ofSeconds(5).toNanos()) { + transport.pull(local) + lastPull = System.nanoTime() + } + } + val result = coordinator.run( + local, + Duration.ofMinutes(10), + afterPeerPublication = { transport.pushPeer(local) }, + afterPublication = { + stage = "generation-publication" + transport.pushGeneration(local) + }, + pollTransport = boundedPull, + afterAcknowledgement = { + stage = "acknowledgement" + transport.pushAcknowledgement(local) + }, + ) + if (result is ForegroundGenerationResult.Completed) Result.success() else Result.retry() + } catch (error: Exception) { + /* SECURITY: stage and exception class are fixed application + * metadata. Provider messages may contain private URIs and are + * deliberately never written to logs. */ + Log.w("TrainlogDriveSync", "Drive sync failed at $stage (${error.javaClass.simpleName})") + Result.retry() + } finally { + repository.close() + running.set(false) + } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncBackgroundService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncBackgroundService.kt new file mode 100644 index 0000000..7371b77 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncBackgroundService.kt @@ -0,0 +1,144 @@ +package com.labfytools.trainlog.data + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.labfytools.trainlog.R +import java.time.Duration +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +internal class BackgroundSyncSettings(context: Context) { + private val preferences = context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + var enabled: Boolean + get() = preferences.getBoolean(ENABLED, false) + private set(value) { preferences.edit().putBoolean(ENABLED, value).apply() } + + fun enable() { enabled = true } + fun disable() { enabled = false } + + companion object { + private const val PREFERENCES = "trainlog-background-sync" + private const val ENABLED = "enabled" + } +} + +/** + * User-enabled foreground listener for PC-initiated USB generation requests. + * + * WHY: a Web request must be consumed while Compose is not visible, but modern + * Android does not permit an invisible permanent background loop. + * CONTRACT: the service shows a mandatory notification, invokes the same + * generation coordinator as SyncScreen, and never starts an exercise. + * INVARIANT: filesystem visibility only triggers protocol validation; stable + * run/generation/ACK identities remain the sole business authority. + */ +class SyncBackgroundService : Service() { + private val stopped = AtomicBoolean(false) + private val launched = AtomicBoolean(false) + private val executor = Executors.newSingleThreadExecutor() + private var repository: TrainlogRepository? = null + + override fun onCreate() { + super.onCreate() + createChannel() + val notification = NotificationCompat.Builder(this, CHANNEL) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(getString(R.string.background_sync_notification_title)) + .setContentText(getString(R.string.background_sync_notification_text)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + if (Build.VERSION.SDK_INT >= 29) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(NOTIFICATION_ID, notification) + } + repository = TrainlogRepository(applicationContext) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP || !BackgroundSyncSettings(this).enabled) { + stopSelf() + return START_NOT_STICKY + } + if (!launched.compareAndSet(false, true)) return START_NOT_STICKY + executor.execute { + val coordinator = SyncGenerationCoordinator(requireNotNull(repository)) + while (!stopped.get() && BackgroundSyncSettings(this).enabled) { + val directory = canonicalExchangeDirectory() + /* WHY: waiting inside run() owns the process-wide conversation + * lock and would starve Drive while USB is idle. CONTRACT: a + * filesystem observation only schedules bounded validation; + * the coordinator still validates all protocol evidence. + * INVARIANT: no request means no business work and no lock. */ + coordinator.publishPeer(directory) + if (coordinator.hasActionableRequest(directory)) { + coordinator.run(directory, Duration.ofMinutes(5)) + } + if (!stopped.get()) runCatching { Thread.sleep(5_000) } + } + stopSelf() + } + return START_NOT_STICKY + } + + override fun onDestroy() { + stopped.set(true) + executor.shutdownNow() + repository?.close() + repository = null + super.onDestroy() + } + + override fun onTimeout(startId: Int, fgsType: Int) { + /* Android 15 bounds dataSync foreground time across the application. + * Stop immediately rather than risking an ANR; durable generations + * and ACKs make a later explicit restart safe. */ + stopped.set(true) + stopSelf(startId) + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createChannel() { + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL, + getString(R.string.background_sync_channel), + NotificationManager.IMPORTANCE_LOW, + ), + ) + } + + companion object { + private const val CHANNEL = "trainlog_sync" + private const val NOTIFICATION_ID = 1401 + private const val ACTION_START = "com.labfytools.trainlog.sync.START" + private const val ACTION_STOP = "com.labfytools.trainlog.sync.STOP" + + fun start(context: Context) { + BackgroundSyncSettings(context).enable() + ContextCompat.startForegroundService( + context, + Intent(context, SyncBackgroundService::class.java).setAction(ACTION_START), + ) + } + + fun stop(context: Context) { + BackgroundSyncSettings(context).disable() + context.startService( + Intent(context, SyncBackgroundService::class.java).setAction(ACTION_STOP), + ) + } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt index fca6919..f029336 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt @@ -2,6 +2,9 @@ package com.labfytools.trainlog.data import java.io.File import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.time.Duration import java.util.UUID import org.json.JSONArray @@ -14,9 +17,17 @@ internal sealed interface ForegroundGenerationResult { } /** Owns the bounded, user-initiated Android side of a generation conversation. */ -internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) { +internal class SyncGenerationCoordinator(private val repository: TrainlogRepository) { private val service = SyncGenerationService(repository) + companion object { + /* WHY: UI and background triggers share one repository protocol and + * must never create concurrent runs. CONTRACT: this process-wide lock + * guards only one bounded conversation; SQLite remains the durable + * replay authority after process restart. */ + private val conversationLock = java.util.concurrent.locks.ReentrantLock() + } + internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? = repository.inSyncGenerationTransaction { db -> db.rawQuery( @@ -45,6 +56,59 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train } } + internal fun hasActionableRequest(directory: File): Boolean { + val path = File(directory, "request-v1.json") + if (!path.isFile || path.length() !in 1..64 * 1024) return false + val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return false + val runId = request.optString("run_id") + val desktopPeerId = request.optString("desktop_peer_id") + if ( + request.optString("format") != "trainlog-sync-generation-request" || + request.optInt("version", -1) != 1 || + !runId.matches(Regex("^sy_[0-9a-f-]{36}$")) || + !desktopPeerId.matches(Regex("^peer_[0-9a-f-]{36}$")) || + request.optString("android_peer_id") != service.peerId() + ) return false + if (resumableGeneration(runId, desktopPeerId) != null) return true + return !repository.inSyncGenerationTransaction { db -> + db.rawQuery( + "SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1", + arrayOf(runId), + ).use { it.moveToFirst() } + } + } + + internal fun hasReplayableAcknowledgement(directory: File): Boolean { + val request = + runCatching { JSONObject(File(directory, "request-v1.json").readText()) }.getOrNull() + ?: return false + val reference = + runCatching { JSONObject(File(directory, "desktop-generation-v1.json").readText()) } + .getOrNull() ?: return false + val acknowledgement = + runCatching { JSONObject(File(directory, "android-consumption-ack-v1.json").readText()) } + .getOrNull() ?: return false + /* WHY: a network failure may occur after Core commits consumption but + * before Drive receives its ACK. CONTRACT: only the exact durable ACK + * correlated to the current request and desktop reference is replayed. + * INVARIANT: replay transports existing evidence and never recaptures + * a generation or repeats a domain mutation. */ + return request.optString("format") == "trainlog-sync-generation-request" && + request.optInt("version", -1) == 1 && + request.optString("android_peer_id") == service.peerId() && + reference.optString("format") == "trainlog-sync-generation-reference" && + reference.optInt("version", -1) == 1 && + acknowledgement.optString("format") == "trainlog-sync-ack" && + acknowledgement.optInt("version", -1) == 1 && + acknowledgement.optString("run_id") == request.optString("run_id") && + reference.optString("run_id") == request.optString("run_id") && + acknowledgement.optString("generation_id") == reference.optString("generation_id") && + acknowledgement.optString("manifest_sha256") == reference.optString("manifest_sha256") && + acknowledgement.optString("producer_peer_id") == request.optString("desktop_peer_id") && + acknowledgement.optString("consumer_peer_id") == service.peerId() && + acknowledgement.optString("result") in setOf("consumed", "rejected") + } + private fun requireExactKeys(value: JSONObject, expected: Set, label: String) { val actual = value.keys().asSequence().toSet() if (actual != expected) throw SyncGenerationException("invalid $label fields") @@ -104,13 +168,78 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train stream.flush() stream.fd.sync() } - if (!temporary.renameTo(path)) throw SyncGenerationException("cannot publish ${path.name}") + /* WHY: Drive conversations reuse coordination filenames, and + * File.renameTo() does not reliably replace an existing destination + * on Android private storage. CONTRACT: readers observe either the + * previous complete JSON document or its complete replacement. + * INVARIANT: the temporary file is in the destination directory, so + * the preferred move is same-filesystem and atomic when supported. */ + try { + Files.move( + temporary.toPath(), + path.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + temporary.toPath(), + path.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: Exception) { + temporary.delete() + throw SyncGenerationException("cannot publish ${path.name}") + } } - private fun await(path: File, deadline: Long): File { + /* WHY: transports must advertise a stable installation before a desktop + * can address its first request. CONTRACT: discovery publishes no domain + * facts and does not acquire the conversation lock. INVARIANT: run() uses + * this exact descriptor, so idle discovery cannot fork capabilities. */ + internal fun publishPeer(directory: File): String { + if (!directory.isDirectory) + throw SyncGenerationException("exchange directory is unavailable") + val peer = service.peerId() + val path = File(directory, "android-peer-v1.json") + val descriptor = + JSONObject() + .put("format", "trainlog-sync-peer") + .put("version", 1) + .put("peer_id", peer) + .put( + "capabilities", + JSONArray( + listOf( + "generation-manifest-v1", + "generation-ack-v1", + "causal-delete-v1", + "mobile-history-v4", + "execution-draft-v1", + "generation-archive-v1", + "session-preparations-v2", + "programs-v1", + ) + ), + ) + .toString() + /* WHY: the foreground listener probes every five seconds, while some + * Android MediaProvider implementations cannot atomically replace an + * unchanged file that is simultaneously exposed over MTP. CONTRACT: + * an identical canonical descriptor is already a complete durable + * publication. INVARIANT: changed or unreadable bytes still use the + * crash-safe replacement path above. */ + if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false)) + return peer + publish(path, descriptor) + return peer + } + + private fun await(path: File, deadline: Long, pump: (() -> Unit)? = null): File { while (!path.isFile) { if (System.nanoTime() >= deadline) throw SyncGenerationException("timeout waiting for ${path.name}") + pump?.invoke() Thread.sleep(50) } return path @@ -120,6 +249,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train path: File, deadline: Long, predicate: (JSONObject) -> Boolean, + pump: (() -> Unit)? = null, ): ByteArray { while (true) { if (path.isFile && path.length() <= 64 * 1024) { @@ -133,6 +263,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train } if (System.nanoTime() >= deadline) throw SyncGenerationException("timeout waiting for correlated ${path.name}") + pump?.invoke() Thread.sleep(50) } } @@ -142,34 +273,14 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train timeout: Duration = Duration.ofMinutes(5), afterPeerPublication: (() -> Unit)? = null, afterPublication: (() -> Unit)? = null, - ): ForegroundGenerationResult = - try { - if (!directory.isDirectory) - throw SyncGenerationException("exchange directory is unavailable") - val peer = service.peerId() - publish( - File(directory, "android-peer-v1.json"), - JSONObject() - .put("format", "trainlog-sync-peer") - .put("version", 1) - .put("peer_id", peer) - .put( - "capabilities", - JSONArray( - listOf( - "generation-manifest-v1", - "generation-ack-v1", - "causal-delete-v1", - "mobile-history-v4", - "execution-draft-v1", - "generation-archive-v1", - "session-preparations-v2", - "programs-v1", - ) - ), - ) - .toString(), - ) + pollTransport: (() -> Unit)? = null, + afterAcknowledgement: (() -> Unit)? = null, + ): ForegroundGenerationResult { + if (!conversationLock.tryLock()) { + return ForegroundGenerationResult.Error("Synchronization already in progress.") + } + return try { + val peer = publishPeer(directory) /* WHY: trainlog-syncd is intentionally driven by the established * Android request artifact, not by polling generation internals. * CONTRACT: each explicit foreground attempt publishes the peer @@ -179,7 +290,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train afterPeerPublication?.invoke() val deadline = System.nanoTime() + timeout.toNanos() val requestRaw = - awaitCorrelated(File(directory, "request-v1.json"), deadline) { candidate -> + awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate -> val runId = candidate.optString("run_id") runId.startsWith("sy_") && (resumableGeneration(runId) != null || @@ -189,7 +300,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train arrayOf(runId), ).use { it.moveToFirst() } }) - } + }, pollTransport) val request = JSONObject(requestRaw.toString(Charsets.UTF_8)) if (request.getString("android_peer_id") != peer) throw SyncGenerationException("request targets another Android peer") @@ -197,10 +308,12 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train awaitCorrelated( File(directory, "desktop-archive-acknowledgements-v1.json"), deadline, - ) { - it.optString("run_id") == request.getString("run_id") && - it.optString("android_peer_id") == peer - } + { + it.optString("run_id") == request.getString("run_id") && + it.optString("android_peer_id") == peer + }, + pollTransport, + ) reconcileArchiveAcknowledgements( archiveAckRaw, request.getString("run_id"), @@ -240,19 +353,28 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train // the exchange directory. Only this run's exact generation ACK // can advance its lineage; stale bytes are retained and ignored. val desktopAck = - awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline) { + awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, { it.optString("run_id") == captured.runId && it.optString("generation_id") == captured.generationId - } + }, pollTransport) service.acceptAcknowledgement(desktopAck) val referenceRaw = - awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline) { + awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, { it.optString("run_id") == captured.runId - } + }, pollTransport) val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8)) + /* WHY: non-atomic transports can expose a newly downloaded + * reference locally before the corresponding immutable objects + * have reached this process. CONTRACT: correlation validates the + * reference first, then one transport refresh stages its committed + * generation before Core validates hashes and imports it. + * INVARIANT: the refresh moves bytes only; service.consume remains + * the sole validation and mutation authority. */ + pollTransport?.invoke() val acknowledgement = service.consume(File(directory, reference.getString("relative_path"))) publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement) + afterAcknowledgement?.invoke() ForegroundGenerationResult.Completed(captured.runId) } catch (error: Exception) { if (error.message == "generation retention capacity exhausted") { @@ -275,5 +397,12 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train } } ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed") + } finally { + conversationLock.unlock() } + } } + +/* Compatibility name for focused tests and callers outside the coordinator + * migration. New triggers should depend on SyncGenerationCoordinator. */ +internal typealias SyncGenerationForegroundCoordinator = SyncGenerationCoordinator diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt index 0cbe9d8..5fe69df 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt @@ -8,6 +8,8 @@ package com.labfytools.trainlog.ui /* TRAINLOG_ANDROID_TRIGGERED_SYNC_V1 */ import android.content.Context +import android.Manifest +import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.ui.platform.LocalContext @@ -28,6 +30,9 @@ import com.labfytools.trainlog.data.SyncRequestResult import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.ForegroundGenerationResult import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator +import com.labfytools.trainlog.data.BackgroundSyncSettings +import com.labfytools.trainlog.data.SyncBackgroundService +import com.labfytools.trainlog.data.DriveSyncSettings import com.labfytools.trainlog.data.canonicalExchangeDirectory import com.labfytools.trainlog.data.logDirectExchange import com.labfytools.trainlog.data.directStoragePermissionIntent @@ -164,6 +169,34 @@ fun SyncScreen( } var syncRunning by remember { mutableStateOf(false) } + val backgroundSettings = remember { BackgroundSyncSettings(context) } + var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) } + val driveSettings = remember { DriveSyncSettings(context) } + var driveConnected by remember { mutableStateOf(driveSettings.connected) } + + val driveFolderLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> + if (uri != null) { + runCatching { driveSettings.connect(uri) } + .onSuccess { + driveConnected = true + status = strings.getString(R.string.drive_sync_connected) + } + .onFailure { status = strings.getString(R.string.drive_sync_connection_failed) } + } + } + + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + SyncBackgroundService.start(context) + backgroundEnabled = true + } else { + status = strings.getString(R.string.background_sync_notification_required) + } + } LaunchedEffect( pendingRequestId @@ -453,6 +486,58 @@ fun SyncScreen( ) } + TrainlogFrame(title = strings.getString(R.string.background_sync_title)) { + TrainlogInfo( + text = strings.getString(R.string.background_sync_description), + color = if (backgroundEnabled) colors.success else colors.warning, + ) + TrainlogAction( + label = strings.getString( + if (backgroundEnabled) R.string.background_sync_disable + else R.string.background_sync_enable, + ), + description = strings.getString(R.string.background_sync_description), + accent = if (backgroundEnabled) colors.warning else colors.success, + onClick = { + if (backgroundEnabled) { + SyncBackgroundService.stop(context) + backgroundEnabled = false + } else if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != + android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + SyncBackgroundService.start(context) + backgroundEnabled = true + } + }, + ) + } + + TrainlogFrame(title = strings.getString(R.string.drive_sync_title)) { + TrainlogInfo( + text = strings.getString(R.string.drive_sync_description), + color = if (driveConnected) colors.success else colors.warning, + ) + TrainlogAction( + label = strings.getString( + if (driveConnected) R.string.drive_sync_disconnect else R.string.drive_sync_connect, + ), + description = strings.getString(R.string.drive_sync_latency), + accent = if (driveConnected) colors.warning else colors.success, + onClick = { + if (driveConnected) { + driveSettings.disconnect() + driveConnected = false + } else { + driveFolderLauncher.launch(null) + } + }, + ) + } + if (status != null) { TrainlogFrame( title = strings.getString(R.string.sync_status), diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index 241ee7e..20cc0a2 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -95,6 +95,21 @@ The Android snapshot is maintained automatically. Synchronization in progress… Synchronize now + Background synchronization + Allows Trainlog to receive PC synchronizations without opening the app. + Enable background synchronization + Disable background synchronization + Trainlog synchronization + Trainlog is ready to synchronize + The service is waiting for a PC request. + Allow notifications to enable background synchronization. + Private Google Drive + Select the private Trainlog/Sync/v1 folder. Trainlog only accesses that folder and never copies SQLite into it. + Connect Google Drive + Disconnect Google Drive + Background checks are bounded by Android and can take at least 15 minutes. + Drive folder connected. A check has been scheduled. + Unable to retain access to the Drive folder. Android → PC then PC → Android, in one operation. File access is required. Authorize Documents/Trainlog first. Request sent · waiting for the PC… diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f85160d..4a888ac 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -95,6 +95,21 @@ Le snapshot Android est maintenu automatiquement. Synchronisation en cours… Synchroniser maintenant + Synchronisation en arrière-plan + Permet à Trainlog de recevoir les synchronisations du PC sans ouvrir l’application. + Activer la synchronisation en arrière-plan + Désactiver la synchronisation en arrière-plan + Synchronisation Trainlog + Trainlog est prêt à synchroniser + Le service attend une demande du PC. + Autorisez les notifications pour activer la synchronisation en arrière-plan. + Google Drive privé + Sélectionnez le dossier privé Trainlog/Sync/v1. Trainlog n’accède qu’à ce dossier et n’y copie jamais SQLite. + Connecter Google Drive + Déconnecter Google Drive + La vérification en arrière-plan est bornée par Android et peut prendre au moins 15 minutes. + Dossier Drive connecté. Une vérification est planifiée. + Impossible de conserver l’accès au dossier Drive. Android → PC puis PC → Android, en une seule opération. Accès fichiers requis. Autorisez d’abord Documents/Trainlog. Demande envoyée · attente du PC… diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt index 8905f37..4978bab 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt @@ -104,6 +104,7 @@ class SyncDeploymentConversationTest { val coordinator = SyncGenerationForegroundCoordinator(repository) val executor = java.util.concurrent.Executors.newSingleThreadExecutor() var requestSignals = 0 + var refreshedAfterDesktopReference = false val result = executor.submit { coordinator.run( @@ -111,6 +112,14 @@ class SyncDeploymentConversationTest { Duration.ofSeconds(30), afterPeerPublication = { requestSignals += 1 }, afterPublication = { release?.let { awaitFile(File(it)) } }, + pollTransport = { + refreshedAfterDesktopReference = + runCatching { + JSONObject(File(transport, "desktop-generation-v1.json").readText()) + .optString("format") == + "trainlog-sync-generation-reference" + }.getOrDefault(false) + }, ) } val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText()) @@ -143,6 +152,10 @@ class SyncDeploymentConversationTest { completed is ForegroundGenerationResult.Completed, ) assertEquals("one fresh request signal per conversation", 1, requestSignals) + assertTrue( + "transport is refreshed after the correlated desktop reference", + refreshedAfterDesktopReference, + ) var workerOutput = "" localWorker?.let { worker -> workerOutput = worker.inputStream.bufferedReader().readText() diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index 56d3579..785d5ee 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -9,6 +9,7 @@ import com.labfytools.trainlog.model.SessionDraft import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.TrackingMode +import java.io.File import java.nio.file.Files import java.util.UUID import java.util.concurrent.CountDownLatch @@ -349,6 +350,113 @@ class SyncGenerationServiceTest { } } + @Test + fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() { + val databaseName = "generation-actionable-${UUID.randomUUID()}.db" + val root = Files.createTempDirectory("trainlog-generation-actionable-").toFile() + val repository = TrainlogRepository(context, databaseName) + try { + val coordinator = SyncGenerationCoordinator(repository) + assertEquals(false, coordinator.hasActionableRequest(root)) + File(root, "request-v1.json").writeText("not-json") + assertEquals(false, coordinator.hasActionableRequest(root)) + + val peer = SyncGenerationService(repository).peerId() + val request = + JSONObject() + .put("format", "trainlog-sync-generation-request") + .put("version", 1) + .put("run_id", "sy_${UUID.randomUUID()}") + .put("android_peer_id", "peer_${UUID.randomUUID()}") + .put("desktop_peer_id", "peer_${UUID.randomUUID()}") + File(root, "request-v1.json").writeText(request.toString()) + assertEquals(false, coordinator.hasActionableRequest(root)) + + request.put("android_peer_id", peer) + File(root, "request-v1.json").writeText(request.toString()) + assertEquals(true, coordinator.hasActionableRequest(root)) + } finally { + repository.close() + context.deleteDatabase(databaseName) + root.deleteRecursively() + } + } + + @Test + fun repeatedPeerPublicationLeavesIdenticalCanonicalFileUntouched() { + val databaseName = "generation-peer-publication-${UUID.randomUUID()}.db" + val root = Files.createTempDirectory("trainlog-generation-peer-publication-").toFile() + val repository = TrainlogRepository(context, databaseName) + try { + val coordinator = SyncGenerationCoordinator(repository) + val peer = coordinator.publishPeer(root) + val descriptor = File(root, "android-peer-v1.json") + val retainedTimestamp = 1_234_000L + assertTrue(descriptor.setLastModified(retainedTimestamp)) + + assertEquals(peer, coordinator.publishPeer(root)) + assertEquals(retainedTimestamp, descriptor.lastModified()) + assertEquals(peer, JSONObject(descriptor.readText()).getString("peer_id")) + } finally { + repository.close() + context.deleteDatabase(databaseName) + root.deleteRecursively() + } + } + + @Test + fun coordinatorReplaysOnlyStrictlyCorrelatedDurableAcknowledgement() { + val databaseName = "generation-ack-replay-${UUID.randomUUID()}.db" + val root = Files.createTempDirectory("trainlog-generation-ack-replay-").toFile() + val repository = TrainlogRepository(context, databaseName) + try { + val coordinator = SyncGenerationCoordinator(repository) + val androidPeer = SyncGenerationService(repository).peerId() + val desktopPeer = "peer_${UUID.randomUUID()}" + val runId = "sy_${UUID.randomUUID()}" + val generationId = "gen_${UUID.randomUUID()}" + val digest = "a".repeat(64) + File(root, "request-v1.json").writeText( + JSONObject() + .put("format", "trainlog-sync-generation-request") + .put("version", 1) + .put("run_id", runId) + .put("android_peer_id", androidPeer) + .put("desktop_peer_id", desktopPeer) + .toString() + ) + File(root, "desktop-generation-v1.json").writeText( + JSONObject() + .put("format", "trainlog-sync-generation-reference") + .put("version", 1) + .put("run_id", runId) + .put("generation_id", generationId) + .put("manifest_sha256", digest) + .toString() + ) + val acknowledgement = + JSONObject() + .put("format", "trainlog-sync-ack") + .put("version", 1) + .put("run_id", runId) + .put("generation_id", generationId) + .put("manifest_sha256", digest) + .put("producer_peer_id", desktopPeer) + .put("consumer_peer_id", androidPeer) + .put("result", "consumed") + File(root, "android-consumption-ack-v1.json").writeText(acknowledgement.toString()) + assertEquals(true, coordinator.hasReplayableAcknowledgement(root)) + + acknowledgement.put("generation_id", "gen_${UUID.randomUUID()}") + File(root, "android-consumption-ack-v1.json").writeText(acknowledgement.toString()) + assertEquals(false, coordinator.hasReplayableAcknowledgement(root)) + } finally { + repository.close() + context.deleteDatabase(databaseName) + root.deleteRecursively() + } + } + @Test fun v19MigrationPreservesDataAndInventsNoGenerationState() { val name = "generation-migration-${UUID.randomUUID()}.db" diff --git a/docs/android.md b/docs/android.md index 83f47ac..8614119 100644 --- a/docs/android.md +++ b/docs/android.md @@ -1,5 +1,24 @@ # Android application +## Background USB and private Drive synchronization + +SyncScreen and the explicitly enabled background service invoke one +`SyncGenerationCoordinator` above `SyncGenerationService`. The shared guard +permits one device conversation at a time. The USB listener is a real +`dataSync` foreground service with a dedicated visible notification; disabling +the setting stops it. It validates a request and generation before any domain +mutation and never starts a prepared workout. + +Private Drive access uses a user-selected system document-provider folder and +persisted folder-only permission. Disconnect revokes that permission and +cancels periodic work. WorkManager performs a connected-network check at +connection and periodic checks thereafter; Android's minimum periodic interval +is 15 minutes and OEM scheduling can add latency. Drive therefore provides +durable fallback, not an undocumented instant push promise. Android 15+ does +not permit a `dataSync` foreground service to be launched from +`BOOT_COMPLETED`, so the USB listener requires user activation again after +reboot. Local data remains usable offline and no SQLite file enters Drive. + Android schema v22 adds a pending manual-preparation store which is separate from AI proposals and the exactly-one active-session singleton. A received preparation is inert until explicit start; an occupied singleton is never diff --git a/docs/architecture.md b/docs/architecture.md index 6c04957..834c4ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,22 @@ # Architecture +## Synchronization transport boundary + +Generation capture, validation, causal import, publication and ACK acceptance +are transport-neutral. `sync_peer_worker.py` owns that conversation; direct MTP +and `sync_drive_transport.py` only move its bounded bytes. Automatic selection +probes the expected MTP peer before preferring USB. A successful USB run is +committed independently, then mirrored to Drive best-effort; Drive failure can +never reopen its SQLite transactions. If MTP is unavailable, the same worker +runs over the Drive byte adapter. + +Android mirrors that ownership: one `SyncGenerationCoordinator` is called by +SyncScreen, the visible USB foreground service, and the private-folder Drive +worker. One process guard prevents overlap, while generation and ACK tables +remain the restart authority. Transport staging and network/provider waits are +outside SQLite transactions. Neither transport can select or traverse an +application database. + Full-generation synchronization uses the common admission lock and a fixed coordinator worker. Browser input cannot select commands, paths, timeouts or capabilities. Peer identity/capabilities come from Android advertisement; diff --git a/docs/current_state.md b/docs/current_state.md index 93b4af4..7a9c615 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -1,5 +1,20 @@ # Current implementation state +Full-generation Web synchronization now selects an actually usable MTP peer +before USB, falls back to a separately configured private Drive generation +namespace, and mirrors successful USB conversations to Drive on a best-effort +basis without rolling back USB. Both transports move the same generation/ACK +bytes; neither transports SQLite. Android has an explicitly enabled foreground +USB listener with mandatory notification and a private-folder Drive grant with +bounded WorkManager checks. Both use the same concurrency-guarded generation +coordinator as SyncScreen. + +Existing preparation rows expose separate editing and Android-delivery states +and an accessible send action. Draft/local advances the same preparation to a +ready revision before delivery; ready/local delivers the current revision. +Completed history hides the active preparation exclusively through the +delivery's stable execution-session identity and retains all provenance. + The opt-in full-generation path is operational on the private daily installation. The loopback Web control and foreground Android coordinator use the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and diff --git a/docs/reviews/sync_usb_drive_android_v1_manual.md b/docs/reviews/sync_usb_drive_android_v1_manual.md new file mode 100644 index 0000000..853d059 --- /dev/null +++ b/docs/reviews/sync_usb_drive_android_v1_manual.md @@ -0,0 +1,53 @@ +# TRAINLOG_SYNC_USB_DRIVE_ANDROID_V1 manual validation + +Automated tests use isolated directory and fake Drive transports. The first +live validation must use a disposable namespace only: + +```text +Trainlog/Sync-Test// +``` + +It must never target `Trainlog/Sync`, `Trainlog/AI`, `Documents/Trainlog` from a +real user installation, or either SQLite database. + +## Preconditions + +1. Create a fresh UUIDv4-based `` and an empty private Drive folder. +2. Configure a disposable rclone remote/path for that exact folder. +3. In a debug/test Android installation, select that same folder through the + system document picker. +4. Use disposable desktop and Android databases containing one synthetic + preparation and no real account data. +5. Record hashes and stable IDs before transport; never record credentials, + URI grants, tokens or authorization headers. + +## Procedure + +1. Desktop publishes artifacts, verifies the uploaded bytes, then publishes + `manifest.json` and the generation reference last. +2. Android consumes the generation, confirms the preparation exactly once and + publishes its correlated ACK plus one synthetic completed-session + generation. +3. Desktop consumes that generation, confirms one History row, one completed + Program execution and one acknowledged preparation delivery, then publishes + its ACK. +4. Replay the same Drive objects and confirm every count is unchanged. +5. Repeat the preserved facts over fake/isolated USB and confirm no duplicate + delivery, execution session, History row or ACK. +6. Interrupt one upload before the manifest and confirm neither peer mutates. +7. Replace one artifact with a same-size SHA-mismatching payload and confirm + rejection before mutation. +8. Stop network access after USB commit and confirm USB remains committed while + Web reports the Drive mirror failure. +9. With SyncScreen closed and the user-enabled USB foreground notification + visible, start a Web run and confirm the full request/generation/import/ACK + conversation without touching Android. + +## Cleanup + +Remove only `Trainlog/Sync-Test//` and the two disposable databases. +Revoke the Android folder grant and remove the disposable rclone configuration. +Retain the redacted run report, generation/ACK identities and SHA-256 evidence. + +Live status for this implementation checkout: `NOT_RUN`; no private Drive, +physical phone or real user data was accessed by automated validation. diff --git a/docs/roadmap.md b/docs/roadmap.md index b97fae6..957400d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3,7 +3,10 @@ The controlled private rollout is complete for the paired daily installation: physical libmtp, verified backups, signing continuity, migrations, correlated ACKs, restart, and idempotent replay passed. Generation mode remains an -explicit per-installation opt-in; Drive is still unconfigured and unvalidated. +explicit per-installation opt-in. The additive private Drive transport and +Android background software are implemented and fake-transport validated; +private live Drive and post-reboot/manual device validation remain explicit +deployment gates. This document owns future work. The implemented baseline is summarized in [current_state.md](current_state.md); completed narratives and evidence belong diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 83ffd88..9257caa 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -1,5 +1,41 @@ # Synchronization exchange +## USB-priority and private Drive transport + +Full-generation synchronization has one business engine and two byte +transports. Automatic desktop admission acquires the existing global lock, +probes the configured Android MTP peer, selects USB when that peer is actually +usable, and otherwise selects the configured private Drive namespace. A cable +or retained peer advertisement is not availability evidence. + +After a successful USB conversation, desktop attempts to mirror the exact +current coordination objects and referenced immutable generations to the +separate configured `Trainlog/Sync/v1` namespace. Artifacts are uploaded and +read back for size/SHA-256 verification; a generation manifest is uploaded +after its artifacts and its mutable reference last. Mirror failure is reported +but never rolls back the already committed USB import or ACK. With Drive +disabled, historical USB behavior remains independent of rclone. + +Drive carries only existing versioned artifacts, manifests, references and +ACKs. Desktop and Android SQLite files are never eligible transport objects. +`Trainlog/Sync` is independent from `Trainlog/AI`; the rclone remote is trusted +installation configuration, not a product constant or an HTTP parameter. + +Android uses the system document provider: the user explicitly selects the +private `Trainlog/Sync/v1` folder, Trainlog persists access to that folder only, +and Disconnect revokes it. No rclone password, OAuth token or authorization +header is stored or logged by Trainlog. WorkManager checks Drive immediately +after connection and then under Android scheduling constraints; periodic work +has a 15-minute minimum and is not an instant push channel. + +The separately enabled USB background mode uses a visible `dataSync` +foreground-service notification and the same `SyncGenerationCoordinator` as +SyncScreen. A process-wide guard rejects concurrent UI/service conversations. +Android 15+ limits `dataSync` foreground-service time and forbids starting it +from `BOOT_COMPLETED`; after reboot the user must reopen Trainlog and reactivate +the listener. A filesystem/provider event is only a prompt to validate the +full run, generation, peer, hash and ACK envelope and never starts a workout. + ## Manual session preparations V2 `trainlog-session-preparations` V2 is a separate optional desktop-to-Android diff --git a/docs/tests.md b/docs/tests.md index 61ee30c..1c45bfd 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1,5 +1,14 @@ # Tests and validation +`test_sync_drive_transport` proves Drive artifact-before-manifest and +manifest-before-reference publication, rejects unsafe/AI namespaces, and keeps +a partial generation invisible. Orchestrator coverage proves that Drive mirror +failure cannot undo a completed primary transport. `web_sessions` additionally +proves duplicate-delivery rejection and stable-identity active-list removal +after the reserved execution session appears in completed history. Android's +normal unit suite builds the shared generation coordinator, foreground service +and WorkManager transport rather than a test-only protocol copy. + `web_sessions` exercises production preparation creation, durable request replay, immutable successor revisions, stale-revision conflict, duplicate exercise occurrences, list search, persisted detail serialization, revision- diff --git a/docs/web_sessions.md b/docs/web_sessions.md index 6ff3365..20a7f49 100644 --- a/docs/web_sessions.md +++ b/docs/web_sessions.md @@ -1,5 +1,20 @@ # Web Sessions V1 +Preparation list rows expose independent `editing_state` and `delivery_state`. +A local draft action saves one new ready revision on the same preparation and +then creates its delivery; a local ready revision is delivered directly. +Pending, acknowledged and remote-unknown preparations never create a second +delivery. The action is disabled while its request is running and every state +has visible text in addition to color. + +Successful delivery admission immediately starts the configured common sync +orchestrator. If transport is unavailable, the durable pending delivery remains +retryable; the preparation is not reverted or recreated. When completed +history contains the delivery's exact `execution_session_id`, the preparation +leaves the active list while all preparation revisions, entries and delivery +provenance remain stored. Program completion continues to derive from the Core +`execution_state`, never from list disappearance. + The Sessions route implements four distinct views: - Preparation: manual preparations and immutable AI proposals; diff --git a/tests/test_sync_drive_transport.py b/tests/test_sync_drive_transport.py new file mode 100644 index 0000000..2cf5aee --- /dev/null +++ b/tests/test_sync_drive_transport.py @@ -0,0 +1,145 @@ +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "sync_drive_transport", ROOT / "tools/sync_drive_transport.py" +) +drive = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(drive) + + +class MemoryDrive: + def __init__(self): + self.objects = {} + self.uploads = [] + self.downloads = [] + + def upload_verified(self, source, relative): + self.uploads.append(relative) + self.objects[relative] = Path(source).read_bytes() + + def download(self, relative, destination, optional=False): + self.downloads.append(relative) + if relative not in self.objects: + if optional: + return False + raise drive.DriveTransportError("missing object") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(self.objects[relative]) + return True + + +class DriveTransportTest(unittest.TestCase): + def generation(self, root): + generation = root / "desktop-objects/generations/gen_one" + generation.mkdir(parents=True) + artifact = generation / "history.json" + artifact.write_bytes(b"{}\n") + manifest = { + "generation_id": "gen_one", + "artifacts": [ + { + "filename": "generations/gen_one/history.json", + "sha256": drive.digest(artifact), + } + ], + } + (generation / "manifest.json").write_text(json.dumps(manifest)) + (root / "desktop-generation-v1.json").write_text( + json.dumps( + { + "generation_id": "gen_one", + "relative_path": "desktop-objects/generations/gen_one", + } + ) + ) + + def test_manifest_is_uploaded_after_artifacts_and_reference_is_last(self): + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + self.generation(root) + remote = MemoryDrive() + drive.push(remote, root) + self.assertLess( + remote.uploads.index("desktop-objects/generations/gen_one/history.json"), + remote.uploads.index("desktop-objects/generations/gen_one/manifest.json"), + ) + self.assertLess( + remote.uploads.index("desktop-objects/generations/gen_one/manifest.json"), + remote.uploads.index("desktop-generation-v1.json"), + ) + + def test_generation_reference_accepts_its_producer_namespace(self): + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + self.generation(root) + self.assertEqual( + drive.generation_from_reference(root, "desktop-generation-v1.json"), + "desktop-objects/generations/gen_one", + ) + + value = json.loads((root / "desktop-generation-v1.json").read_text()) + value["relative_path"] = "android-objects/generations/gen_one" + (root / "desktop-generation-v1.json").write_text(json.dumps(value)) + with self.assertRaises(drive.DriveTransportError): + drive.generation_from_reference(root, "desktop-generation-v1.json") + + def test_partial_generation_never_becomes_visible_locally(self): + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + remote = MemoryDrive() + remote.objects["android-generation-v1.json"] = json.dumps( + { + "generation_id": "gen_one", + "relative_path": "android-objects/generations/gen_one", + } + ).encode() + remote.objects["android-objects/generations/gen_one/manifest.json"] = json.dumps( + { + "generation_id": "gen_one", + "artifacts": [ + {"filename": "generations/gen_one/missing.json"} + ], + } + ).encode() + with self.assertRaises(drive.DriveTransportError): + drive.pull(remote, root) + self.assertFalse((root / "android-objects/generations/gen_one").exists()) + + def test_complete_generation_is_pulled_into_its_producer_namespace(self): + with tempfile.TemporaryDirectory() as raw: + source = Path(raw) / "source" + source.mkdir() + self.generation(source) + remote = MemoryDrive() + drive.push(remote, source) + destination = Path(raw) / "destination" + drive.pull(remote, destination) + self.assertEqual( + (destination / "desktop-objects/generations/gen_one/history.json").read_bytes(), + b"{}\n", + ) + + remote.downloads.clear() + drive.pull(remote, destination) + self.assertNotIn( + "desktop-objects/generations/gen_one/history.json", + remote.downloads, + ) + + def test_unsafe_or_ai_namespaces_are_rejected(self): + for remote in ("", "remote:Trainlog/AI", "remote:Trainlog/Sync/v1\nother"): + with self.subTest(remote=remote), self.assertRaises(drive.DriveTransportError): + drive.RcloneDrive("rclone", remote) + for relative in ("../db", "/absolute", "a\\b"): + with self.subTest(relative=relative), self.assertRaises(drive.DriveTransportError): + drive.safe_relative(relative) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_orchestrator.py b/tests/test_sync_orchestrator.py index 533fa91..7ba5245 100644 --- a/tests/test_sync_orchestrator.py +++ b/tests/test_sync_orchestrator.py @@ -134,6 +134,42 @@ class OrchestratorTest(unittest.TestCase): os.kill(pid, 0) self.assertEqual(json.loads(state.read_text())["phase"], "interrupted") + def test_committed_primary_run_survives_drive_mirror_failure(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sandbox = root / "program" + sandbox.mkdir() + (sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes()) + (sandbox / "sync_drive_transport.py").write_text( + "import sys\nprint('injected Drive failure',file=sys.stderr)\nraise SystemExit(1)\n" + ) + peer, desktop = identity("peer_"), identity("peer_") + inbound, outbound = identity("gen_"), identity("gen_") + report = { + "format": "trainlog-sync-worker-report", "version": 1, + "run_id": "RUN", "producer_peer_id": peer, "consumer_peer_id": desktop, + "inbound_generation_id": inbound, "outbound_generation_id": outbound, + "manifest_sha256": "a" * 64, "result": "completed", + "sessions_reconciled": 1, "domains": {"history-v4": True}, "drafts": [], + "ai_midpoint": {"result": "not_configured"}, + "ai_post_sync": {"result": "not_configured"}, + } + (sandbox / "sync_peer_worker.py").write_text( + "import json,sys\nv=" + repr(report) + "\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\nprint(json.dumps(v))\n" + ) + values = self.fixture( + root, + version=2, + drive_enabled=True, + drive_remote="fake:Trainlog/Sync/v1", + ) + result = self.invoke(sandbox / "sync_orchestrator.py", values) + self.assertEqual(result.returncode, 0, result.stderr) + final = json.loads(values[1].read_text()) + self.assertEqual(final["phase"], "completed") + self.assertEqual(final["drive_state"], "failed") + self.assertIn("injected Drive failure", final["drive_diagnostic"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sync_peer_worker.py b/tests/test_sync_peer_worker.py new file mode 100644 index 0000000..6afab48 --- /dev/null +++ b/tests/test_sync_peer_worker.py @@ -0,0 +1,35 @@ +import importlib.util +import subprocess +import time +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "sync_peer_worker", ROOT / "tools/sync_peer_worker.py" +) +worker = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(worker) + + +class SyncPeerWorkerTest(unittest.TestCase): + def test_drive_adapter_uses_remaining_conversation_deadline(self): + completed = subprocess.CompletedProcess([], 0, b"", b"") + with mock.patch.object(worker.subprocess, "run", return_value=completed) as run: + worker.run_drive_adapter( + Path("/fixed/adapter.py"), + "pull", + "remote:Trainlog/Sync-Test/run", + Path("/private/transport"), + time.monotonic() + 180, + ) + timeout = run.call_args.kwargs["timeout"] + self.assertGreaterEqual(timeout, 178) + self.assertLessEqual(timeout, 180) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/sync_drive_transport.py b/tools/sync_drive_transport.py new file mode 100644 index 0000000..8e39806 --- /dev/null +++ b/tools/sync_drive_transport.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Bounded byte transport for the Trainlog generation namespace on rclone. + +Business validation, causality, import and acknowledgement remain owned by the +existing generation services. This adapter only moves immutable bytes and +mutable run-correlated coordination objects. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import tempfile +from pathlib import Path, PurePosixPath + +COORDINATION = ( + "android-peer-v1.json", + "android-generation-v1.json", + "android-consumption-ack-v1.json", + "android-generation-error-v1.json", + "request-v1.json", + "desktop-archive-acknowledgements-v1.json", + "desktop-consumption-ack-v1.json", + "desktop-generation-v1.json", +) +MAX_FILE = 64 * 1024 * 1024 +MAX_GENERATION = 256 * 1024 * 1024 + + +class DriveTransportError(RuntimeError): + pass + + +def safe_relative(value: str) -> Path: + candidate = PurePosixPath(value) + if ( + not value + or value.startswith("/") + or "\\" in value + or "\x00" in value + or any(part in ("", ".", "..") for part in candidate.parts) + or len(candidate.parts) > 4 + ): + raise DriveTransportError("unsafe Drive object path") + return Path(*candidate.parts) + + +def digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + value.update(chunk) + return value.hexdigest() + + +class RcloneDrive: + def __init__(self, executable: str, remote: str): + if not remote or "\n" in remote or "\r" in remote or "Trainlog/AI" in remote: + raise DriveTransportError("invalid or AI Drive namespace") + self.executable = executable + self.remote = remote.rstrip("/") + + def target(self, relative: str) -> str: + safe_relative(relative) + return f"{self.remote}/{relative}" + + def run(self, *arguments: str, timeout: int = 60) -> subprocess.CompletedProcess[bytes]: + try: + return subprocess.run( + [self.executable, *arguments], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise DriveTransportError(f"Drive transport unavailable: {error}") from error + + def download(self, relative: str, destination: Path, optional: bool = False) -> bool: + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + with tempfile.TemporaryDirectory(prefix=".trainlog-drive-pull-", dir=destination.parent) as raw: + temporary = Path(raw) / "object" + result = self.run("copyto", self.target(relative), str(temporary)) + if result.returncode != 0: + message = result.stderr.decode("utf-8", "replace")[:1024] + if optional and any(token in message.lower() for token in ("not found", "doesn't exist", "directory not found")): + return False + raise DriveTransportError(message.strip() or "Drive download failed") + if not temporary.is_file() or temporary.stat().st_size > MAX_FILE: + raise DriveTransportError("Drive object exceeds local bound") + os.replace(temporary, destination) + return True + + def upload_verified(self, source: Path, relative: str) -> None: + if source.is_symlink() or not source.is_file() or source.stat().st_size > MAX_FILE: + raise DriveTransportError("invalid Drive upload source") + result = self.run("copyto", str(source), self.target(relative)) + if result.returncode != 0: + raise DriveTransportError(result.stderr.decode("utf-8", "replace")[:1024].strip()) + with tempfile.TemporaryDirectory(prefix="trainlog-drive-verify-") as raw: + verification = Path(raw) / "object" + self.download(relative, verification) + if verification.stat().st_size != source.stat().st_size or digest(verification) != digest(source): + raise DriveTransportError("Drive upload verification failed") + + +def generation_from_reference(root: Path, name: str) -> str | None: + path = root / name + if not path.is_file() or path.stat().st_size > 65536: + return None + value = json.loads(path.read_text(encoding="utf-8")) + relative = value.get("relative_path") + if not isinstance(relative, str): + raise DriveTransportError("invalid generation reference") + safe = safe_relative(relative) + expected_root = ( + "android-objects" if name == "android-generation-v1.json" else "desktop-objects" + ) + # CONTRACT: references point only at the immutable generation namespace + # owned by their producer. The generation identity is the third path + # component; the middle component is the literal ``generations``. + if ( + len(safe.parts) != 3 + or safe.parts[0] != expected_root + or safe.parts[1] != "generations" + or safe.parts[2] != value.get("generation_id") + ): + raise DriveTransportError("uncorrelated generation reference") + return relative + + +def pull_generation(drive: RcloneDrive, root: Path, relative: str) -> None: + generation_path = safe_relative(relative) + producer_root = generation_path.parts[0] + generation_relative = Path(*generation_path.parts[1:]) + destination = root / relative + # WHY: polling must remain bounded even when Drive retains many immutable + # generations. CONTRACT: os.replace below makes a generation visible + # locally only after every listed artifact has downloaded. INVARIANT: a + # committed local generation is immutable and is validated by the shared + # consumer before import; an incomplete pre-existing path is never hidden. + if destination.is_dir() and (destination / "manifest.json").is_file(): + return + if destination.exists(): + raise DriveTransportError("incomplete local Drive generation") + manifest_relative = f"{relative}/manifest.json" + with tempfile.TemporaryDirectory(prefix=".trainlog-drive-generation-", dir=root) as raw: + staging = Path(raw) / relative + drive.download(manifest_relative, staging / "manifest.json") + manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8")) + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list) or len(artifacts) > 32: + raise DriveTransportError("invalid Drive generation manifest") + total = (staging / "manifest.json").stat().st_size + for artifact in artifacts: + filename = artifact.get("filename") if isinstance(artifact, dict) else None + if not isinstance(filename, str): + raise DriveTransportError("artifact outside Drive generation") + artifact_path = safe_relative(filename) + if artifact_path.parent != generation_relative: + raise DriveTransportError("artifact outside Drive generation") + artifact_destination = staging / artifact_path.name + drive.download(f"{producer_root}/{filename}", artifact_destination) + total += artifact_destination.stat().st_size + if total > MAX_GENERATION: + raise DriveTransportError("Drive generation exceeds bound") + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + os.replace(staging, destination) + + +def pull(drive: RcloneDrive, root: Path) -> None: + root.mkdir(parents=True, exist_ok=True, mode=0o700) + for name in COORDINATION: + drive.download(name, root / name, optional=True) + for name in ("android-generation-v1.json", "desktop-generation-v1.json"): + relative = generation_from_reference(root, name) + if relative is not None: + pull_generation(drive, root, relative) + + +def ordered_files(root: Path) -> list[Path]: + files = [path for path in root.rglob("*") if path.is_file()] + if any(path.is_symlink() for path in files): + raise DriveTransportError("Drive outbox contains a symbolic link") + # CONTRACT: a generation is consumable only after its manifest exists; + # coordination references are published after the referenced directory. + return sorted( + files, + key=lambda path: ( + 2 if path.name.endswith("generation-v1.json") else + 1 if path.name == "manifest.json" else 0, + path.relative_to(root).as_posix(), + ), + ) + + +def push(drive: RcloneDrive, root: Path) -> None: + if not root.is_dir(): + raise DriveTransportError("Drive outbox is unavailable") + for source in ordered_files(root): + drive.upload_verified(source, source.relative_to(root).as_posix()) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("operation", choices=("pull", "push")) + parser.add_argument("remote") + parser.add_argument("root", type=Path) + parser.add_argument("--rclone", default=os.environ.get("TRAINLOG_RCLONE", "rclone")) + args = parser.parse_args() + drive = RcloneDrive(args.rclone, args.remote) + if args.operation == "pull": + pull(drive, args.root) + else: + push(drive, args.root) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (DriveTransportError, json.JSONDecodeError, OSError) as error: + print(f"Drive transport failed: {error}", file=os.sys.stderr) + raise SystemExit(1) from None diff --git a/tools/sync_orchestrator.py b/tools/sync_orchestrator.py index cd1fd23..2a0bb72 100644 --- a/tools/sync_orchestrator.py +++ b/tools/sync_orchestrator.py @@ -20,6 +20,7 @@ import sys import tempfile import time import ctypes +import shutil from datetime import datetime, timezone from pathlib import Path @@ -182,6 +183,57 @@ def validate_result(value: dict, run_id: str) -> dict: return value +def drive_adapter_path() -> Path: + return Path(__file__).with_name("sync_drive_transport.py") + + +def mirror_current_exchange(transport_root: Path, remote: str, timeout: int) -> None: + """Mirror only current protocol objects; never traverse application data.""" + coordination = ( + "android-peer-v1.json", + "android-generation-v1.json", + "android-consumption-ack-v1.json", + "android-generation-error-v1.json", + "request-v1.json", + "desktop-archive-acknowledgements-v1.json", + "desktop-consumption-ack-v1.json", + "desktop-generation-v1.json", + ) + with tempfile.TemporaryDirectory(prefix="trainlog-drive-mirror-", dir=transport_root.parent) as raw: + outbox = Path(raw) + for name in coordination: + source = transport_root / name + if source.is_file() and not source.is_symlink(): + shutil.copy2(source, outbox / name) + for name in ("android-generation-v1.json", "desktop-generation-v1.json"): + reference = outbox / name + if not reference.is_file(): + continue + value = load_json(reference, MAX_REPORT) + relative = value.get("relative_path") + if not isinstance(relative, str): + raise RuntimeError("invalid generation reference during Drive mirror") + source = (transport_root / relative).resolve() + if not source.is_relative_to(transport_root.resolve()) or not source.is_dir(): + raise RuntimeError("generation reference escapes transport root") + destination = outbox / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, destination, copy_function=shutil.copy2) + result = subprocess.run( + [sys.executable, str(drive_adapter_path()), "push", remote, str(outbox)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + result.stderr.decode("utf-8", "replace")[:MAX_DIAGNOSTIC].strip() + or "Drive mirror failed" + ) + + def read_worker( child: subprocess.Popen[bytes], state_path: Path, state: dict, run_id: str, timeout: int ) -> tuple[dict, bytes]: @@ -276,7 +328,7 @@ def run(args: argparse.Namespace) -> int: config = load_json(config_path, MAX_CONFIG) if state.get("run_id") != args.run_id or state.get("request_id") != args.request_id: raise RuntimeError("admission identity mismatch") - if set(config) != { + base_keys = { "format", "version", "enabled", @@ -285,16 +337,24 @@ def run(args: argparse.Namespace) -> int: "transport_root", "owned_root", "timeout_seconds", - }: + } + drive_keys = {"drive_enabled", "drive_remote"} + if set(config) not in (base_keys, base_keys | drive_keys): raise RuntimeError("invalid trusted sync configuration") if ( config["format"] != "trainlog-sync-orchestrator-config" - or config["version"] != 1 + or config["version"] not in (1, 2) or config["enabled"] is not True ): raise RuntimeError("full-generation synchronization is disabled") - if config["mode"] not in ("directory", "mtp"): + if config["mode"] not in ("directory", "mtp", "drive", "auto"): raise RuntimeError("invalid transport mode") + drive_enabled = config.get("drive_enabled", False) + drive_remote = config.get("drive_remote", "") + if type(drive_enabled) is not bool or (drive_enabled and (not isinstance(drive_remote, str) or not drive_remote)): + raise RuntimeError("invalid Drive configuration") + if config["mode"] in ("drive", "auto") and not drive_enabled: + raise RuntimeError("Drive transport is not configured") for key in ("transport_root", "owned_root"): if not isinstance(config[key], str) or not Path(config[key]).is_absolute(): raise RuntimeError("trusted paths must be absolute") @@ -333,6 +393,28 @@ def run(args: argparse.Namespace) -> int: "TRAINLOG_SYNC_DATABASE": args.database, } ) + selected_mode = config["mode"] + adapter = Path( + os.environ.get( + "TRAINLOG_SYNC_MTP_ADAPTER", + str(Path(__file__).resolve().parents[1] / "build/tui/trainlog-generation-mtp-adapter"), + ) + ) + if selected_mode == "auto": + # WHY: cable presence is not peer availability. A successful pull + # is the bounded proof that the configured Trainlog peer is usable. + try: + probe = subprocess.run( + [str(adapter), "pull", config["expected_peer_id"], config["transport_root"]], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=min(timeout, 30), + check=False, + ) if adapter.is_absolute() and os.access(adapter, os.X_OK) else None + except (OSError, subprocess.TimeoutExpired): + probe = None + selected_mode = "mtp" if probe is not None and probe.returncode == 0 else "drive" command = [ sys.executable, str(Path(__file__).with_name("sync_peer_worker.py")), @@ -349,19 +431,16 @@ def run(args: argparse.Namespace) -> int: "--timeout", str(timeout), "--mode", - config["mode"], + selected_mode, ] - if config["mode"] == "mtp": - adapter = os.environ.get( - "TRAINLOG_SYNC_MTP_ADAPTER", - str( - Path(__file__).resolve().parents[1] - / "build/tui/trainlog-generation-mtp-adapter" - ), - ) - if not Path(adapter).is_absolute() or not os.access(adapter, os.X_OK): + if selected_mode == "mtp": + if not adapter.is_absolute() or not os.access(adapter, os.X_OK): raise RuntimeError("fixed MTP adapter executable is unavailable") - command.extend(["--mtp-adapter", adapter]) + command.extend(["--mtp-adapter", str(adapter)]) + elif selected_mode == "drive": + command.extend( + ["--drive-adapter", str(drive_adapter_path()), "--drive-remote", drive_remote] + ) child = subprocess.Popen( command, stdin=subprocess.DEVNULL, @@ -384,6 +463,20 @@ def run(args: argparse.Namespace) -> int: raise finally: _owned_process_group = None + drive_state = "disabled" + drive_diagnostic = "" + if drive_enabled: + if selected_mode == "drive": + drive_state = "success" + else: + try: + mirror_current_exchange(Path(config["transport_root"]), drive_remote, timeout) + drive_state = "mirrored" + except Exception as error: + # CONTRACT: committed USB synchronization is never rolled + # back because its additive safety mirror is unavailable. + drive_state = "failed" + drive_diagnostic = str(error)[:MAX_DIAGNOSTIC] update( state_path, state, @@ -400,6 +493,10 @@ def run(args: argparse.Namespace) -> int: manifest_sha256=result["manifest_sha256"], ai_midpoint=result["ai_midpoint"], ai_post_sync=result["ai_post_sync"], + usb_state="success" if selected_mode == "mtp" else "unavailable", + drive_state=drive_state, + drive_diagnostic=drive_diagnostic, + transport=selected_mode, finished_at=datetime.now(timezone.utc).isoformat(), ) return 0 diff --git a/tools/sync_peer_worker.py b/tools/sync_peer_worker.py index e041caf..8bad60c 100644 --- a/tools/sync_peer_worker.py +++ b/tools/sync_peer_worker.py @@ -192,6 +192,62 @@ def push_desktop_generation( ) +def run_drive_adapter( + adapter: Path, + operation: str, + remote: str, + root: Path, + deadline: float, +) -> None: + """Run the fixed Drive byte adapter without exposing configuration to HTTP.""" + remaining = max(1, int(deadline - time.monotonic())) + try: + result = subprocess.run( + [sys.executable, str(adapter), operation, remote, str(root)], + stdin=subprocess.DEVNULL, + capture_output=True, + # CONTRACT: the trusted configuration already bounds the complete + # conversation to at most 900 seconds. A full verified Drive pull + # may legitimately need more than 60 seconds because every object + # is downloaded and hashed before the generation becomes visible. + timeout=remaining, + check=False, + ) + except subprocess.TimeoutExpired as error: + raise RuntimeError("transport_timeout: Drive adapter timed out") from error + if result.returncode != 0: + raise RuntimeError( + result.stderr.decode("utf-8", "replace")[:1024].strip() + or "Drive adapter failed" + ) + + +def push_drive( + adapter: Path, + remote: str, + root: Path, + relative_paths: list[str], + deadline: float, +) -> None: + """Publish a phase-owned outbox through the shared Drive byte adapter.""" + with tempfile.TemporaryDirectory(prefix="trainlog-drive-outbox-", dir=root.parent) as directory: + outbox = Path(directory) + os.chmod(outbox, 0o700) + for relative in relative_paths: + source = root / relative + destination = outbox / relative + if source.is_symlink() or not source.exists() or not source.resolve().is_relative_to(root.resolve()): + raise RuntimeError(f"invalid Drive outbox source: {relative}") + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if source.is_dir(): + if any(path.is_symlink() for path in source.rglob("*")): + raise RuntimeError(f"Drive outbox contains a symbolic link: {relative}") + shutil.copytree(source, destination, copy_function=shutil.copy2) + else: + shutil.copy2(source, destination) + run_drive_adapter(adapter, "push", remote, outbox, deadline) + + def wait_file(path: Path, deadline: float, pump=None) -> None: while not path.is_file(): if time.monotonic() >= deadline: @@ -294,8 +350,10 @@ def main() -> int: parser.add_argument("--run-id", required=True) parser.add_argument("--expected-peer", required=True) parser.add_argument("--timeout", required=True, type=int) - parser.add_argument("--mode", choices=("directory", "mtp"), default="directory") + parser.add_argument("--mode", choices=("directory", "mtp", "drive"), default="directory") parser.add_argument("--mtp-adapter", type=Path) + parser.add_argument("--drive-adapter", type=Path) + parser.add_argument("--drive-remote") args = parser.parse_args() deadline = time.monotonic() + args.timeout pump = None @@ -316,6 +374,27 @@ def main() -> int: args.transport_root, deadline, ) + elif args.mode == "drive": + if args.drive_adapter is None or not args.drive_remote: + raise RuntimeError("Drive adapter and remote are required") + last_drive_pull = 0.0 + + def pump_drive(force: bool = False) -> None: + nonlocal last_drive_pull + now = time.monotonic() + if not force and now - last_drive_pull < 5.0: + return + run_drive_adapter( + args.drive_adapter, + "pull", + args.drive_remote, + args.transport_root, + deadline, + ) + last_drive_pull = time.monotonic() + + pump = pump_drive + pump_drive(True) peer = load_peer(args.transport_root, args.expected_peer) with closing(generation.connect_database(args.database)) as db: generation.require_schema(db) @@ -351,6 +430,14 @@ def main() -> int: ["request-v1.json", "desktop-archive-acknowledgements-v1.json"], deadline, ) + elif args.mode == "drive": + push_drive( + args.drive_adapter, + args.drive_remote, + args.transport_root, + ["request-v1.json", "desktop-archive-acknowledgements-v1.json"], + deadline, + ) emit( args.run_id, "waiting_android_publication", @@ -378,6 +465,14 @@ def main() -> int: ["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"], deadline, ) + elif args.mode == "drive": + push_drive( + args.drive_adapter, + args.drive_remote, + args.transport_root, + ["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"], + deadline, + ) if ack["result"] != "consumed": raise RuntimeError("desktop rejected Android generation") emit( @@ -419,6 +514,26 @@ def main() -> int: ref["relative_path"], deadline, ) + elif args.mode == "drive": + push_drive( + args.drive_adapter, + args.drive_remote, + args.transport_root, + [ref["relative_path"]], + deadline, + ) + push_drive( + args.drive_adapter, + args.drive_remote, + args.transport_root, + [ + "request-v1.json", + "desktop-archive-acknowledgements-v1.json", + "desktop-consumption-ack-v1.json", + "desktop-generation-v1.json", + ], + deadline, + ) emit( args.run_id, "published", diff --git a/tui/meson.build b/tui/meson.build index 8875bd8..6f21839 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -856,6 +856,11 @@ test( find_program('python3'), args: [meson.project_source_root() / 'tests/test_sync_orchestrator.py'], ) +test( + 'sync_drive_transport', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_sync_drive_transport.py'], +) test( 'sync_deployment_tools', find_program('python3'), diff --git a/tui/src/web_sessions.c b/tui/src/web_sessions.c index 7a93697..c17e894 100644 --- a/tui/src/web_sessions.c +++ b/tui/src/web_sessions.c @@ -19,11 +19,15 @@ typedef struct ListDefinition { static const ListDefinition LISTS[] = { {"preparation", - "SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at " + "SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at," + "p.editing_state,p.delivery_state " "FROM session_preparations p JOIN session_preparation_revisions r ON " "r.revision_id=p.current_revision_id " "LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id " - "WHERE p.withdrawn_at IS NULL AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR " + "WHERE p.withdrawn_at IS NULL AND NOT EXISTS(SELECT 1 FROM " + "session_preparation_deliveries d JOIN sessions completed ON " + "completed.session_id=d.execution_session_id WHERE d.preparation_id=p.preparation_id) " + "AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR " "EXISTS(SELECT 1 FROM " "session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE " "pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) " @@ -31,7 +35,7 @@ static const ListDefinition LISTS[] = { "COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?2 OFFSET ?3"}, {"proposal", "SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,CASE WHEN d.published_at IS NULL THEN " - "'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at " + "'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at,NULL,NULL " "FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON e.draft_row_id=d.id " "WHERE d.withdrawn_at IS NULL AND (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR " "EXISTS(SELECT 1 FROM " @@ -42,7 +46,7 @@ static const ListDefinition LISTS[] = { {"draft", "SELECT " "d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d." - "payload_json,'$.exercises'),0),COALESCE(d.started_at,'') " + "payload_json,'$.exercises'),0),COALESCE(d.started_at,''),NULL,NULL " "FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE " "f.session_id=d.session_id) " "AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND " @@ -52,7 +56,7 @@ static const ListDefinition LISTS[] = { {"history", "SELECT " "s.session_id,s.session_type,substr(s.started_at,1,10),'completed',COUNT(se.entry_id),s." - "started_at " + "started_at,NULL,NULL " "FROM sessions s LEFT JOIN session_exercises se ON se.session_row_id=s.id " "WHERE (?1='' OR s.session_type LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM " "session_exercises he JOIN exercises x ON x.id=he.exercise_row_id WHERE " @@ -158,6 +162,8 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database, !add_nullable_text(document, item, "state", statement, 3) || !yyjson_mut_obj_add_sint(document, item, "occurrence_count", occurrence_count) || !add_nullable_text(document, item, "sort_timestamp", statement, 5) || + !add_nullable_text(document, item, "editing_state", statement, 6) || + !add_nullable_text(document, item, "delivery_state", statement, 7) || !yyjson_mut_arr_add_val(items, item)) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto finish; @@ -208,6 +214,7 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database, "p.preparation_id,r.title,r.planned_for,r.session_type,r.notes," "COALESCE(p.source_payload_sha256,''),p.current_revision_id," "CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END," + "p.editing_state,p.delivery_state," "p.source_proposal_id,source.title,p.withdrawn_at,w.withdrawal_id," "w.acknowledged_at FROM session_preparations p JOIN " "session_preparation_revisions r ON r.revision_id=p.current_revision_id " @@ -257,6 +264,11 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database, } } if (!proposal) { + if (!add_nullable_text(document, root, "editing_state", statement, 8) || + !add_nullable_text(document, root, "delivery_state", statement, 9)) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } static const char *const provenance_keys[] = {"source_proposal_id", "source_proposal_title", "withdrawn_at", @@ -264,7 +276,7 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database, "withdrawal_acknowledged_at"}; for (column = 0; column < 5; ++column) { if (!add_nullable_text( - document, root, provenance_keys[column], statement, column + 8)) { + document, root, provenance_keys[column], statement, column + 10)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -1274,6 +1286,9 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database, database->connection, "SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND " "p.current_revision_id=?2 AND p.withdrawn_at IS NULL AND p.editing_state='ready' " + "AND p.delivery_state='local' AND NOT EXISTS(SELECT 1 FROM " + "session_preparation_deliveries d WHERE d.preparation_id=p.preparation_id AND " + "d.state IN('pending','acknowledged','remote_unknown')) " "AND EXISTS(SELECT 1 FROM " "session_preparation_entries e WHERE e.revision_id=p.current_revision_id)", -1, diff --git a/tui/tests/test_web_sessions.c b/tui/tests/test_web_sessions.c index fdc59e8..f092185 100644 --- a/tui/tests/test_web_sessions.c +++ b/tui/tests/test_web_sessions.c @@ -227,8 +227,7 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) { "\"target_sets\":1,\"target_reps\":1,\"target_duration_seconds\":null," "\"target_weight_kg\":null,\"notes\":null}]}"; TrainlogDatabase *database = NULL; - TrainlogWebSessionsPageQuery query = { - TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 64U, ""}; + TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 64U, ""}; char *created = NULL; char *delivery = NULL; char *withdrawal = NULL; @@ -245,6 +244,7 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) { char revision_id[128]; char local_preparation_id[128]; char local_revision_id[128]; + char execution_session_id[128]; CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_insert_exercise_profiled(database, @@ -289,6 +289,14 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) { "request-withdraw-deliver", &delivery, &delivery_size) == TRAINLOG_STATUS_OK); + CHECK(trainlog_web_sessions_deliver_json(database, + preparation_id, + revision_id, + "request-withdraw-deliver-duplicate", + &replayed, + &replayed_size) == TRAINLOG_STATUS_CONFLICT); + CHECK(replayed == NULL && replayed_size == 0U); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 1); CHECK(trainlog_web_sessions_withdraw_json(database, preparation_id, revision_id, @@ -317,8 +325,7 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) { CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 1); CHECK(scalar(database, "SELECT COUNT(*) FROM ai_session_drafts") == 1); CHECK(scalar(database, - "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'") == - 1); + "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'") == 1); CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) == TRAINLOG_STATUS_OK); CHECK(strstr(page, preparation_id) == NULL); @@ -353,10 +360,8 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) { strlen(LOCAL_READY_BODY), &replayed, &replayed_size) == TRAINLOG_STATUS_OK); - CHECK(json_string(replayed, - "preparation_id", - local_preparation_id, - sizeof(local_preparation_id))); + CHECK(json_string( + replayed, "preparation_id", local_preparation_id, sizeof(local_preparation_id))); CHECK(json_string(replayed, "revision_id", local_revision_id, sizeof(local_revision_id))); free(replayed); replayed = NULL; @@ -372,6 +377,57 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) { CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 1); CHECK(scalar(database, "SELECT COUNT(*) FROM ai_session_drafts") == 1); + /* A completed occurrence hides the preparation by its reserved stable + * execution identity while retaining every preparation/delivery row. */ + free(replayed); + replayed = NULL; + replayed_size = 0U; + CHECK(trainlog_web_sessions_save_json(database, + NULL, + "", + "request-completed-create", + LOCAL_READY_BODY, + strlen(LOCAL_READY_BODY), + &replayed, + &replayed_size) == TRAINLOG_STATUS_OK); + CHECK(json_string( + replayed, "preparation_id", local_preparation_id, sizeof(local_preparation_id))); + CHECK(json_string(replayed, "revision_id", local_revision_id, sizeof(local_revision_id))); + free(replayed); + replayed = NULL; + replayed_size = 0U; + CHECK(trainlog_web_sessions_deliver_json(database, + local_preparation_id, + local_revision_id, + "request-completed-deliver", + &replayed, + &replayed_size) == TRAINLOG_STATUS_OK); + CHECK(json_string( + replayed, "execution_session_id", execution_session_id, sizeof(execution_session_id))); + free(page); + page = NULL; + page_size = 0U; + CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) == + TRAINLOG_STATUS_OK); + CHECK(strstr(page, local_preparation_id) != NULL); + { + char *sql = + sqlite3_mprintf("INSERT INTO sessions(session_id,started_at,ended_at,session_type) " + "VALUES(%Q,'2026-09-20T10:00:00Z','2026-09-20T11:00:00Z','training')", + execution_session_id); + CHECK(sql != NULL); + CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK); + sqlite3_free(sql); + } + free(page); + page = NULL; + page_size = 0U; + CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) == + TRAINLOG_STATUS_OK); + CHECK(strstr(page, local_preparation_id) == NULL); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 2); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 3); + free(created); free(delivery); free(withdrawal); diff --git a/web/src/api/sessions.ts b/web/src/api/sessions.ts index 7b291c4..5a861f0 100644 --- a/web/src/api/sessions.ts +++ b/web/src/api/sessions.ts @@ -1,4 +1,4 @@ -import { mutationCsrfToken } from './sync' +import { mutationCsrfToken, newRequestId, startSync } from './sync' export type SessionCollection = 'preparations' | 'proposals' | 'drafts' | 'history' @@ -9,6 +9,8 @@ export interface SessionListItem { state: string occurrence_count: number sort_timestamp: string + editing_state?: 'draft' | 'ready' | null + delivery_state?: 'local' | 'pending' | 'acknowledged' | 'remote_unknown' | 'cancelled' | null } export interface SessionPage { @@ -52,6 +54,8 @@ export interface SessionDetail { withdrawal_id?: string | null withdrawal_acknowledged_at?: string | null state: string + editing_state?: 'draft' | 'ready' + delivery_state?: 'local' | 'pending' | 'acknowledged' | 'remote_unknown' | 'cancelled' occurrences: PreparationOccurrence[] } @@ -83,7 +87,9 @@ function validListItem(value: unknown): value is SessionListItem { return typeof value.identity === 'string' && typeof value.title === 'string' && (value.date === null || typeof value.date === 'string') && typeof value.state === 'string' && Number.isSafeInteger(value.occurrence_count) && Number(value.occurrence_count) >= 0 && - typeof value.sort_timestamp === 'string' + typeof value.sort_timestamp === 'string' && + (value.editing_state === undefined || value.editing_state === null || value.editing_state === 'draft' || value.editing_state === 'ready') && + (value.delivery_state === undefined || value.delivery_state === null || ['local', 'pending', 'acknowledged', 'remote_unknown', 'cancelled'].includes(String(value.delivery_state))) } export async function fetchSessionPage( @@ -236,6 +242,11 @@ export async function prepareForAndroid(identity: string, revision: string): Pro const reason = object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}` throw new Error(reason) } + /* WHY: a durable delivery must become reachable before the desktop can be + * powered down. CONTRACT: admission reuses the one sync orchestrator and + * its global lock; failure never removes or duplicates the pending delivery. + * INVARIANT: the transport is not part of delivery identity. */ + await startSync(newRequestId()) } export interface PreparationWithdrawalResult { diff --git a/web/src/api/sync.ts b/web/src/api/sync.ts index e8a0f12..9b61c9f 100644 --- a/web/src/api/sync.ts +++ b/web/src/api/sync.ts @@ -37,6 +37,10 @@ export interface SyncStatus { drafts?: SyncDraftSummary[] started_at?: string | null finished_at?: string | null + transport?: 'directory' | 'mtp' | 'drive' + usb_state?: 'unavailable' | 'skipped' | 'running' | 'success' | 'failed' + drive_state?: 'disabled' | 'unavailable' | 'running' | 'mirrored' | 'success' | 'failed' + drive_diagnostic?: string } let csrfToken = '' @@ -85,6 +89,10 @@ function parse(value: unknown): SyncStatus { !optionalString(status.error_code) || !optionalString(status.started_at) || !optionalString(status.finished_at) || + !optionalString(status.drive_diagnostic) || + (status.transport !== undefined && !['directory', 'mtp', 'drive'].includes(status.transport)) || + (status.usb_state !== undefined && !['unavailable', 'skipped', 'running', 'success', 'failed'].includes(status.usb_state)) || + (status.drive_state !== undefined && !['disabled', 'unavailable', 'running', 'mirrored', 'success', 'failed'].includes(status.drive_state)) || (status.progress_revision !== undefined && (!Number.isSafeInteger(status.progress_revision) || status.progress_revision < 0))) { throw new Error('statut sync invalide') diff --git a/web/src/components/SyncControl.tsx b/web/src/components/SyncControl.tsx index d3d6859..e96de29 100644 --- a/web/src/components/SyncControl.tsx +++ b/web/src/components/SyncControl.tsx @@ -28,7 +28,7 @@ const committedPhases = new Set([ function statusAction(status: SyncStatus | null): string { if (status?.phase === 'waiting_android_publication') { - return 'Ouvrez l’écran de synchronisation Android et laissez le téléphone déverrouillé.' + return 'Demande envoyée ; Trainlog Android doit être joignable par le service de fond ou Drive.' } if (status?.phase === 'failed' && status.error_code === 'device_unavailable') { return 'Connectez et déverrouillez le téléphone, puis réessayez.' @@ -135,6 +135,19 @@ export function SyncControl({ onCommitted }: { onCommitted: () => void }) { {action &&

{action}

} {status?.error_code &&

Code : {status.error_code}

} {status?.diagnostic &&

{status.diagnostic}

} + {status?.usb_state &&

Android USB : {{ + unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours', + success: '✓ synchronisé', failed: '⚠ non traité', + }[status.usb_state]}

} + {status?.drive_state &&

Google Drive : {{ + disabled: 'désactivé', unavailable: 'non disponible', running: 'en cours', + mirrored: '✓ miroir à jour', success: '✓ synchronisé', failed: '⚠ miroir non mis à jour', + }[status.drive_state]}

} + {status?.drive_diagnostic &&

{status.drive_diagnostic}

} + {status?.phase === 'waiting_android_publication' &&

Demande : envoyée · Android : génération attendue

} + {status?.inbound_generation_id &&

PC : import terminé · {status.inbound_generation_id}

} + {status?.outbound_generation_id &&

PC : génération publiée · {status.outbound_generation_id}

} + {status?.phase === 'completed' &&

Android : import terminé · ACK : reçu · Résultat : terminé

} {status?.sessions_reconciled !== null && status?.sessions_reconciled !== undefined && (

Séances rapprochées : {status.sessions_reconciled}

)} diff --git a/web/src/routes/SessionsPage.tsx b/web/src/routes/SessionsPage.tsx index 0b61c45..dd4b3c7 100644 --- a/web/src/routes/SessionsPage.tsx +++ b/web/src/routes/SessionsPage.tsx @@ -498,6 +498,7 @@ export function SessionsPage() { const [deleting, setDeleting] = useState(false) const [deleteError, setDeleteError] = useState('') const [statusMessage, setStatusMessage] = useState('') + const [sendingPreparation, setSendingPreparation] = useState(null) const deleteReturnFocus = useRef(null) const [detailPath, setDetailPath] = useState(() => window.location.pathname) const loadRevision = useRef(0) @@ -583,6 +584,36 @@ export function SessionsPage() { setDeleting(false) } } + const sendPreparation = async (item: SessionListEntry) => { + if (sendingPreparation !== null || item.collection !== 'preparations' || + item.delivery_state !== 'local') return + setSendingPreparation(item.identity) + setStatusMessage('') + try { + const detail = await fetchSessionDetail('preparation', item.identity) + let revision = detail.revision_id + if (detail.editing_state === 'draft') { + const saved = await savePreparation({ + title: detail.title, + session_type: detail.session_type, + planned_for: detail.planned_for, + notes: detail.notes, + editing_state: 'ready', + occurrences: detail.occurrences.map(commandOccurrence), + }, detail.identity, detail.revision_id) + revision = saved.revision_id + } + await prepareForAndroid(detail.identity, revision) + setStatusMessage('Préparation à synchroniser vers Android.') + } catch (reason) { + setStatusMessage(reason instanceof Error + ? `Envoi Android impossible : ${reason.message}` + : 'Envoi Android impossible.') + } finally { + setSendingPreparation(null) + load() + } + } if (editor) return
setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} />
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) { @@ -636,6 +667,15 @@ export function SessionsPage() { {!pending && !failed && visibleItems.length === 0 &&

Aucun résultat pour cette vue.

}
{visibleItems.map((item) => { const kind = deletionKind(item.collection) + const deliveryLabel = item.collection !== 'preparations' ? null + : item.delivery_state === 'pending' ? 'À synchroniser' + : item.delivery_state === 'acknowledged' ? 'Reçue sur Android' + : item.delivery_state === 'remote_unknown' ? 'État Android inconnu' + : item.delivery_state === 'cancelled' ? 'Livraison annulée' + : 'Locale' + const sendLabel = item.editing_state === 'draft' + ? 'Marquer prête et envoyer vers Android' + : 'Envoyer vers Android' return
+ {item.collection === 'preparations' && item.delivery_state === 'local' && } + {item.collection === 'preparations' && item.delivery_state !== 'local' && {deliveryLabel}}