feat(sync): add USB and Drive Android synchronization
This commit is contained in:
parent
3ad50be247
commit
799054ee2c
34 changed files with 1975 additions and 77 deletions
20
CHANGELOG.md
20
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
<!-- Personal sideloaded app: permanent bidirectional MTP exchange uses one
|
||||
user-visible directory, Documents/Trainlog, without a SAF provider. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
|
|
@ -26,5 +29,10 @@
|
|||
android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".data.SyncBackgroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -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<DriveSyncWorker>(15, TimeUnit.MINUTES)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder<DriveSyncWorker>().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<String> {
|
||||
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<String>): 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<File> { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>, 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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -95,6 +95,21 @@
|
|||
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
|
||||
<string name="sync_running">Synchronization in progress…</string>
|
||||
<string name="sync_now">Synchronize now</string>
|
||||
<string name="background_sync_title">Background synchronization</string>
|
||||
<string name="background_sync_description">Allows Trainlog to receive PC synchronizations without opening the app.</string>
|
||||
<string name="background_sync_enable">Enable background synchronization</string>
|
||||
<string name="background_sync_disable">Disable background synchronization</string>
|
||||
<string name="background_sync_channel">Trainlog synchronization</string>
|
||||
<string name="background_sync_notification_title">Trainlog is ready to synchronize</string>
|
||||
<string name="background_sync_notification_text">The service is waiting for a PC request.</string>
|
||||
<string name="background_sync_notification_required">Allow notifications to enable background synchronization.</string>
|
||||
<string name="drive_sync_title">Private Google Drive</string>
|
||||
<string name="drive_sync_description">Select the private Trainlog/Sync/v1 folder. Trainlog only accesses that folder and never copies SQLite into it.</string>
|
||||
<string name="drive_sync_connect">Connect Google Drive</string>
|
||||
<string name="drive_sync_disconnect">Disconnect Google Drive</string>
|
||||
<string name="drive_sync_latency">Background checks are bounded by Android and can take at least 15 minutes.</string>
|
||||
<string name="drive_sync_connected">Drive folder connected. A check has been scheduled.</string>
|
||||
<string name="drive_sync_connection_failed">Unable to retain access to the Drive folder.</string>
|
||||
<string name="sync_flow_description">Android → PC then PC → Android, in one operation.</string>
|
||||
<string name="sync_authorize_first">File access is required. Authorize Documents/Trainlog first.</string>
|
||||
<string name="sync_request_sent">Request sent · waiting for the PC…</string>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,21 @@
|
|||
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
|
||||
<string name="sync_running">Synchronisation en cours…</string>
|
||||
<string name="sync_now">Synchroniser maintenant</string>
|
||||
<string name="background_sync_title">Synchronisation en arrière-plan</string>
|
||||
<string name="background_sync_description">Permet à Trainlog de recevoir les synchronisations du PC sans ouvrir l’application.</string>
|
||||
<string name="background_sync_enable">Activer la synchronisation en arrière-plan</string>
|
||||
<string name="background_sync_disable">Désactiver la synchronisation en arrière-plan</string>
|
||||
<string name="background_sync_channel">Synchronisation Trainlog</string>
|
||||
<string name="background_sync_notification_title">Trainlog est prêt à synchroniser</string>
|
||||
<string name="background_sync_notification_text">Le service attend une demande du PC.</string>
|
||||
<string name="background_sync_notification_required">Autorisez les notifications pour activer la synchronisation en arrière-plan.</string>
|
||||
<string name="drive_sync_title">Google Drive privé</string>
|
||||
<string name="drive_sync_description">Sélectionnez le dossier privé Trainlog/Sync/v1. Trainlog n’accède qu’à ce dossier et n’y copie jamais SQLite.</string>
|
||||
<string name="drive_sync_connect">Connecter Google Drive</string>
|
||||
<string name="drive_sync_disconnect">Déconnecter Google Drive</string>
|
||||
<string name="drive_sync_latency">La vérification en arrière-plan est bornée par Android et peut prendre au moins 15 minutes.</string>
|
||||
<string name="drive_sync_connected">Dossier Drive connecté. Une vérification est planifiée.</string>
|
||||
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Drive.</string>
|
||||
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
|
||||
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
|
||||
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
|
||||
|
|
|
|||
|
|
@ -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<ForegroundGenerationResult> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
53
docs/reviews/sync_usb_drive_android_v1_manual.md
Normal file
53
docs/reviews/sync_usb_drive_android_v1_manual.md
Normal file
|
|
@ -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/<run-id>/
|
||||
```
|
||||
|
||||
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 `<run-id>` 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/<run-id>/` 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
145
tests/test_sync_drive_transport.py
Normal file
145
tests/test_sync_drive_transport.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
35
tests/test_sync_peer_worker.py
Normal file
35
tests/test_sync_peer_worker.py
Normal file
|
|
@ -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()
|
||||
228
tools/sync_drive_transport.py
Normal file
228
tools/sync_drive_transport.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 && <p>{action}</p>}
|
||||
{status?.error_code && <p>Code : {status.error_code}</p>}
|
||||
{status?.diagnostic && <p>{status.diagnostic}</p>}
|
||||
{status?.usb_state && <p>Android USB : {{
|
||||
unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours',
|
||||
success: '✓ synchronisé', failed: '⚠ non traité',
|
||||
}[status.usb_state]}</p>}
|
||||
{status?.drive_state && <p>Google Drive : {{
|
||||
disabled: 'désactivé', unavailable: 'non disponible', running: 'en cours',
|
||||
mirrored: '✓ miroir à jour', success: '✓ synchronisé', failed: '⚠ miroir non mis à jour',
|
||||
}[status.drive_state]}</p>}
|
||||
{status?.drive_diagnostic && <p>{status.drive_diagnostic}</p>}
|
||||
{status?.phase === 'waiting_android_publication' && <p>Demande : envoyée · Android : génération attendue</p>}
|
||||
{status?.inbound_generation_id && <p>PC : import terminé · <code>{status.inbound_generation_id}</code></p>}
|
||||
{status?.outbound_generation_id && <p>PC : génération publiée · <code>{status.outbound_generation_id}</code></p>}
|
||||
{status?.phase === 'completed' && <p>Android : import terminé · ACK : reçu · Résultat : terminé</p>}
|
||||
{status?.sessions_reconciled !== null && status?.sessions_reconciled !== undefined && (
|
||||
<p>Séances rapprochées : {status.sessions_reconciled}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -498,6 +498,7 @@ export function SessionsPage() {
|
|||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
const [sendingPreparation, setSendingPreparation] = useState<string | null>(null)
|
||||
const deleteReturnFocus = useRef<HTMLElement | null>(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 <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
|
||||
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
|
||||
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
|
||||
|
|
@ -636,6 +667,15 @@ export function SessionsPage() {
|
|||
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
|
||||
<div className="session-list">{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 <div className="session-row" key={`${item.collection}-${item.identity}`}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -649,8 +689,21 @@ export function SessionsPage() {
|
|||
: 'Date inconnue'
|
||||
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
|
||||
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
|
||||
<span>{sessionStateLabel(item.collection, item.state)}</span>
|
||||
<span>{item.collection === 'preparations'
|
||||
? `${sessionStateLabel(item.collection, item.editing_state ?? item.state)} · ${deliveryLabel}`
|
||||
: sessionStateLabel(item.collection, item.state)}</span>
|
||||
</button>
|
||||
{item.collection === 'preparations' && item.delivery_state === 'local' && <button
|
||||
type="button"
|
||||
className="android-send-action"
|
||||
aria-label={sendLabel}
|
||||
title={sendLabel}
|
||||
disabled={sendingPreparation !== null}
|
||||
onClick={() => void sendPreparation(item)}
|
||||
><span aria-hidden="true">⇥</span></button>}
|
||||
{item.collection === 'preparations' && item.delivery_state !== 'local' && <span
|
||||
className={`android-delivery-state state-${item.delivery_state}`}
|
||||
>{deliveryLabel}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="trash-action"
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
border: 1px solid var(--surface-1);
|
||||
border-radius: .75rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 3rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto 3rem;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -166,6 +166,35 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
}
|
||||
.trash-action:hover,
|
||||
.trash-action:focus-visible { background: rgb(243 139 168 / 12%); }
|
||||
.android-send-action {
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--surface-1);
|
||||
color: var(--blue);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: 1.25rem;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
.android-send-action:hover, .android-send-action:focus-visible { background: rgb(137 180 250 / 12%); }
|
||||
.android-send-action:disabled { cursor: wait; opacity: .55; }
|
||||
.android-delivery-state {
|
||||
align-items: center;
|
||||
border-left: 1px solid var(--surface-1);
|
||||
color: var(--subtext-0);
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
max-width: 9rem;
|
||||
padding: .55rem .75rem;
|
||||
text-align: center;
|
||||
}
|
||||
.android-delivery-state.state-remote_unknown { color: var(--yellow); }
|
||||
.android-delivery-state.state-acknowledged { color: var(--green); }
|
||||
.program-list, .program-session-list { display: grid; gap: .75rem; }
|
||||
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); }
|
||||
.program-row .session-row-main { grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr)); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue