style(sync): expand Python and Kotlin workflows

This commit is contained in:
fy59 2026-09-17 14:45:00 +02:00
parent 6cd6219ac5
commit 7b769076c1
15 changed files with 2802 additions and 853 deletions

View file

@ -15,10 +15,15 @@ import org.json.JSONObject
internal sealed interface AndroidBackupResult { internal sealed interface AndroidBackupResult {
data class Success(val bytes: Long) : AndroidBackupResult data class Success(val bytes: Long) : AndroidBackupResult
data class Error(val message: String) : AndroidBackupResult data class Error(val message: String) : AndroidBackupResult
} }
internal data class VerifiedAndroidBackup(val directory: File, val database: File, val preferences: File) internal data class VerifiedAndroidBackup(
val directory: File,
val database: File,
val preferences: File,
)
/** Complete same-installation backup container; plaintext is explicit by design. */ /** Complete same-installation backup container; plaintext is explicit by design. */
internal class AndroidBackupService(private val context: Context) { internal class AndroidBackupService(private val context: Context) {
@ -34,26 +39,58 @@ internal class AndroidBackupService(private val context: Context) {
val value = MessageDigest.getInstance("SHA-256") val value = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input -> file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024) val buffer = ByteArray(64 * 1024)
while (true) { val count = input.read(buffer); if (count < 0) break; value.update(buffer, 0, count) } while (true) {
val count = input.read(buffer)
if (count < 0) break
value.update(buffer, 0, count)
}
} }
return value.digest().joinToString("") { "%02x".format(it) } return value.digest().joinToString("") { "%02x".format(it) }
} }
private fun preferencesJson(): String = JSONObject().apply { private fun preferencesJson(): String =
put("format", "trainlog-android-preferences"); put("version", 1) JSONObject()
put("stores", JSONObject().also { stores -> PREFERENCES.forEach { name -> .apply {
stores.put(name, JSONObject().also { values -> put("format", "trainlog-android-preferences")
context.getSharedPreferences(name, Context.MODE_PRIVATE).all.toSortedMap().forEach { (key, value) -> put("version", 1)
when (value) { put(
null -> values.put(key, JSONObject.NULL) "stores",
is String, is Boolean, is Int, is Long, is Float -> values.put(key, value) JSONObject().also { stores ->
is Set<*> -> values.put(key, JSONArray(value.filterIsInstance<String>().sorted())) PREFERENCES.forEach { name ->
else -> error("Unsupported preference type") stores.put(
} name,
} JSONObject().also { values ->
}) context
} }) .getSharedPreferences(name, Context.MODE_PRIVATE)
}.toString() .all
.toSortedMap()
.forEach { (key, value) ->
when (value) {
null -> values.put(key, JSONObject.NULL)
is String,
is Boolean,
is Int,
is Long,
is Float -> values.put(key, value)
is Set<*> ->
values.put(
key,
JSONArray(
value
.filterIsInstance<String>()
.sorted()
),
)
else -> error("Unsupported preference type")
}
}
},
)
}
},
)
}
.toString()
fun createPrivate(repository: TrainlogRepository): Result<File> = runCatching { fun createPrivate(repository: TrainlogRepository): Result<File> = runCatching {
val directory = File(context.filesDir, "backups").apply { mkdirs() } val directory = File(context.filesDir, "backups").apply { mkdirs() }
@ -66,85 +103,197 @@ internal class AndroidBackupService(private val context: Context) {
} }
fun create(repository: TrainlogRepository, destination: OutputStream): AndroidBackupResult { fun create(repository: TrainlogRepository, destination: OutputStream): AndroidBackupResult {
val archive = createPrivate(repository).getOrElse { return AndroidBackupResult.Error(it.message ?: "Backup failed.") } val archive =
return try { archive.inputStream().use { it.copyTo(destination) }; destination.flush() createPrivate(repository).getOrElse {
val bytes = archive.length(); archive.delete(); AndroidBackupResult.Success(bytes) return AndroidBackupResult.Error(it.message ?: "Backup failed.")
}
return try {
archive.inputStream().use { it.copyTo(destination) }
destination.flush()
val bytes = archive.length()
archive.delete()
AndroidBackupResult.Success(bytes)
} catch (error: Exception) { } catch (error: Exception) {
AndroidBackupResult.Error("Export failed; private backup retained at ${archive.name}: ${error.message}") AndroidBackupResult.Error(
"Export failed; private backup retained at ${archive.name}: ${error.message}"
)
} }
} }
private fun writeArchive(repository: TrainlogRepository, destination: OutputStream): AndroidBackupResult { private fun writeArchive(
repository: TrainlogRepository,
destination: OutputStream,
): AndroidBackupResult {
val stage = File(context.cacheDir, "backup-${java.util.UUID.randomUUID()}") val stage = File(context.cacheDir, "backup-${java.util.UUID.randomUUID()}")
if (!stage.mkdir()) return AndroidBackupResult.Error("Cannot create private backup staging.") if (!stage.mkdir())
val database = File(stage, "database.sqlite"); val preferences = File(stage, "preferences.json") return AndroidBackupResult.Error("Cannot create private backup staging.")
val database = File(stage, "database.sqlite")
val preferences = File(stage, "preferences.json")
return try { return try {
repository.backupDatabaseTo(database); preferences.writeText(preferencesJson()) repository.backupDatabaseTo(database)
preferences.writeText(preferencesJson())
val schema = validateDatabase(database) val schema = validateDatabase(database)
val files = listOf("database.sqlite" to database, "preferences.json" to preferences) val files = listOf("database.sqlite" to database, "preferences.json" to preferences)
val manifest = JSONObject().put("format", FORMAT).put("version", VERSION) val manifest =
.put("package", context.packageName).put("schema", schema).put("product_version", "0.1.2") JSONObject()
.put("plaintext", true).put("created_at", OffsetDateTime.now().toString()) .put("format", FORMAT)
.put("entries", JSONArray(files.map { (name, file) -> JSONObject().put("path", name) .put("version", VERSION)
.put("size", file.length()).put("sha256", digest(file)) })) .put("package", context.packageName)
.put("schema", schema)
.put("product_version", "0.1.2")
.put("plaintext", true)
.put("created_at", OffsetDateTime.now().toString())
.put(
"entries",
JSONArray(
files.map { (name, file) ->
JSONObject()
.put("path", name)
.put("size", file.length())
.put("sha256", digest(file))
}
),
)
var written = 0L var written = 0L
ZipOutputStream(destination).use { zip -> ZipOutputStream(destination).use { zip ->
files.forEach { (name, file) -> zip.putNextEntry(ZipEntry(name)); file.inputStream().use { input -> written += input.copyTo(zip) }; zip.closeEntry() } files.forEach { (name, file) ->
zip.putNextEntry(ZipEntry("manifest.json")); val raw = manifest.toString().toByteArray(); zip.write(raw); written += raw.size; zip.closeEntry() zip.putNextEntry(ZipEntry(name))
file.inputStream().use { input -> written += input.copyTo(zip) }
zip.closeEntry()
}
zip.putNextEntry(ZipEntry("manifest.json"))
val raw = manifest.toString().toByteArray()
zip.write(raw)
written += raw.size
zip.closeEntry()
} }
AndroidBackupResult.Success(written) AndroidBackupResult.Success(written)
} catch (error: Exception) { AndroidBackupResult.Error(error.message ?: "Backup failed.") } catch (error: Exception) {
} finally { stage.deleteRecursively() } AndroidBackupResult.Error(error.message ?: "Backup failed.")
} finally {
stage.deleteRecursively()
}
} }
fun verify(input: InputStream): Result<VerifiedAndroidBackup> { fun verify(input: InputStream): Result<VerifiedAndroidBackup> {
val stage = File(context.cacheDir, "restore-${java.util.UUID.randomUUID()}") val stage = File(context.cacheDir, "restore-${java.util.UUID.randomUUID()}")
if (!stage.mkdir()) return Result.failure(IllegalStateException("Cannot create restore staging.")) if (!stage.mkdir())
return Result.failure(IllegalStateException("Cannot create restore staging."))
return runCatching { return runCatching {
var total = 0L; val names = mutableSetOf<String>() var total = 0L
ZipInputStream(input).use { zip -> val names = mutableSetOf<String>()
while (true) { ZipInputStream(input).use { zip ->
val entry = zip.nextEntry ?: break while (true) {
require(!entry.isDirectory && entry.name in setOf("database.sqlite", "preferences.json", "manifest.json") && names.add(entry.name)) { "Invalid backup entry." } val entry = zip.nextEntry ?: break
val target = File(stage, entry.name); target.outputStream().use { output -> require(
val buffer = ByteArray(64 * 1024); var entryBytes = 0L !entry.isDirectory &&
while (true) { val count = zip.read(buffer); if (count < 0) break; entryBytes += count; total += count entry.name in
require(entryBytes <= MAX_ENTRY_BYTES && total <= MAX_TOTAL_BYTES) { "Backup exceeds bounds." }; output.write(buffer, 0, count) } setOf("database.sqlite", "preferences.json", "manifest.json") &&
names.add(entry.name)
) {
"Invalid backup entry."
}
val target = File(stage, entry.name)
target.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
var entryBytes = 0L
while (true) {
val count = zip.read(buffer)
if (count < 0) break
entryBytes += count
total += count
require(entryBytes <= MAX_ENTRY_BYTES && total <= MAX_TOTAL_BYTES) {
"Backup exceeds bounds."
}
output.write(buffer, 0, count)
}
}
}
} }
require(names == setOf("database.sqlite", "preferences.json", "manifest.json")) {
"Backup entries are incomplete."
}
val manifest = JSONObject(File(stage, "manifest.json").readText())
require(
manifest.getString("format") == FORMAT &&
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..20
) {
"Unsupported backup."
}
val expected = manifest.getJSONArray("entries")
require(expected.length() == 2)
val manifestPaths = mutableSetOf<String>()
for (index in 0 until expected.length()) {
val item = expected.getJSONObject(index)
val path = item.getString("path")
require(
path in setOf("database.sqlite", "preferences.json") &&
manifestPaths.add(path)
) {
"Invalid manifest entry."
}
val file = File(stage, path)
require(
file.canonicalFile.parentFile == stage.canonicalFile &&
file.length() == item.getLong("size") &&
digest(file) == item.getString("sha256")
) {
"Backup digest mismatch."
}
}
require(manifestPaths == setOf("database.sqlite", "preferences.json")) {
"Backup manifest is incomplete."
}
require(
validateDatabase(File(stage, "database.sqlite")) == manifest.getInt("schema")
)
val preferences = JSONObject(File(stage, "preferences.json").readText())
require(
preferences.getString("format") == "trainlog-android-preferences" &&
preferences.getInt("version") == 1
)
VerifiedAndroidBackup(
stage,
File(stage, "database.sqlite"),
File(stage, "preferences.json"),
)
} }
} .onFailure { stage.deleteRecursively() }
require(names == setOf("database.sqlite", "preferences.json", "manifest.json")) { "Backup entries are incomplete." }
val manifest = JSONObject(File(stage, "manifest.json").readText())
require(manifest.getString("format") == FORMAT && manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..20) { "Unsupported backup." }
val expected = manifest.getJSONArray("entries")
require(expected.length() == 2)
val manifestPaths = mutableSetOf<String>()
for (index in 0 until expected.length()) { val item = expected.getJSONObject(index); val path = item.getString("path")
require(path in setOf("database.sqlite", "preferences.json") && manifestPaths.add(path)) { "Invalid manifest entry." }; val file = File(stage, path)
require(file.canonicalFile.parentFile == stage.canonicalFile && file.length() == item.getLong("size") && digest(file) == item.getString("sha256")) { "Backup digest mismatch." } }
require(manifestPaths == setOf("database.sqlite", "preferences.json")) { "Backup manifest is incomplete." }
require(validateDatabase(File(stage, "database.sqlite")) == manifest.getInt("schema"))
val preferences = JSONObject(File(stage, "preferences.json").readText())
require(preferences.getString("format") == "trainlog-android-preferences" && preferences.getInt("version") == 1)
VerifiedAndroidBackup(stage, File(stage, "database.sqlite"), File(stage, "preferences.json"))
}.onFailure { stage.deleteRecursively() }
} }
fun restore(repository: TrainlogRepository, verified: VerifiedAndroidBackup, databaseName: String = "trainlog-android.db"): AndroidBackupResult { fun restore(
repository: TrainlogRepository,
verified: VerifiedAndroidBackup,
databaseName: String = "trainlog-android.db",
): AndroidBackupResult {
val destination = context.getDatabasePath(databaseName) val destination = context.getDatabasePath(databaseName)
val previous = File(destination.parentFile, "${destination.name}.pre-restore-${java.util.UUID.randomUUID()}") val previous =
val previousPreferences = File(context.cacheDir, "preferences-pre-restore-${java.util.UUID.randomUUID()}.json") File(
destination.parentFile,
"${destination.name}.pre-restore-${java.util.UUID.randomUUID()}",
)
val previousPreferences =
File(context.cacheDir, "preferences-pre-restore-${java.util.UUID.randomUUID()}.json")
var moved = false var moved = false
return try { return try {
previousPreferences.writeText(preferencesJson()) previousPreferences.writeText(preferencesJson())
repository.close(); destination.parentFile?.mkdirs() repository.close()
if (destination.exists()) { check(destination.renameTo(previous)); moved = true } destination.parentFile?.mkdirs()
File("${destination.path}-wal").delete(); File("${destination.path}-shm").delete() if (destination.exists()) {
check(destination.renameTo(previous))
moved = true
}
File("${destination.path}-wal").delete()
File("${destination.path}-shm").delete()
verified.database.copyTo(destination, overwrite = false) verified.database.copyTo(destination, overwrite = false)
validateDatabase(destination); restorePreferences(verified.preferences) validateDatabase(destination)
previous.delete(); previousPreferences.delete(); verified.directory.deleteRecursively() restorePreferences(verified.preferences)
previous.delete()
previousPreferences.delete()
verified.directory.deleteRecursively()
AndroidBackupResult.Success(destination.length()) AndroidBackupResult.Success(destination.length())
} catch (error: Exception) { } catch (error: Exception) {
destination.delete() destination.delete()
@ -157,20 +306,40 @@ internal class AndroidBackupService(private val context: Context) {
private fun validateDatabase(file: File): Int { private fun validateDatabase(file: File): Int {
val db = SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY) val db = SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY)
try { require(db.rawQuery("PRAGMA integrity_check", null).use { it.moveToFirst() && it.getString(0) == "ok" }) try {
require(
db.rawQuery("PRAGMA integrity_check", null).use {
it.moveToFirst() && it.getString(0) == "ok"
}
)
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..20) { "Unsupported Android schema ${db.version}." } require(db.version in 17..20) { "Unsupported Android schema ${db.version}." }
return db.version return db.version
} finally { db.close() } } finally {
db.close()
}
} }
private fun restorePreferences(file: File) { private fun restorePreferences(file: File) {
val stores = JSONObject(file.readText()).getJSONObject("stores") val stores = JSONObject(file.readText()).getJSONObject("stores")
PREFERENCES.forEach { name -> val editor = context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear(); val values = stores.getJSONObject(name) PREFERENCES.forEach { name ->
values.keys().forEach { key -> when (val value = values.get(key)) { val editor = context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear()
is String -> editor.putString(key, value); is Boolean -> editor.putBoolean(key, value) val values = stores.getJSONObject(name)
is Int -> editor.putInt(key, value); is Long -> editor.putLong(key, value) values.keys().forEach { key ->
is Double -> editor.putFloat(key, value.toFloat()); is JSONArray -> editor.putStringSet(key, (0 until value.length()).map { value.getString(it) }.toSet()) when (val value = values.get(key)) {
} }; check(editor.commit()) } is String -> editor.putString(key, value)
is Boolean -> editor.putBoolean(key, value)
is Int -> editor.putInt(key, value)
is Long -> editor.putLong(key, value)
is Double -> editor.putFloat(key, value.toFloat())
is JSONArray ->
editor.putStringSet(
key,
(0 until value.length()).map { value.getString(it) }.toSet(),
)
}
}
check(editor.commit())
}
} }
} }

View file

@ -9,6 +9,7 @@ import org.json.JSONObject
internal sealed interface ForegroundGenerationResult { internal sealed interface ForegroundGenerationResult {
data class Completed(val runId: String) : ForegroundGenerationResult data class Completed(val runId: String) : ForegroundGenerationResult
data class Error(val message: String) : ForegroundGenerationResult data class Error(val message: String) : ForegroundGenerationResult
} }
@ -19,43 +20,86 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
private fun publish(path: File, value: String) { private fun publish(path: File, value: String) {
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}") val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
FileOutputStream(temporary).use { stream -> FileOutputStream(temporary).use { stream ->
stream.write(value.toByteArray()); stream.flush(); stream.fd.sync() stream.write(value.toByteArray())
stream.flush()
stream.fd.sync()
} }
if (!temporary.renameTo(path)) throw SyncGenerationException("cannot publish ${path.name}") if (!temporary.renameTo(path)) throw SyncGenerationException("cannot publish ${path.name}")
} }
private fun await(path: File, deadline: Long): File { private fun await(path: File, deadline: Long): File {
while (!path.isFile) { while (!path.isFile) {
if (System.nanoTime() >= deadline) throw SyncGenerationException("timeout waiting for ${path.name}") if (System.nanoTime() >= deadline)
throw SyncGenerationException("timeout waiting for ${path.name}")
Thread.sleep(50) Thread.sleep(50)
} }
return path return path
} }
fun run(directory: File, timeout: Duration = Duration.ofMinutes(5), afterPublication: (() -> Unit)? = null): ForegroundGenerationResult = try { fun run(
if (!directory.isDirectory) throw SyncGenerationException("exchange directory is unavailable") directory: File,
val peer = service.peerId() timeout: Duration = Duration.ofMinutes(5),
publish(File(directory, "android-peer-v1.json"), JSONObject() afterPublication: (() -> Unit)? = null,
.put("format", "trainlog-sync-peer").put("version", 1).put("peer_id", peer) ): ForegroundGenerationResult =
.put("capabilities", JSONArray(listOf("generation-manifest-v1", "generation-ack-v1", try {
"causal-delete-v1", "mobile-history-v4", "execution-draft-v1"))).toString()) if (!directory.isDirectory)
val deadline = System.nanoTime() + timeout.toNanos() throw SyncGenerationException("exchange directory is unavailable")
val request = JSONObject(await(File(directory, "request-v1.json"), deadline).readText()) val peer = service.peerId()
if (request.getString("android_peer_id") != peer) throw SyncGenerationException("request targets another Android peer") publish(
val captured = service.capture(directory, request.getString("desktop_peer_id"), request.getString("run_id")) File(directory, "android-peer-v1.json"),
val published = service.publish(captured, File(directory, "android-objects")) JSONObject()
publish(File(directory, "android-generation-v1.json"), JSONObject() .put("format", "trainlog-sync-peer")
.put("format", "trainlog-sync-generation-reference").put("version", 1) .put("version", 1)
.put("run_id", captured.runId).put("generation_id", captured.generationId) .put("peer_id", peer)
.put("manifest_sha256", captured.manifestSha256) .put(
.put("relative_path", published.relativeTo(directory).path).toString()) "capabilities",
afterPublication?.invoke() JSONArray(
service.acceptAcknowledgement(await(File(directory, "desktop-consumption-ack-v1.json"), deadline).readBytes()) listOf(
val reference = JSONObject(await(File(directory, "desktop-generation-v1.json"), deadline).readText()) "generation-manifest-v1",
val acknowledgement = service.consume(File(directory, reference.getString("relative_path"))) "generation-ack-v1",
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement) "causal-delete-v1",
ForegroundGenerationResult.Completed(captured.runId) "mobile-history-v4",
} catch (error: Exception) { "execution-draft-v1",
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed") )
} ),
)
.toString(),
)
val deadline = System.nanoTime() + timeout.toNanos()
val request = JSONObject(await(File(directory, "request-v1.json"), deadline).readText())
if (request.getString("android_peer_id") != peer)
throw SyncGenerationException("request targets another Android peer")
val captured =
service.capture(
directory,
request.getString("desktop_peer_id"),
request.getString("run_id"),
)
val published = service.publish(captured, File(directory, "android-objects"))
publish(
File(directory, "android-generation-v1.json"),
JSONObject()
.put("format", "trainlog-sync-generation-reference")
.put("version", 1)
.put("run_id", captured.runId)
.put("generation_id", captured.generationId)
.put("manifest_sha256", captured.manifestSha256)
.put("relative_path", published.relativeTo(directory).path)
.toString(),
)
afterPublication?.invoke()
service.acceptAcknowledgement(
await(File(directory, "desktop-consumption-ack-v1.json"), deadline).readBytes()
)
val reference =
JSONObject(
await(File(directory, "desktop-generation-v1.json"), deadline).readText()
)
val acknowledgement =
service.consume(File(directory, reference.getString("relative_path")))
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
ForegroundGenerationResult.Completed(captured.runId)
} catch (error: Exception) {
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
}
} }

View file

@ -5,8 +5,8 @@ import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionDraftForm import com.labfytools.trainlog.model.SessionDraftForm
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@ -24,20 +24,43 @@ import org.robolectric.annotation.Config
class AndroidBackupServiceTest { class AndroidBackupServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext() private val context: Context = ApplicationProvider.getApplicationContext()
@Test fun completeBackupRestoresDraftPeerLedgersAndPreferences() { @Test
fun completeBackupRestoresDraftPeerLedgersAndPreferences() {
val name = "backup-${UUID.randomUUID()}.db" val name = "backup-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name) var repository = TrainlogRepository(context, name)
val service = AndroidBackupService(context) val service = AndroidBackupService(context)
try { try {
val exercise = (repository.createExercise(NewExerciseProfile("Backup exercise", val exercise =
RecordingMode.SETS, TrackingMode.REPS, 0)) as CreateExerciseResult.Created).exercise (repository.createExercise(
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(ActiveSessionDraft( NewExerciseProfile(
exercises = listOf(SessionExerciseDraft(exercise = exercise, "Backup exercise",
sets = listOf(SessionSetDraft(reps = 9, weightKg = 31.5)))), RecordingMode.SETS,
form = SessionDraftForm(selectedExercise = exercise, weightText = "31,")))) TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created)
.exercise
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 9, weightKg = 31.5)),
)
),
form = SessionDraftForm(selectedExercise = exercise, weightText = "31,"),
)
),
)
val peer = SyncGenerationService(repository).peerId() val peer = SyncGenerationService(repository).peerId()
context.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE) context
.edit().putString("language", "en").commit() .getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE)
.edit()
.putString("language", "en")
.commit()
val output = ByteArrayOutputStream() val output = ByteArrayOutputStream()
assertTrue(service.create(repository, output) is AndroidBackupResult.Success) assertTrue(service.create(repository, output) is AndroidBackupResult.Success)
val verified = service.verify(ByteArrayInputStream(output.toByteArray())).getOrThrow() val verified = service.verify(ByteArrayInputStream(output.toByteArray())).getOrThrow()
@ -47,15 +70,26 @@ class AndroidBackupServiceTest {
assertTrue(draft is ActiveDraftLoadResult.Loaded) assertTrue(draft is ActiveDraftLoadResult.Loaded)
assertEquals("31,", (draft as ActiveDraftLoadResult.Loaded).draft.form.weightText) assertEquals("31,", (draft as ActiveDraftLoadResult.Loaded).draft.form.weightText)
assertEquals(peer, SyncGenerationService(repository).peerId()) assertEquals(peer, SyncGenerationService(repository).peerId())
assertEquals("en", context.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE).getString("language", null)) assertEquals(
} finally { repository.close(); context.deleteDatabase(name) } "en",
context
.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE)
.getString("language", null),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
} }
@Test fun corruptAndTraversalContainersAreRejectedBeforeRestore() { @Test
fun corruptAndTraversalContainersAreRejectedBeforeRestore() {
val service = AndroidBackupService(context) val service = AndroidBackupService(context)
val output = ByteArrayOutputStream() val output = ByteArrayOutputStream()
java.util.zip.ZipOutputStream(output).use { zip -> java.util.zip.ZipOutputStream(output).use { zip ->
zip.putNextEntry(java.util.zip.ZipEntry("../database.sqlite")); zip.write(byteArrayOf(1)); zip.closeEntry() zip.putNextEntry(java.util.zip.ZipEntry("../database.sqlite"))
zip.write(byteArrayOf(1))
zip.closeEntry()
} }
assertTrue(service.verify(ByteArrayInputStream(output.toByteArray())).isFailure) assertTrue(service.verify(ByteArrayInputStream(output.toByteArray())).isFailure)
} }

View file

@ -2,8 +2,8 @@ package com.labfytools.trainlog.data
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import java.io.FileInputStream
import java.io.File import java.io.File
import java.io.FileInputStream
import java.util.UUID import java.util.UUID
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@ -13,19 +13,37 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class) @Config(sdk=[35]) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BridgeBackupMigrationTest { class BridgeBackupMigrationTest {
@Test fun verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner() { @Test
val source=System.getenv("TRAINLOG_BRIDGE_BACKUP_INPUT")?.let(::File) fun verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner() {
assumeTrue(source?.isFile==true) val source = System.getenv("TRAINLOG_BRIDGE_BACKUP_INPUT")?.let(::File)
val context:Context=ApplicationProvider.getApplicationContext();val name="bridge-migrate-${UUID.randomUUID()}.db" assumeTrue(source?.isFile == true)
val service=AndroidBackupService(context);var repository=TrainlogRepository(context,name) val context: Context = ApplicationProvider.getApplicationContext()
val name = "bridge-migrate-${UUID.randomUUID()}.db"
val service = AndroidBackupService(context)
var repository = TrainlogRepository(context, name)
try { try {
val verified=FileInputStream(source!!).use { service.verify(it).getOrThrow() } val verified = FileInputStream(source!!).use { service.verify(it).getOrThrow() }
assertTrue(service.restore(repository,verified,name) is AndroidBackupResult.Success) assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
repository=TrainlogRepository(context,name) repository = TrainlogRepository(context, name)
assertEquals("21,",(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft.form.weightText) assertEquals(
android.database.sqlite.SQLiteDatabase.openDatabase(context.getDatabasePath(name).path,null,android.database.sqlite.SQLiteDatabase.OPEN_READONLY).use { assertEquals(20,it.version) } "21,",
} finally {repository.close();context.deleteDatabase(name)} (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
.draft
.form
.weightText,
)
android.database.sqlite.SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
)
.use { assertEquals(20, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
} }
} }

View file

@ -36,7 +36,9 @@ class SyncDeploymentConversationTest {
private fun publish(file: File, value: String) { private fun publish(file: File, value: String) {
val temporary = File(file.parentFile, ".${file.name}.tmp-${UUID.randomUUID()}") val temporary = File(file.parentFile, ".${file.name}.tmp-${UUID.randomUUID()}")
temporary.outputStream().use { output -> temporary.outputStream().use { output ->
output.write(value.toByteArray()); output.flush(); output.fd.sync() output.write(value.toByteArray())
output.flush()
output.fd.sync()
} }
check(temporary.renameTo(file)) { "cannot publish ${file.name}" } check(temporary.renameTo(file)) { "cannot publish ${file.name}" }
} }
@ -44,42 +46,83 @@ class SyncDeploymentConversationTest {
@Test @Test
fun foregroundAndroidServiceCompletesCorrelatedDesktopConversation() { fun foregroundAndroidServiceCompletesCorrelatedDesktopConversation() {
val suppliedTransport = System.getenv("TRAINLOG_SYNC_TRANSPORT_ROOT") val suppliedTransport = System.getenv("TRAINLOG_SYNC_TRANSPORT_ROOT")
val transport = suppliedTransport?.let(::File) ?: kotlin.io.path.createTempDirectory("deployment-conversation-").toFile() val transport =
val repositoryRoot = generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile } suppliedTransport?.let(::File)
.first { File(it, "tools/sync_peer_worker.py").isFile } ?: kotlin.io.path.createTempDirectory("deployment-conversation-").toFile()
val repositoryRoot =
generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile }
.first { File(it, "tools/sync_peer_worker.py").isFile }
val databaseName = "deployment-peer-${UUID.randomUUID()}.db" val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, databaseName) var repository = TrainlogRepository(context, databaseName)
var localWorker: Process? = null var localWorker: Process? = null
try { try {
transport.mkdirs() transport.mkdirs()
val exercise = (repository.createExercise(NewExerciseProfile( val exercise =
"Deployment conversation", RecordingMode.SETS, TrackingMode.REPS, 0, (repository.createExercise(
)) as CreateExerciseResult.Created).exercise NewExerciseProfile(
val occurrence = SessionExerciseDraft(exercise = exercise, "Deployment conversation",
sets = listOf(SessionSetDraft(reps = 7, weightKg = 22.5))) RecordingMode.SETS,
assertEquals(ActiveDraftMutationResult.Saved, TrackingMode.REPS,
repository.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(occurrence)))) 0,
)
) as CreateExerciseResult.Created)
.exercise
val occurrence =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 7, weightKg = 22.5)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(occurrence))
),
)
val coordinator = SyncGenerationForegroundCoordinator(repository) val coordinator = SyncGenerationForegroundCoordinator(repository)
val release = System.getenv("TRAINLOG_SYNC_TEST_RELEASE_FILE") val release = System.getenv("TRAINLOG_SYNC_TEST_RELEASE_FILE")
val executor = java.util.concurrent.Executors.newSingleThreadExecutor() val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
val result = executor.submit<ForegroundGenerationResult> { val result =
coordinator.run(transport, Duration.ofSeconds(30)) { executor.submit<ForegroundGenerationResult> {
release?.let { awaitFile(File(it)) } coordinator.run(transport, Duration.ofSeconds(30)) {
release?.let { awaitFile(File(it)) }
}
} }
}
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText()) val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
if (suppliedTransport == null) { if (suppliedTransport == null) {
val desktop = File(transport, "desktop.sqlite") val desktop = File(transport, "desktop.sqlite")
val fixture = ProcessBuilder(File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath, val fixture =
desktop.absolutePath).directory(repositoryRoot).start() ProcessBuilder(
File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktop.absolutePath,
)
.directory(repositoryRoot)
.start()
assertEquals(0, fixture.waitFor()) assertEquals(0, fixture.waitFor())
localWorker = ProcessBuilder("python3", File(repositoryRoot, "tools/sync_peer_worker.py").absolutePath, localWorker =
"--database", desktop.absolutePath, "--transport-root", transport.absolutePath, ProcessBuilder(
"--owned-root", File(transport, "owned").absolutePath, "--run-id", "sy_${UUID.randomUUID()}", "python3",
"--expected-peer", peer.getString("peer_id"), "--timeout", "30").directory(repositoryRoot) File(repositoryRoot, "tools/sync_peer_worker.py").absolutePath,
.redirectErrorStream(true).start() "--database",
desktop.absolutePath,
"--transport-root",
transport.absolutePath,
"--owned-root",
File(transport, "owned").absolutePath,
"--run-id",
"sy_${UUID.randomUUID()}",
"--expected-peer",
peer.getString("peer_id"),
"--timeout",
"30",
)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
} }
assertTrue(result.get(35, java.util.concurrent.TimeUnit.SECONDS) is ForegroundGenerationResult.Completed) assertTrue(
result.get(35, java.util.concurrent.TimeUnit.SECONDS)
is ForegroundGenerationResult.Completed
)
executor.shutdownNow() executor.shutdownNow()
repository.close() repository.close()
repository = TrainlogRepository(context, databaseName) repository = TrainlogRepository(context, databaseName)

View file

@ -9,6 +9,10 @@ import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.json.JSONObject import org.json.JSONObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@ -16,10 +20,6 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [35]) @Config(sdk = [35])
@ -28,157 +28,493 @@ class SyncGenerationServiceTest {
@Test @Test
fun v19MigrationPreservesDataAndInventsNoGenerationState() { fun v19MigrationPreservesDataAndInventsNoGenerationState() {
val name="generation-migration-${UUID.randomUUID()}.db" val name = "generation-migration-${UUID.randomUUID()}.db"
TrainlogRepository(context,name).also{ TrainlogRepository(context, name).also {
assertTrue(it.createExercise(NewExerciseProfile("Migration sentinel",RecordingMode.SETS,TrackingMode.REPS,0)) is CreateExerciseResult.Created) assertTrue(
it.createExercise(
NewExerciseProfile(
"Migration sentinel",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) is CreateExerciseResult.Created
)
it.close() it.close()
} }
val path=context.getDatabasePath(name) val path = context.getDatabasePath(name)
SQLiteDatabase.openDatabase(path.absolutePath,null,SQLiteDatabase.OPEN_READWRITE).use{db-> SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READWRITE).use { db
listOf("sync_causal_publications","sync_acknowledgements","sync_consumed_generations","sync_generation_artifacts","sync_generations","sync_peer_identity").forEach{db.execSQL("DROP TABLE $it")} ->
listOf(
"sync_causal_publications",
"sync_acknowledgements",
"sync_consumed_generations",
"sync_generation_artifacts",
"sync_generations",
"sync_peer_identity",
)
.forEach { db.execSQL("DROP TABLE $it") }
db.execSQL("PRAGMA user_version=19") db.execSQL("PRAGMA user_version=19")
} }
val migrated=TrainlogRepository(context,name).also{it.listExercises()} val migrated = TrainlogRepository(context, name).also { it.listExercises() }
try{ try {
SQLiteDatabase.openDatabase(path.absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db-> SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
db.rawQuery("PRAGMA user_version",null).use{assertTrue(it.moveToFirst());assertEquals(20,it.getInt(0))} .use { db ->
db.rawQuery("SELECT COUNT(*) FROM exercises",null).use{assertTrue(it.moveToFirst());assertTrue(it.getInt(0)>0)} db.rawQuery("PRAGMA user_version", null).use {
listOf("sync_generations","sync_consumed_generations","sync_acknowledgements","sync_causal_publications").forEach{table->db.rawQuery("SELECT COUNT(*) FROM $table",null).use{assertTrue(it.moveToFirst());assertEquals(0,it.getInt(0))}} assertTrue(it.moveToFirst())
} assertEquals(20, it.getInt(0))
}finally{migrated.close();context.deleteDatabase(name)} }
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())
assertTrue(it.getInt(0) > 0)
}
listOf(
"sync_generations",
"sync_consumed_generations",
"sync_acknowledgements",
"sync_causal_publications",
)
.forEach { table ->
db.rawQuery("SELECT COUNT(*) FROM $table", null).use {
assertTrue(it.moveToFirst())
assertEquals(0, it.getInt(0))
}
}
}
} finally {
migrated.close()
context.deleteDatabase(name)
}
} }
@Test @Test
fun lateSemanticFailureRollsBackWholeAndroidGeneration() { fun lateSemanticFailureRollsBackWholeAndroidGeneration() {
val sourceName="generation-rollback-source-${UUID.randomUUID()}.db";val destinationName="generation-rollback-destination-${UUID.randomUUID()}.db" val sourceName = "generation-rollback-source-${UUID.randomUUID()}.db"
val root=Files.createTempDirectory("trainlog-generation-rollback-").toFile();val source=TrainlogRepository(context,sourceName);val destination=TrainlogRepository(context,destinationName) val destinationName = "generation-rollback-destination-${UUID.randomUUID()}.db"
try{ val root = Files.createTempDirectory("trainlog-generation-rollback-").toFile()
val exercise=(source.createExercise(NewExerciseProfile("Rollback exercise",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise val source = TrainlogRepository(context, sourceName)
assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=5)))))) is SaveSessionResult.Saved) val destination = TrainlogRepository(context, destinationName)
val destinationService=SyncGenerationService(destination);val identity=destinationService.capture(root,"peer_11111111-1111-4111-8111-111111111111") try {
val peer=JSONObject(java.io.File(identity.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id") val exercise =
val sourceService=SyncGenerationService(source);val captured=sourceService.capture(root,peer);val published=sourceService.publish(captured,java.io.File(root,"objects")) (source.createExercise(
val bad="{}".toByteArray();java.io.File(published,"feedback-v2.json").writeBytes(bad) NewExerciseProfile(
val manifest=JSONObject(java.io.File(published,"manifest.json").readText());val artifacts=manifest.getJSONArray("artifacts") "Rollback exercise",
for(index in 0 until artifacts.length())if(artifacts.getJSONObject(index).getString("logical_name")=="feedback")artifacts.getJSONObject(index).put("size",bad.size).put("sha256",java.security.MessageDigest.getInstance("SHA-256").digest(bad).joinToString(""){"%02x".format(it)}) RecordingMode.SETS,
java.io.File(published,"manifest.json").writeText(manifest.toString()) TrackingMode.REPS,
assertEquals("rejected",JSONObject(destinationService.consume(published)).getString("result")) 0,
)
) as CreateExerciseResult.Created)
.exercise
assertTrue(
source.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5)),
)
)
)
) is SaveSessionResult.Saved
)
val destinationService = SyncGenerationService(destination)
val identity =
destinationService.capture(root, "peer_11111111-1111-4111-8111-111111111111")
val peer =
JSONObject(java.io.File(identity.stagingDirectory, "manifest.json").readText())
.getJSONObject("producer")
.getString("peer_id")
val sourceService = SyncGenerationService(source)
val captured = sourceService.capture(root, peer)
val published = sourceService.publish(captured, java.io.File(root, "objects"))
val bad = "{}".toByteArray()
java.io.File(published, "feedback-v2.json").writeBytes(bad)
val manifest = JSONObject(java.io.File(published, "manifest.json").readText())
val artifacts = manifest.getJSONArray("artifacts")
for (index in 0 until artifacts.length()) if (
artifacts.getJSONObject(index).getString("logical_name") == "feedback"
)
artifacts
.getJSONObject(index)
.put("size", bad.size)
.put(
"sha256",
java.security.MessageDigest.getInstance("SHA-256").digest(bad).joinToString(
""
) {
"%02x".format(it)
},
)
java.io.File(published, "manifest.json").writeText(manifest.toString())
assertEquals(
"rejected",
JSONObject(destinationService.consume(published)).getString("result"),
)
assertTrue(destination.listSessions().isEmpty()) assertTrue(destination.listSessions().isEmpty())
SQLiteDatabase.openDatabase(context.getDatabasePath(destinationName).absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db->db.rawQuery("SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'",null).use{assertTrue(it.moveToFirst());assertEquals(1,it.getInt(0))}} SQLiteDatabase.openDatabase(
}finally{source.close();destination.close();context.deleteDatabase(sourceName);context.deleteDatabase(destinationName);root.deleteRecursively()} context.getDatabasePath(destinationName).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
)
.use { db ->
db.rawQuery(
"SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'",
null,
)
.use {
assertTrue(it.moveToFirst())
assertEquals(1, it.getInt(0))
}
}
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
root.deleteRecursively()
}
} }
@Test @Test
fun causalOperationBytesRemainImmutableAcrossAndroidGenerations() { fun causalOperationBytesRemainImmutableAcrossAndroidGenerations() {
val name="generation-causal-${UUID.randomUUID()}.db";val root=Files.createTempDirectory("trainlog-generation-causal-").toFile();val repo=TrainlogRepository(context,name) val name = "generation-causal-${UUID.randomUUID()}.db"
try{ val root = Files.createTempDirectory("trainlog-generation-causal-").toFile()
val exercise=(repo.createExercise(NewExerciseProfile("Causal generation",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise val repo = TrainlogRepository(context, name)
assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=4)))))) is SaveSessionResult.Saved) try {
val sessionId=repo.listSessions().single().sessionId val exercise =
assertTrue(repo.deleteCausally("session",sessionId,"peer_android_generation") is CausalDeleteResult.Applied) (repo.createExercise(
val service=SyncGenerationService(repo);val peer="peer_33333333-3333-4333-8333-333333333333" NewExerciseProfile(
val first=service.capture(root,peer);val second=service.capture(root,peer) "Causal generation",
fun operation(captured:CapturedSyncGeneration)=JSONObject(java.io.File(captured.stagingDirectory,"causal-deletions-v1.json").readText()).getJSONArray("operations").getJSONObject(0) RecordingMode.SETS,
assertEquals(operation(first).toString(),operation(second).toString());assertTrue(operation(first).isNull("publication_context")) TrackingMode.REPS,
SQLiteDatabase.openDatabase(context.getDatabasePath(name).absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db-> 0,
db.rawQuery("SELECT SUM(first_emission),COUNT(*)-SUM(first_emission) FROM sync_causal_publications",null).use{assertTrue(it.moveToFirst());assertEquals(1,it.getInt(0));assertEquals(1,it.getInt(1))} )
} ) as CreateExerciseResult.Created)
}finally{repo.close();context.deleteDatabase(name);root.deleteRecursively()} .exercise
assertTrue(
repo.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 4)),
)
)
)
) is SaveSessionResult.Saved
)
val sessionId = repo.listSessions().single().sessionId
assertTrue(
repo.deleteCausally("session", sessionId, "peer_android_generation")
is CausalDeleteResult.Applied
)
val service = SyncGenerationService(repo)
val peer = "peer_33333333-3333-4333-8333-333333333333"
val first = service.capture(root, peer)
val second = service.capture(root, peer)
fun operation(captured: CapturedSyncGeneration) =
JSONObject(
java.io
.File(captured.stagingDirectory, "causal-deletions-v1.json")
.readText()
)
.getJSONArray("operations")
.getJSONObject(0)
assertEquals(operation(first).toString(), operation(second).toString())
assertTrue(operation(first).isNull("publication_context"))
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
)
.use { db ->
db.rawQuery(
"SELECT SUM(first_emission),COUNT(*)-SUM(first_emission) FROM sync_causal_publications",
null,
)
.use {
assertTrue(it.moveToFirst())
assertEquals(1, it.getInt(0))
assertEquals(1, it.getInt(1))
}
}
} finally {
repo.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
} }
@Test @Test
fun androidCaptureUsesOneSnapshotAgainstControlledWriter() { fun androidCaptureUsesOneSnapshotAgainstControlledWriter() {
val name="generation-snapshot-${UUID.randomUUID()}.db";val repo=TrainlogRepository(context,name) val name = "generation-snapshot-${UUID.randomUUID()}.db"
try{ val repo = TrainlogRepository(context, name)
val first=(repo.createExercise(NewExerciseProfile("Snapshot A",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise try {
assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=first,sets=listOf(SessionSetDraft(reps=3)))))) is SaveSessionResult.Saved) val first =
val attempted=CountDownLatch(1);var writer:Thread?=null (repo.createExercise(
val captured=repo.captureSyncGenerationArtifacts{ NewExerciseProfile("Snapshot A", RecordingMode.SETS, TrackingMode.REPS, 0)
writer=Thread{ ) as CreateExerciseResult.Created)
attempted.countDown() .exercise
repo.createExercise(NewExerciseProfile("Snapshot B",RecordingMode.SETS,TrackingMode.REPS,0)) assertTrue(
}.also{it.start()} repo.saveSession(
assertTrue(attempted.await(5,TimeUnit.SECONDS)) SessionDraft(
} listOf(
val completedWriter=checkNotNull(writer) SessionExerciseDraft(
completedWriter.join(5000);assertTrue(!completedWriter.isAlive) exercise = first,
assertEquals(1,JSONObject(captured.getValue("catalog")).getJSONArray("exercises").length()) sets = listOf(SessionSetDraft(reps = 3)),
assertEquals(1,JSONObject(captured.getValue("exercise-profile-state")).getJSONArray("exercises").length()) )
assertEquals(2,repo.listExercises().size) )
}finally{repo.close();context.deleteDatabase(name)} )
) is SaveSessionResult.Saved
)
val attempted = CountDownLatch(1)
var writer: Thread? = null
val captured =
repo.captureSyncGenerationArtifacts {
writer =
Thread {
attempted.countDown()
repo.createExercise(
NewExerciseProfile(
"Snapshot B",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
)
}
.also { it.start() }
assertTrue(attempted.await(5, TimeUnit.SECONDS))
}
val completedWriter = checkNotNull(writer)
completedWriter.join(5000)
assertTrue(!completedWriter.isAlive)
assertEquals(
1,
JSONObject(captured.getValue("catalog")).getJSONArray("exercises").length(),
)
assertEquals(
1,
JSONObject(captured.getValue("exercise-profile-state"))
.getJSONArray("exercises")
.length(),
)
assertEquals(2, repo.listExercises().size)
} finally {
repo.close()
context.deleteDatabase(name)
}
} }
@Test @Test
fun androidGenerationCapturePublishConsumeAndAckSurviveReopen() { fun androidGenerationCapturePublishConsumeAndAckSurviveReopen() {
val sourceName="generation-source-${UUID.randomUUID()}.db" val sourceName = "generation-source-${UUID.randomUUID()}.db"
val destinationName="generation-destination-${UUID.randomUUID()}.db" val destinationName = "generation-destination-${UUID.randomUUID()}.db"
val desktopDestinationName="generation-desktop-destination-${UUID.randomUUID()}.db" val desktopDestinationName = "generation-desktop-destination-${UUID.randomUUID()}.db"
val root=Files.createTempDirectory("trainlog-generation-android-").toFile() val root = Files.createTempDirectory("trainlog-generation-android-").toFile()
var source=TrainlogRepository(context,sourceName);var destination=TrainlogRepository(context,destinationName) var source = TrainlogRepository(context, sourceName)
var desktopDestination=TrainlogRepository(context,desktopDestinationName) var destination = TrainlogRepository(context, destinationName)
var desktopDestination = TrainlogRepository(context, desktopDestinationName)
try { try {
val created=source.createExercise(NewExerciseProfile("Generation exercise",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created val created =
val saved = source.saveSession( source.createExercise(
SessionDraft(listOf(SessionExerciseDraft( NewExerciseProfile(
exercise=created.exercise, "Generation exercise",
sets=listOf(SessionSetDraft(reps=7,weightKg=21.5)), RecordingMode.SETS,
))), TrackingMode.REPS,
) 0,
)
) as CreateExerciseResult.Created
val saved =
source.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = created.exercise,
sets = listOf(SessionSetDraft(reps = 7, weightKg = 21.5)),
)
)
)
)
assertTrue(saved is SaveSessionResult.Saved) assertTrue(saved is SaveSessionResult.Saved)
val destinationService=SyncGenerationService(destination) val destinationService = SyncGenerationService(destination)
val identityCapture=destinationService.capture(root,"peer_11111111-1111-4111-8111-111111111111") val identityCapture =
val destinationPeer=JSONObject(java.io.File(identityCapture.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id") destinationService.capture(root, "peer_11111111-1111-4111-8111-111111111111")
val sourceService=SyncGenerationService(source) val destinationPeer =
val captured=sourceService.capture(root,destinationPeer) JSONObject(
val published=sourceService.publish(captured,java.io.File(root,"objects")) java.io.File(identityCapture.stagingDirectory, "manifest.json").readText()
val before=published.listFiles()!!.associate{it.name to it.readBytes().contentHashCode()} )
assertEquals(published,sourceService.publish(captured,java.io.File(root,"objects"))) .getJSONObject("producer")
assertEquals(before,published.listFiles()!!.associate{it.name to it.readBytes().contentHashCode()}) .getString("peer_id")
val ack=destinationService.consume(published) val sourceService = SyncGenerationService(source)
assertEquals("acknowledged",sourceService.acceptAcknowledgement(ack.toByteArray())) val captured = sourceService.capture(root, destinationPeer)
source.close();destination.close() val published = sourceService.publish(captured, java.io.File(root, "objects"))
source=TrainlogRepository(context,sourceName);destination=TrainlogRepository(context,destinationName) val before =
assertEquals(1,destination.listSessions().size) published.listFiles()!!.associate { it.name to it.readBytes().contentHashCode() }
assertEquals("unchanged",SyncGenerationService(source).acceptAcknowledgement(ack.toByteArray())) assertEquals(published, sourceService.publish(captured, java.io.File(root, "objects")))
assertEquals(ack,SyncGenerationService(destination).consume(published)) assertEquals(
before,
published.listFiles()!!.associate { it.name to it.readBytes().contentHashCode() },
)
val ack = destinationService.consume(published)
assertEquals("acknowledged", sourceService.acceptAcknowledgement(ack.toByteArray()))
source.close()
destination.close()
source = TrainlogRepository(context, sourceName)
destination = TrainlogRepository(context, destinationName)
assertEquals(1, destination.listSessions().size)
assertEquals(
"unchanged",
SyncGenerationService(source).acceptAcknowledgement(ack.toByteArray()),
)
assertEquals(ack, SyncGenerationService(destination).consume(published))
// A desktop-created mutation uses the public C persistence API, // A desktop-created mutation uses the public C persistence API,
// then the production desktop generation entry point. This is not // then the production desktop generation entry point. This is not
// an Android generation re-exported through desktop. // an Android generation re-exported through desktop.
val repositoryRoot=generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))){it.parentFile} val repositoryRoot =
.first{java.io.File(it,"tools/sync_generation_exchange.py").isFile} generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
val desktopDb=java.io.File(root,"desktop-source.sqlite") it.parentFile
fun run(vararg command:String):String{ }
val process=ProcessBuilder(*command).directory(repositoryRoot).redirectErrorStream(true).start() .first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val text=process.inputStream.bufferedReader().readText() val desktopDb = java.io.File(root, "desktop-source.sqlite")
assertEquals("command failed: ${command.joinToString(" ")}\n$text",0,process.waitFor()) fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val text = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$text",
0,
process.waitFor(),
)
return text return text
} }
val desktopConsumerDb=java.io.File(root,"desktop-consumer.sqlite") val desktopConsumerDb = java.io.File(root, "desktop-consumer.sqlite")
run(java.io.File(repositoryRoot,"build/tui/sync-generation-fixture").absolutePath,desktopConsumerDb.absolutePath) run(
val desktopPeer=Regex("PEER_ID=(peer_[^\\s]+)").find(run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"peer-id","--database",desktopConsumerDb.absolutePath))!!.groupValues[1] java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
val androidToDesktop=sourceService.capture(root,desktopPeer) desktopConsumerDb.absolutePath,
val androidPublished=sourceService.publish(androidToDesktop,java.io.File(root,"android-desktop-objects")) )
val androidAckFile=java.io.File(root,"android-desktop-ack.json") val desktopPeer =
run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"consume-desktop",androidPublished.absolutePath,"--database",desktopConsumerDb.absolutePath,"--ack-output",androidAckFile.absolutePath) Regex("PEER_ID=(peer_[^\\s]+)")
assertEquals("acknowledged",sourceService.acceptAcknowledgement(androidAckFile.readBytes())) .find(
val androidSessionId=source.listSessions().single().sessionId run(
assertEquals("1",run("sqlite3",desktopConsumerDb.absolutePath,"SELECT count(*) FROM sessions WHERE session_id='$androidSessionId';").trim()) "python3",
java.io
.File(repositoryRoot, "tools/sync_generation_exchange.py")
.absolutePath,
"peer-id",
"--database",
desktopConsumerDb.absolutePath,
)
)!!
.groupValues[1]
val androidToDesktop = sourceService.capture(root, desktopPeer)
val androidPublished =
sourceService.publish(
androidToDesktop,
java.io.File(root, "android-desktop-objects"),
)
val androidAckFile = java.io.File(root, "android-desktop-ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
androidPublished.absolutePath,
"--database",
desktopConsumerDb.absolutePath,
"--ack-output",
androidAckFile.absolutePath,
)
assertEquals(
"acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
val androidSessionId = source.listSessions().single().sessionId
assertEquals(
"1",
run(
"sqlite3",
desktopConsumerDb.absolutePath,
"SELECT count(*) FROM sessions WHERE session_id='$androidSessionId';",
)
.trim(),
)
run(java.io.File(repositoryRoot,"build/tui/sync-generation-fixture").absolutePath,desktopDb.absolutePath) run(
val androidPeerCapture=SyncGenerationService(desktopDestination).capture(root,"peer_22222222-2222-4222-8222-222222222222") java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
val androidPeer=JSONObject(java.io.File(androidPeerCapture.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id") desktopDb.absolutePath,
val desktopOwned=java.io.File(root,"desktop-owned");val desktopObjects=java.io.File(root,"desktop-objects") )
val captureText=run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"capture-desktop","--database",desktopDb.absolutePath,"--owned-root",desktopOwned.absolutePath,"--consumer-peer",androidPeer) val androidPeerCapture =
val desktopGeneration=Regex("generation_id=(gen_[^ ]+)").find(captureText)!!.groupValues[1] SyncGenerationService(desktopDestination)
run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"publish","--database",desktopDb.absolutePath,"--generation-id",desktopGeneration,"--object-root",desktopObjects.absolutePath) .capture(root, "peer_22222222-2222-4222-8222-222222222222")
val desktopPublished=java.io.File(desktopObjects,"generations/$desktopGeneration") val androidPeer =
val desktopAck=SyncGenerationService(desktopDestination).consume(desktopPublished) JSONObject(
val desktopAckFile=java.io.File(root,"desktop-ack.json").apply{writeText(desktopAck)} java.io
assertTrue(run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"accept-ack",desktopAckFile.absolutePath,"--database",desktopDb.absolutePath).contains("ACK_ACCEPT=acknowledged")) .File(androidPeerCapture.stagingDirectory, "manifest.json")
assertTrue(desktopDestination.listSessions().any{it.sessionId=="se_99999999-9999-4999-8999-999999999999"}) .readText()
)
.getJSONObject("producer")
.getString("peer_id")
val desktopOwned = java.io.File(root, "desktop-owned")
val desktopObjects = java.io.File(root, "desktop-objects")
val captureText =
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"capture-desktop",
"--database",
desktopDb.absolutePath,
"--owned-root",
desktopOwned.absolutePath,
"--consumer-peer",
androidPeer,
)
val desktopGeneration =
Regex("generation_id=(gen_[^ ]+)").find(captureText)!!.groupValues[1]
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"publish",
"--database",
desktopDb.absolutePath,
"--generation-id",
desktopGeneration,
"--object-root",
desktopObjects.absolutePath,
)
val desktopPublished = java.io.File(desktopObjects, "generations/$desktopGeneration")
val desktopAck = SyncGenerationService(desktopDestination).consume(desktopPublished)
val desktopAckFile =
java.io.File(root, "desktop-ack.json").apply { writeText(desktopAck) }
assertTrue(
run(
"python3",
java.io
.File(repositoryRoot, "tools/sync_generation_exchange.py")
.absolutePath,
"accept-ack",
desktopAckFile.absolutePath,
"--database",
desktopDb.absolutePath,
)
.contains("ACK_ACCEPT=acknowledged")
)
assertTrue(
desktopDestination.listSessions().any {
it.sessionId == "se_99999999-9999-4999-8999-999999999999"
}
)
} finally { } finally {
source.close();destination.close();desktopDestination.close();context.deleteDatabase(sourceName);context.deleteDatabase(destinationName);context.deleteDatabase(desktopDestinationName);root.deleteRecursively() source.close()
destination.close()
desktopDestination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
context.deleteDatabase(desktopDestinationName)
root.deleteRecursively()
} }
} }
} }

View file

@ -1,43 +1,137 @@
import json, os, signal, subprocess, tempfile, time, unittest, uuid import json, os, signal, subprocess, tempfile, time, unittest, uuid
from pathlib import Path from pathlib import Path
ROOT=Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
ORCHESTRATOR=ROOT/"tools/sync_orchestrator.py" ORCHESTRATOR = ROOT / "tools/sync_orchestrator.py"
def identity(prefix): return prefix+str(uuid.uuid4())
def identity(prefix):
return prefix + str(uuid.uuid4())
class OrchestratorTest(unittest.TestCase): class OrchestratorTest(unittest.TestCase):
def fixture(self, root, **overrides): def fixture(self, root, **overrides):
db=root/"trainlog.db";db.touch();state=Path(str(db)+".sync-run.json") db = root / "trainlog.db"
run=identity("sy_");request=identity("sy_") db.touch()
state.write_text(json.dumps({"run_id":run,"request_id":request,"trigger":"web","phase":"requested","progress_revision":1,"result":"running"})) state = Path(str(db) + ".sync-run.json")
config={"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory","expected_peer_id":identity("peer_"),"transport_root":str(root/"transport"),"owned_root":str(root/"owned"),"timeout_seconds":30}|overrides run = identity("sy_")
(root/"transport").mkdir();(root/"owned").mkdir();path=root/"config.json";path.write_text(json.dumps(config)) request = identity("sy_")
return db,state,path,run,request state.write_text(
def invoke(self, executable, values): json.dumps(
db,state,config,run,request=values {
return subprocess.run(["python3",str(executable),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request],capture_output=True,text=True) "run_id": run,
def test_browser_configuration_cannot_select_command_or_boolean_timeout(self): "request_id": request,
for override in ({"peer_command":["evil"]},{"timeout_seconds":True},{"mode":"unsupported"}): "trigger": "web",
with self.subTest(override=override),tempfile.TemporaryDirectory() as directory: "phase": "requested",
root=Path(directory);values=self.fixture(root,**override);result=self.invoke(ORCHESTRATOR,values) "progress_revision": 1,
self.assertEqual(result.returncode,2);self.assertEqual(json.loads(values[1].read_text())["phase"],"failed") "result": "running",
def test_stdout_bound_is_enforced_before_complete_accumulation(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_peer_worker.py").write_text("import sys,time\nsys.stdout.write('x'*70000);sys.stdout.flush();time.sleep(30)\n") config = {
values=self.fixture(root);result=self.invoke(sandbox/"sync_orchestrator.py",values) "format": "trainlog-sync-orchestrator-config",
self.assertEqual(result.returncode,2);final=json.loads(values[1].read_text());self.assertIn("exceeds 64 KiB",final["diagnostic"]) "version": 1,
def test_shutdown_reaps_ignoring_helper_and_descendant(self): "enabled": True,
with tempfile.TemporaryDirectory() as directory: "mode": "directory",
root=Path(directory);sandbox=root/"program";sandbox.mkdir();pidfile=root/"pids";(sandbox/"sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes()) "expected_peer_id": identity("peer_"),
(sandbox/"sync_peer_worker.py").write_text("import os,signal,time\nsignal.signal(signal.SIGTERM,signal.SIG_IGN)\npid=os.fork()\nif pid==0:\n signal.signal(signal.SIGTERM,signal.SIG_IGN);open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\nelse:\n open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\n"%(str(pidfile),str(pidfile))) "transport_root": str(root / "transport"),
values=self.fixture(root);db,state,config,run,request=values "owned_root": str(root / "owned"),
process=subprocess.Popen(["python3",str(sandbox/"sync_orchestrator.py"),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request]) "timeout_seconds": 30,
deadline=time.monotonic()+5 } | overrides
while not pidfile.is_file() and time.monotonic()<deadline: time.sleep(.02) (root / "transport").mkdir()
process.send_signal(signal.SIGTERM);self.assertEqual(process.wait(timeout=10),2) (root / "owned").mkdir()
for pid in map(int,pidfile.read_text().split()): path = root / "config.json"
with self.assertRaises(ProcessLookupError): os.kill(pid,0) path.write_text(json.dumps(config))
self.assertEqual(json.loads(state.read_text())["phase"],"interrupted") return db, state, path, run, request
if __name__=="__main__": unittest.main() def invoke(self, executable, values):
db, state, config, run, request = values
return subprocess.run(
[
"python3",
str(executable),
"--database",
str(db),
"--state",
str(state),
"--config",
str(config),
"--run-id",
run,
"--request-id",
request,
],
capture_output=True,
text=True,
)
def test_browser_configuration_cannot_select_command_or_boolean_timeout(self):
for override in (
{"peer_command": ["evil"]},
{"timeout_seconds": True},
{"mode": "unsupported"},
):
with self.subTest(override=override), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
values = self.fixture(root, **override)
result = self.invoke(ORCHESTRATOR, values)
self.assertEqual(result.returncode, 2)
self.assertEqual(json.loads(values[1].read_text())["phase"], "failed")
def test_stdout_bound_is_enforced_before_complete_accumulation(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_peer_worker.py").write_text(
"import sys,time\nsys.stdout.write('x'*70000);sys.stdout.flush();time.sleep(30)\n"
)
values = self.fixture(root)
result = self.invoke(sandbox / "sync_orchestrator.py", values)
self.assertEqual(result.returncode, 2)
final = json.loads(values[1].read_text())
self.assertIn("exceeds 64 KiB", final["diagnostic"])
def test_shutdown_reaps_ignoring_helper_and_descendant(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sandbox = root / "program"
sandbox.mkdir()
pidfile = root / "pids"
(sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
(sandbox / "sync_peer_worker.py").write_text(
"import os,signal,time\nsignal.signal(signal.SIGTERM,signal.SIG_IGN)\npid=os.fork()\nif pid==0:\n signal.signal(signal.SIGTERM,signal.SIG_IGN);open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\nelse:\n open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\n"
% (str(pidfile), str(pidfile))
)
values = self.fixture(root)
db, state, config, run, request = values
process = subprocess.Popen(
[
"python3",
str(sandbox / "sync_orchestrator.py"),
"--database",
str(db),
"--state",
str(state),
"--config",
str(config),
"--run-id",
run,
"--request-id",
request,
]
)
deadline = time.monotonic() + 5
while not pidfile.is_file() and time.monotonic() < deadline:
time.sleep(0.02)
process.send_signal(signal.SIGTERM)
self.assertEqual(process.wait(timeout=10), 2)
for pid in map(int, pidfile.read_text().split()):
with self.assertRaises(ProcessLookupError):
os.kill(pid, 0)
self.assertEqual(json.loads(state.read_text())["phase"], "interrupted")
if __name__ == "__main__":
unittest.main()

View file

@ -1,83 +1,211 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Real Firefox and Android production services in one correlated conversation.""" """Real Firefox and Android production services in one correlated conversation."""
import json, os, signal, socket, sqlite3, subprocess, tempfile, time, unittest import json, os, signal, socket, sqlite3, subprocess, tempfile, time, unittest
from pathlib import Path from pathlib import Path
ROOT=Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
GECKO=Path(os.environ.get("TRAINLOG_GECKODRIVER","/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver")) GECKO = Path(
os.environ.get("TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver")
)
def await_condition(predicate, seconds=30): def await_condition(predicate, seconds=30):
deadline=time.monotonic()+seconds deadline = time.monotonic() + seconds
while time.monotonic()<deadline: while time.monotonic() < deadline:
value=predicate() value = predicate()
if value: return value if value:
time.sleep(.025) return value
raise AssertionError("condition did not become true") time.sleep(0.025)
raise AssertionError("condition did not become true")
class BrowserSyncTest(unittest.TestCase): class BrowserSyncTest(unittest.TestCase):
def test_button_starts_worker_reconnects_and_displays_committed_draft(self): def test_button_starts_worker_reconnects_and_displays_committed_draft(self):
if not GECKO.is_file(): self.skipTest("pinned geckodriver unavailable") if not GECKO.is_file():
from selenium import webdriver self.skipTest("pinned geckodriver unavailable")
from selenium.webdriver.common.by import By from selenium import webdriver
from selenium.webdriver.firefox.service import Service from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.firefox.service import Service
with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory: from selenium.webdriver.support.ui import WebDriverWait
root=Path(directory);xdg=root/"data";xdg.mkdir();config=root/"config.json"
mode=os.environ.get("TRAINLOG_WEB_E2E_TRANSPORT_MODE","directory")
transport=root/"transport";transport.mkdir();owned=root/"owned";owned.mkdir()
remote=(root/"device/Documents/Trainlog") if mode=="mtp" else transport
remote.mkdir(parents=True,exist_ok=True)
release=root/"release-android";android_log=(root/"android.log").open("wb")
android_env=os.environ.copy();android_env.update({"JAVA_HOME":"/usr/lib/jvm/java-17-openjdk","TRAINLOG_SYNC_TRANSPORT_ROOT":str(remote),"TRAINLOG_SYNC_TEST_RELEASE_FILE":str(release)})
android=subprocess.Popen(["./gradlew","--no-daemon","testDebugUnitTest","--tests","com.labfytools.trainlog.data.SyncDeploymentConversationTest","--rerun-tasks"],cwd=ROOT/"android",env=android_env,stdout=android_log,stderr=subprocess.STDOUT,start_new_session=True)
try:
peer_file=await_condition(lambda: (remote/"android-peer-v1.json") if (remote/"android-peer-v1.json").is_file() else None,120)
except Exception as error:
if android.poll() is None: os.killpg(android.pid,signal.SIGTERM)
android.wait(timeout=10);android_log.close()
raise AssertionError(f"Android bridge did not advertise:\n{(root/'android.log').read_text(errors='replace')}") from error
peer=json.loads(peer_file.read_text())
config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":mode,"expected_peer_id":peer["peer_id"],"transport_root":str(transport),"owned_root":str(owned),"timeout_seconds":60}))
with socket.socket() as reserved:
reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1]
env=os.environ.copy();env.update({"XDG_DATA_HOME":str(xdg),"XDG_CONFIG_HOME":str(root/"config"),"XDG_CACHE_HOME":str(root/"cache"),"TRAINLOG_SYNC_GENERATION_CONFIG":str(config)})
if mode=="mtp": env.update({"TRAINLOG_SYNC_MTP_ADAPTER":str(ROOT/"build/tui/generation-mtp-adapter-double"),"TRAINLOG_MTP_DOUBLE_ROOT":str(root/"device")})
server=subprocess.Popen([str(ROOT/"build/tui/trainlog"),"--web","--port",str(port)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
driver=None
try:
for _ in range(100):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1",port))==0: break
time.sleep(.05)
options=webdriver.FirefoxOptions();options.add_argument("-headless")
driver=webdriver.Firefox(options=options,service=Service(str(GECKO)))
driver.get(f"http://127.0.0.1:{port}/")
wait=WebDriverWait(driver,15)
button=wait.until(lambda d:d.find_element(By.XPATH,"//button[normalize-space()='Synchroniser']"))
wait.until(lambda _ : button.is_enabled());button.click()
state_path=Path(str(xdg/"trainlog"/"trainlog.db")+".sync-run.json")
observed=await_condition(lambda: json.loads(state_path.read_text()) if state_path.is_file() and json.loads(state_path.read_text()).get("phase") in ("local_import_committed","failed","interrupted") else None,70)
self.assertEqual(observed["phase"],"local_import_committed",f"state={observed}\nandroid={(root/'android.log').read_text(errors='replace')}")
committed=observed
self.assertEqual(committed["result"],"running")
with sqlite3.connect(xdg/"trainlog"/"trainlog.db") as database:
self.assertEqual(database.execute("SELECT COUNT(*) FROM execution_drafts").fetchone()[0],1)
release.touch()
final=await_condition(lambda: json.loads(state_path.read_text()) if json.loads(state_path.read_text()).get("phase") in ("completed","failed","interrupted") else None)
self.assertEqual(final["phase"],"completed",f"state={final}\nandroid={((root/'android.log').read_text(errors='replace'))}")
wait.until(lambda d:"Synchronisation confirmée" in d.page_source)
summary=driver.find_element(By.XPATH,"//summary[contains(.,'Synchronisation confirmée')]");summary.click()
wait.until(lambda d:"Brouillons reçus" in d.page_source and "active" in d.page_source)
driver.refresh();wait.until(lambda d:"Synchronisation confirmée" in d.page_source)
state=json.loads(Path(str(xdg/"trainlog"/"trainlog.db")+".sync-run.json").read_text())
self.assertEqual(state["phase"],"completed")
self.assertEqual(android.wait(timeout=30),0,(root/"android.log").read_text(errors="replace"))
finally:
if driver is not None: driver.quit()
os.killpg(server.pid,signal.SIGTERM)
server.wait(timeout=10)
if android.poll() is None:
os.killpg(android.pid,signal.SIGTERM);android.wait(timeout=10)
android_log.close()
if __name__=="__main__":unittest.main() with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory:
root = Path(directory)
xdg = root / "data"
xdg.mkdir()
config = root / "config.json"
mode = os.environ.get("TRAINLOG_WEB_E2E_TRANSPORT_MODE", "directory")
transport = root / "transport"
transport.mkdir()
owned = root / "owned"
owned.mkdir()
remote = (root / "device/Documents/Trainlog") if mode == "mtp" else transport
remote.mkdir(parents=True, exist_ok=True)
release = root / "release-android"
android_log = (root / "android.log").open("wb")
android_env = os.environ.copy()
android_env.update(
{
"JAVA_HOME": "/usr/lib/jvm/java-17-openjdk",
"TRAINLOG_SYNC_TRANSPORT_ROOT": str(remote),
"TRAINLOG_SYNC_TEST_RELEASE_FILE": str(release),
}
)
android = subprocess.Popen(
[
"./gradlew",
"--no-daemon",
"testDebugUnitTest",
"--tests",
"com.labfytools.trainlog.data.SyncDeploymentConversationTest",
"--rerun-tasks",
],
cwd=ROOT / "android",
env=android_env,
stdout=android_log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
peer_file = await_condition(
lambda: (
(remote / "android-peer-v1.json")
if (remote / "android-peer-v1.json").is_file()
else None
),
120,
)
except Exception as error:
if android.poll() is None:
os.killpg(android.pid, signal.SIGTERM)
android.wait(timeout=10)
android_log.close()
raise AssertionError(
f"Android bridge did not advertise:\n{(root/'android.log').read_text(errors='replace')}"
) from error
peer = json.loads(peer_file.read_text())
config.write_text(
json.dumps(
{
"format": "trainlog-sync-orchestrator-config",
"version": 1,
"enabled": True,
"mode": mode,
"expected_peer_id": peer["peer_id"],
"transport_root": str(transport),
"owned_root": str(owned),
"timeout_seconds": 60,
}
)
)
with socket.socket() as reserved:
reserved.bind(("127.0.0.1", 0))
port = reserved.getsockname()[1]
env = os.environ.copy()
env.update(
{
"XDG_DATA_HOME": str(xdg),
"XDG_CONFIG_HOME": str(root / "config"),
"XDG_CACHE_HOME": str(root / "cache"),
"TRAINLOG_SYNC_GENERATION_CONFIG": str(config),
}
)
if mode == "mtp":
env.update(
{
"TRAINLOG_SYNC_MTP_ADAPTER": str(
ROOT / "build/tui/generation-mtp-adapter-double"
),
"TRAINLOG_MTP_DOUBLE_ROOT": str(root / "device"),
}
)
server = subprocess.Popen(
[str(ROOT / "build/tui/trainlog"), "--web", "--port", str(port)],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
driver = None
try:
for _ in range(100):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", port)) == 0:
break
time.sleep(0.05)
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
driver.get(f"http://127.0.0.1:{port}/")
wait = WebDriverWait(driver, 15)
button = wait.until(
lambda d: d.find_element(By.XPATH, "//button[normalize-space()='Synchroniser']")
)
wait.until(lambda _: button.is_enabled())
button.click()
state_path = Path(str(xdg / "trainlog" / "trainlog.db") + ".sync-run.json")
observed = await_condition(
lambda: (
json.loads(state_path.read_text())
if state_path.is_file()
and json.loads(state_path.read_text()).get("phase")
in ("local_import_committed", "failed", "interrupted")
else None
),
70,
)
self.assertEqual(
observed["phase"],
"local_import_committed",
f"state={observed}\nandroid={(root/'android.log').read_text(errors='replace')}",
)
committed = observed
self.assertEqual(committed["result"], "running")
with sqlite3.connect(xdg / "trainlog" / "trainlog.db") as database:
self.assertEqual(
database.execute("SELECT COUNT(*) FROM execution_drafts").fetchone()[0], 1
)
release.touch()
final = await_condition(
lambda: (
json.loads(state_path.read_text())
if json.loads(state_path.read_text()).get("phase")
in ("completed", "failed", "interrupted")
else None
)
)
self.assertEqual(
final["phase"],
"completed",
f"state={final}\nandroid={((root/'android.log').read_text(errors='replace'))}",
)
wait.until(lambda d: "Synchronisation confirmée" in d.page_source)
summary = driver.find_element(
By.XPATH, "//summary[contains(.,'Synchronisation confirmée')]"
)
summary.click()
wait.until(
lambda d: "Brouillons reçus" in d.page_source and "active" in d.page_source
)
driver.refresh()
wait.until(lambda d: "Synchronisation confirmée" in d.page_source)
state = json.loads(
Path(str(xdg / "trainlog" / "trainlog.db") + ".sync-run.json").read_text()
)
self.assertEqual(state["phase"], "completed")
self.assertEqual(
android.wait(timeout=30), 0, (root / "android.log").read_text(errors="replace")
)
finally:
if driver is not None:
driver.quit()
os.killpg(server.pid, signal.SIGTERM)
server.wait(timeout=10)
if android.poll() is None:
os.killpg(android.pid, signal.SIGTERM)
android.wait(timeout=10)
android_log.close()
if __name__ == "__main__":
unittest.main()

View file

@ -12,26 +12,70 @@ class BackupBridgeActivity : Activity() {
private lateinit var repository: TrainlogRepository private lateinit var repository: TrainlogRepository
private lateinit var status: TextView private lateinit var status: TextView
private val service by lazy { AndroidBackupService(applicationContext) } private val service by lazy { AndroidBackupService(applicationContext) }
override fun onCreate(state: Bundle?) { override fun onCreate(state: Bundle?) {
super.onCreate(state); repository = TrainlogRepository(applicationContext) super.onCreate(state)
status = TextView(this).apply { text = "Plaintext local backup. Keep the exported file private." } repository = TrainlogRepository(applicationContext)
val backup = Button(this).apply { text = "Create complete backup"; setOnClickListener { status =
startActivityForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).setType("application/zip") TextView(this).apply {
.putExtra(Intent.EXTRA_TITLE, "trainlog-backup.tlbackup").addCategory(Intent.CATEGORY_OPENABLE), 1) } } text = "Plaintext local backup. Keep the exported file private."
val restore = Button(this).apply { text = "Verify and restore backup"; setOnClickListener { }
startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).setType("application/zip") val backup =
.addCategory(Intent.CATEGORY_OPENABLE), 2) } } Button(this).apply {
setContentView(LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; addView(status); addView(backup); addView(restore) }) text = "Create complete backup"
setOnClickListener {
startActivityForResult(
Intent(Intent.ACTION_CREATE_DOCUMENT)
.setType("application/zip")
.putExtra(Intent.EXTRA_TITLE, "trainlog-backup.tlbackup")
.addCategory(Intent.CATEGORY_OPENABLE),
1,
)
}
}
val restore =
Button(this).apply {
text = "Verify and restore backup"
setOnClickListener {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT)
.setType("application/zip")
.addCategory(Intent.CATEGORY_OPENABLE),
2,
)
}
}
setContentView(
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(status)
addView(backup)
addView(restore)
}
)
} }
override fun onActivityResult(request: Int, result: Int, data: Intent?) { override fun onActivityResult(request: Int, result: Int, data: Intent?) {
super.onActivityResult(request, result, data); if (result != RESULT_OK || data?.data == null) return super.onActivityResult(request, result, data)
if (request == 1) contentResolver.openOutputStream(data.data!!, "w")!!.use { if (result != RESULT_OK || data?.data == null) return
status.text = service.create(repository, it).toString() if (request == 1)
} else if (request == 2) contentResolver.openInputStream(data.data!!)!!.use { contentResolver.openOutputStream(data.data!!, "w")!!.use {
val verified = service.verify(it).getOrElse { error -> status.text = error.message; return } status.text = service.create(repository, it).toString()
status.text = service.restore(repository, verified).toString() }
recreate() else if (request == 2)
} contentResolver.openInputStream(data.data!!)!!.use {
val verified =
service.verify(it).getOrElse { error ->
status.text = error.message
return
}
status.text = service.restore(repository, verified).toString()
recreate()
}
}
override fun onDestroy() {
repository.close()
super.onDestroy()
} }
override fun onDestroy() { repository.close(); super.onDestroy() }
} }

View file

@ -19,26 +19,75 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class) @Config(sdk=[35]) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BackupBridgeServiceTest { class BackupBridgeServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext() private val context: Context = ApplicationProvider.getApplicationContext()
@Test fun schemaSeventeenWalDraftRoundTripsWithoutMigration() {
val name="bridge-${UUID.randomUUID()}.db";var repository=TrainlogRepository(context,name);val service=AndroidBackupService(context) @Test
fun schemaSeventeenWalDraftRoundTripsWithoutMigration() {
val name = "bridge-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
val service = AndroidBackupService(context)
try { try {
val exercise=(repository.createExercise(NewExerciseProfile("Bridge",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise val exercise =
assertEquals(ActiveDraftMutationResult.Saved,repository.saveActiveSessionDraft(ActiveSessionDraft( (repository.createExercise(
exercises=listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=7,weightKg=20.0)))), NewExerciseProfile("Bridge", RecordingMode.SETS, TrackingMode.REPS, 0)
form=SessionDraftForm(selectedExercise=exercise,weightText="20,")))) ) as CreateExerciseResult.Created)
context.getDatabasePath(name).let { android.database.sqlite.SQLiteDatabase.openDatabase(it.path,null,android.database.sqlite.SQLiteDatabase.OPEN_READWRITE).use { db -> .exercise
db.enableWriteAheadLogging();db.execSQL("UPDATE active_session_draft SET weight_text='21,' WHERE id=1") assertEquals(
assertEquals(17,db.version) ActiveDraftMutationResult.Saved,
} } repository.saveActiveSessionDraft(
val bytes=ByteArrayOutputStream();assertTrue(service.create(repository,bytes) is AndroidBackupResult.Success) ActiveSessionDraft(
System.getenv("TRAINLOG_BRIDGE_BACKUP_OUTPUT")?.let { java.io.File(it).writeBytes(bytes.toByteArray()) } exercises =
val verified=service.verify(ByteArrayInputStream(bytes.toByteArray())).getOrThrow() listOf(
assertTrue(service.restore(repository,verified,name) is AndroidBackupResult.Success) SessionExerciseDraft(
repository=TrainlogRepository(context,name);assertEquals("21,",(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft.form.weightText) exercise = exercise,
context.getDatabasePath(name).let { android.database.sqlite.SQLiteDatabase.openDatabase(it.path,null,android.database.sqlite.SQLiteDatabase.OPEN_READONLY).use { db -> assertEquals(17,db.version) } } sets = listOf(SessionSetDraft(reps = 7, weightKg = 20.0)),
} finally {repository.close();context.deleteDatabase(name)} )
),
form = SessionDraftForm(selectedExercise = exercise, weightText = "20,"),
)
),
)
context.getDatabasePath(name).let {
android.database.sqlite.SQLiteDatabase.openDatabase(
it.path,
null,
android.database.sqlite.SQLiteDatabase.OPEN_READWRITE,
)
.use { db ->
db.enableWriteAheadLogging()
db.execSQL("UPDATE active_session_draft SET weight_text='21,' WHERE id=1")
assertEquals(17, db.version)
}
}
val bytes = ByteArrayOutputStream()
assertTrue(service.create(repository, bytes) is AndroidBackupResult.Success)
System.getenv("TRAINLOG_BRIDGE_BACKUP_OUTPUT")?.let {
java.io.File(it).writeBytes(bytes.toByteArray())
}
val verified = service.verify(ByteArrayInputStream(bytes.toByteArray())).getOrThrow()
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
repository = TrainlogRepository(context, name)
assertEquals(
"21,",
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
.draft
.form
.weightText,
)
context.getDatabasePath(name).let {
android.database.sqlite.SQLiteDatabase.openDatabase(
it.path,
null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
)
.use { db -> assertEquals(17, db.version) }
}
} finally {
repository.close()
context.deleteDatabase(name)
}
} }
} }

View file

@ -1,42 +1,112 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Materialize and build the non-migrating schema-v17 Android backup bridge.""" """Materialize and build the non-migrating schema-v17 Android backup bridge."""
import argparse, io, os, shutil, subprocess, tarfile import argparse, io, os, shutil, subprocess, tarfile
from pathlib import Path from pathlib import Path
ROOT=Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
BASE="77c15172e1401b91b8e2537c4b8548095ed50053" BASE = "77c15172e1401b91b8e2537c4b8548095ed50053"
def replace(path: Path, old: str, new: str):
value = path.read_text()
if value.count(old) != 1:
raise RuntimeError(f"bridge patch anchor mismatch: {path}: {old[:40]}")
path.write_text(value.replace(old, new))
def replace(path:Path,old:str,new:str):
value=path.read_text()
if value.count(old)!=1: raise RuntimeError(f"bridge patch anchor mismatch: {path}: {old[:40]}")
path.write_text(value.replace(old,new))
def main(): def main():
parser=argparse.ArgumentParser();parser.add_argument("--output",required=True,type=Path);parser.add_argument("--jdk",default="/usr/lib/jvm/java-17-openjdk") parser = argparse.ArgumentParser()
args=parser.parse_args() parser.add_argument("--output", required=True, type=Path)
if args.output.exists(): raise RuntimeError("bridge output already exists") parser.add_argument("--jdk", default="/usr/lib/jvm/java-17-openjdk")
args = parser.parse_args()
if args.output.exists():
raise RuntimeError("bridge output already exists")
args.output.mkdir(parents=True) args.output.mkdir(parents=True)
raw=subprocess.check_output(["git","archive","--format=tar",BASE,"android","catalog"],cwd=ROOT) raw = subprocess.check_output(
["git", "archive", "--format=tar", BASE, "android", "catalog"], cwd=ROOT
)
with tarfile.open(fileobj=io.BytesIO(raw)) as archive: with tarfile.open(fileobj=io.BytesIO(raw)) as archive:
for member in archive.getmembers(): for member in archive.getmembers():
target=(args.output/member.name).resolve() target = (args.output / member.name).resolve()
if args.output.resolve() not in target.parents: raise RuntimeError("unsafe archive member") if args.output.resolve() not in target.parents:
archive.extractall(args.output,filter="data") raise RuntimeError("unsafe archive member")
android=args.output/"android" archive.extractall(args.output, filter="data")
repository=android/"app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt" android = args.output / "android"
replace(repository,"import java.text.Normalizer","import java.text.Normalizer\nimport java.io.File") repository = android / "app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt"
replace(repository," fun close() {\n database.close()\n }"," fun close() {\n database.close()\n }\n\n internal fun backupDatabaseTo(destination: File) {\n require(!destination.exists())\n val escaped = destination.absolutePath.replace(\"'\", \"''\")\n database.writableDatabase.execSQL(\"VACUUM INTO '$escaped'\")\n }") replace(
shutil.copy2(ROOT/"android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt",android/"app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt") repository,
shutil.copy2(ROOT/"tools/android_backup_bridge/BackupBridgeActivity.kt",android/"app/src/main/java/com/labfytools/trainlog/data/BackupBridgeActivity.kt") "import java.text.Normalizer",
shutil.copy2(ROOT/"tools/android_backup_bridge/BackupBridgeServiceTest.kt",android/"app/src/test/java/com/labfytools/trainlog/data/BackupBridgeServiceTest.kt") "import java.text.Normalizer\nimport java.io.File",
manifest=android/"app/src/main/AndroidManifest.xml" )
replace(manifest," <activity\n android:name=\".MainActivity\"", " <activity android:name=\".data.BackupBridgeActivity\" android:exported=\"true\" android:label=\"Trainlog Backup\">\n <intent-filter><action android:name=\"android.intent.action.MAIN\"/><category android:name=\"android.intent.category.LAUNCHER\"/></intent-filter>\n </activity>\n <activity\n android:name=\".MainActivity\"") replace(
gradle=android/"app/build.gradle.kts" repository,
replace(gradle,"versionCode = 3","versionCode = providers.environmentVariable(\"TRAINLOG_BRIDGE_VERSION_CODE\").orNull?.toInt() ?: 4") " fun close() {\n database.close()\n }",
env=os.environ.copy();env["JAVA_HOME"]=args.jdk;env["TRAINLOG_BRIDGE_VERSION_CODE"]=env.get("TRAINLOG_BRIDGE_VERSION_CODE","4");env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"]=str(args.output/"schema17-synthetic.tlbackup") ' fun close() {\n database.close()\n }\n\n internal fun backupDatabaseTo(destination: File) {\n require(!destination.exists())\n val escaped = destination.absolutePath.replace("\'", "\'\'")\n database.writableDatabase.execSQL("VACUUM INTO \'$escaped\'")\n }',
subprocess.run([str(android/"gradlew"),"--no-daemon",":app:testDebugUnitTest","--tests","com.labfytools.trainlog.data.BackupBridgeServiceTest",":app:assembleDebug"],cwd=android,env=env,check=True) )
migration_env=os.environ.copy();migration_env["JAVA_HOME"]=args.jdk;migration_env["TRAINLOG_BRIDGE_BACKUP_INPUT"]=env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"] shutil.copy2(
subprocess.run([str(ROOT/"android/gradlew"),"--no-daemon",":app:testDebugUnitTest","--tests","com.labfytools.trainlog.data.BridgeBackupMigrationTest","--rerun-tasks"],cwd=ROOT/"android",env=migration_env,check=True) ROOT / "android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt",
print(f"BRIDGE_BASE={BASE}");print(f"BRIDGE_RECIPE={subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()}") android / "app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt",
)
shutil.copy2(
ROOT / "tools/android_backup_bridge/BackupBridgeActivity.kt",
android / "app/src/main/java/com/labfytools/trainlog/data/BackupBridgeActivity.kt",
)
shutil.copy2(
ROOT / "tools/android_backup_bridge/BackupBridgeServiceTest.kt",
android / "app/src/test/java/com/labfytools/trainlog/data/BackupBridgeServiceTest.kt",
)
manifest = android / "app/src/main/AndroidManifest.xml"
replace(
manifest,
' <activity\n android:name=".MainActivity"',
' <activity android:name=".data.BackupBridgeActivity" android:exported="true" android:label="Trainlog Backup">\n <intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>\n </activity>\n <activity\n android:name=".MainActivity"',
)
gradle = android / "app/build.gradle.kts"
replace(
gradle,
"versionCode = 3",
'versionCode = providers.environmentVariable("TRAINLOG_BRIDGE_VERSION_CODE").orNull?.toInt() ?: 4',
)
env = os.environ.copy()
env["JAVA_HOME"] = args.jdk
env["TRAINLOG_BRIDGE_VERSION_CODE"] = env.get("TRAINLOG_BRIDGE_VERSION_CODE", "4")
env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"] = str(args.output / "schema17-synthetic.tlbackup")
subprocess.run(
[
str(android / "gradlew"),
"--no-daemon",
":app:testDebugUnitTest",
"--tests",
"com.labfytools.trainlog.data.BackupBridgeServiceTest",
":app:assembleDebug",
],
cwd=android,
env=env,
check=True,
)
migration_env = os.environ.copy()
migration_env["JAVA_HOME"] = args.jdk
migration_env["TRAINLOG_BRIDGE_BACKUP_INPUT"] = env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"]
subprocess.run(
[
str(ROOT / "android/gradlew"),
"--no-daemon",
":app:testDebugUnitTest",
"--tests",
"com.labfytools.trainlog.data.BridgeBackupMigrationTest",
"--rerun-tasks",
],
cwd=ROOT / "android",
env=migration_env,
check=True,
)
print(f"BRIDGE_BASE={BASE}")
print(
f"BRIDGE_RECIPE={subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()}"
)
print(f"BRIDGE_APK={android/'app/build/outputs/apk/debug/app-debug.apk'}") print(f"BRIDGE_APK={android/'app/build/outputs/apk/debug/app-debug.apk'}")
if __name__=="__main__": main()
if __name__ == "__main__":
main()

View file

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Stage a reproducible, non-secret Trainlog desktop synchronization candidate.""" """Stage a reproducible, non-secret Trainlog desktop synchronization candidate."""
import argparse import argparse
import hashlib import hashlib
import json import json
@ -10,15 +11,25 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
RUNTIME_TOOLS = [ RUNTIME_TOOLS = [
"sync_orchestrator.py", "sync_peer_worker.py", "sync_generation_exchange.py", "sync_orchestrator.py",
"trainlog_sqlite.py", "validate_json.py", "import_mobile_export.py", "sync_peer_worker.py",
"import_exercise_aliases.py", "import_equipment_definitions.py", "sync_generation_exchange.py",
"import_equipment_associations.py", "import_exercise_body_zones.py", "trainlog_sqlite.py",
"import_training_feedback.py", "execution_draft_exchange.py", "validate_json.py",
"causal_delete_exchange.py", "import_exercise_profile_state.py", "import_mobile_export.py",
"import_exercise_aliases.py",
"import_equipment_definitions.py",
"import_equipment_associations.py",
"import_exercise_body_zones.py",
"import_training_feedback.py",
"execution_draft_exchange.py",
"causal_delete_exchange.py",
"import_exercise_profile_state.py",
"export_exercise_profile_state.py", "export_exercise_profile_state.py",
"export_pc_mobile.py", "export_equipment_definitions.py", "export_pc_mobile.py",
"export_exercise_body_zones.py", "export_training_feedback.py", "export_equipment_definitions.py",
"export_exercise_body_zones.py",
"export_training_feedback.py",
] ]
@ -54,20 +65,53 @@ def main() -> int:
for source in sorted((ROOT / "catalog").glob("*.json")): for source in sorted((ROOT / "catalog").glob("*.json")):
shutil.copy2(source, args.output / "catalog" / source.name) shutil.copy2(source, args.output / "catalog" / source.name)
launcher = args.output / "bin/trainlog" launcher = args.output / "bin/trainlog"
launcher.write_text("#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- \"$(dirname -- \"$0\")/..\" && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR=\"$root/tools\"\nexport TRAINLOG_SYNC_MTP_ADAPTER=\"$root/libexec/trainlog-generation-mtp-adapter\"\nexec \"$root/libexec/trainlog\" \"$@\"\n") launcher.write_text(
'#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR="$root/tools"\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec "$root/libexec/trainlog" "$@"\n'
)
files = sorted(path for path in args.output.rglob("*") if path.is_file()) files = sorted(path for path in args.output.rglob("*") if path.is_file())
inventory = { inventory = {
"format": "trainlog-sync-candidate-inventory", "version": 1, "format": "trainlog-sync-candidate-inventory",
"source_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(), "version": 1,
"product_version": "0.1.2", "desktop_schema": 21, "android_schema": 20, "source_commit": subprocess.check_output(
"protocols": ["mobile-export-v3", "mobile-history-v4", "causal-delete-v1", ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
"execution-draft-v1", "generation-manifest-v1", "generation-ack-v1"], ).strip(),
"entry_points": ["bin/trainlog", "libexec/trainlog-generation-mtp-adapter", "product_version": "0.1.2",
"tools/sync_orchestrator.py"], "desktop_schema": 21,
"runtime_dependencies": ["python>=3.11", "sqlite3", "utf8proc", "libuuid", "libudev", "libmtp", "notcurses"], "android_schema": 20,
"files": [{"path": str(path.relative_to(args.output)), "sha256": digest(path), "size": path.stat().st_size} for path in files], "protocols": [
"mobile-export-v3",
"mobile-history-v4",
"causal-delete-v1",
"execution-draft-v1",
"generation-manifest-v1",
"generation-ack-v1",
],
"entry_points": [
"bin/trainlog",
"libexec/trainlog-generation-mtp-adapter",
"tools/sync_orchestrator.py",
],
"runtime_dependencies": [
"python>=3.11",
"sqlite3",
"utf8proc",
"libuuid",
"libudev",
"libmtp",
"notcurses",
],
"files": [
{
"path": str(path.relative_to(args.output)),
"sha256": digest(path),
"size": path.stat().st_size,
}
for path in files
],
} }
(args.output / "candidate-inventory.json").write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n") (args.output / "candidate-inventory.json").write_text(
json.dumps(inventory, indent=2, sort_keys=True) + "\n"
)
os.chmod(args.output / "bin/trainlog", 0o755) os.chmod(args.output / "bin/trainlog", 0o755)
os.chmod(args.output / "libexec/trainlog", 0o755) os.chmod(args.output / "libexec/trainlog", 0o755)
os.chmod(args.output / "libexec/trainlog-generation-mtp-adapter", 0o755) os.chmod(args.output / "libexec/trainlog-generation-mtp-adapter", 0o755)

View file

@ -5,6 +5,7 @@ The HTTP adapter starts this executable with fixed arguments. Peer transport is
selected only by a trusted local configuration file; request JSON can never selected only by a trusted local configuration file; request JSON can never
choose commands, paths, databases, capabilities, or causal policy. choose commands, paths, databases, capabilities, or causal policy.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@ -25,9 +26,19 @@ from pathlib import Path
MAX_CONFIG = 16 * 1024 MAX_CONFIG = 16 * 1024
MAX_REPORT = 64 * 1024 MAX_REPORT = 64 * 1024
MAX_DIAGNOSTIC = 1024 MAX_DIAGNOSTIC = 1024
PHASES = {"requested", "waiting_android_publication", "running", PHASES = {
"local_import_committed", "published", "waiting_acknowledgement", "requested",
"peer_consumed", "completed", "failed", "interrupted", "explicitly_degraded"} "waiting_android_publication",
"running",
"local_import_committed",
"published",
"waiting_acknowledgement",
"peer_consumed",
"completed",
"failed",
"interrupted",
"explicitly_degraded",
}
class OrchestratorInterrupted(RuntimeError): class OrchestratorInterrupted(RuntimeError):
@ -40,8 +51,10 @@ _owned_process_group: int | None = None
def _forward_termination(signum: int, _frame: object) -> None: def _forward_termination(signum: int, _frame: object) -> None:
"""Forward shutdown only to the exactly-owned helper process group.""" """Forward shutdown only to the exactly-owned helper process group."""
if _owned_process_group is not None: if _owned_process_group is not None:
try: os.killpg(_owned_process_group, signal.SIGTERM) try:
except ProcessLookupError: pass os.killpg(_owned_process_group, signal.SIGTERM)
except ProcessLookupError:
pass
raise OrchestratorInterrupted(f"orchestrator received signal {signum}") raise OrchestratorInterrupted(f"orchestrator received signal {signum}")
@ -54,8 +67,9 @@ def _child_setup() -> None:
def canonical(value: object) -> bytes: def canonical(value: object) -> bytes:
return (json.dumps(value, ensure_ascii=False, sort_keys=True, return (
separators=(",", ":")) + "\n").encode() json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
).encode()
def atomic_write(path: Path, value: dict) -> None: def atomic_write(path: Path, value: dict) -> None:
@ -67,97 +81,176 @@ def atomic_write(path: Path, value: dict) -> None:
try: try:
os.fchmod(fd, 0o600) os.fchmod(fd, 0o600)
with os.fdopen(fd, "wb") as stream: with os.fdopen(fd, "wb") as stream:
stream.write(raw); stream.flush(); os.fsync(stream.fileno()) stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path) os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try: os.fsync(directory) try:
finally: os.close(directory) os.fsync(directory)
finally:
os.close(directory)
finally: finally:
try: os.unlink(temporary) try:
except FileNotFoundError: pass os.unlink(temporary)
except FileNotFoundError:
pass
def load_json(path: Path, limit: int) -> dict: def load_json(path: Path, limit: int) -> dict:
with path.open("rb") as stream: with path.open("rb") as stream:
raw = stream.read(limit + 1) raw = stream.read(limit + 1)
if len(raw) > limit: raise RuntimeError(f"{path.name} exceeds its bound") if len(raw) > limit:
raise RuntimeError(f"{path.name} exceeds its bound")
value = json.loads(raw.decode("utf-8", "strict")) value = json.loads(raw.decode("utf-8", "strict"))
if not isinstance(value, dict): raise RuntimeError(f"{path.name} must contain an object") if not isinstance(value, dict):
raise RuntimeError(f"{path.name} must contain an object")
return value return value
def update(state_path: Path, state: dict, phase: str, **fields: object) -> None: def update(state_path: Path, state: dict, phase: str, **fields: object) -> None:
if phase not in PHASES: raise RuntimeError("invalid run phase") if phase not in PHASES:
state.update(fields); state["phase"] = phase raise RuntimeError("invalid run phase")
state.update(fields)
state["phase"] = phase
state["progress_revision"] = int(state.get("progress_revision", 0)) + 1 state["progress_revision"] = int(state.get("progress_revision", 0)) + 1
state["updated_at"] = datetime.now(timezone.utc).isoformat() state["updated_at"] = datetime.now(timezone.utc).isoformat()
atomic_write(state_path, state) atomic_write(state_path, state)
def validate_result(value: dict, run_id: str) -> dict: def validate_result(value: dict, run_id: str) -> dict:
required = {"format", "version", "run_id", "producer_peer_id", "consumer_peer_id", required = {
"inbound_generation_id", "outbound_generation_id", "manifest_sha256", "format",
"result", "sessions_reconciled", "domains", "drafts", "ai_midpoint", "ai_post_sync"} "version",
if set(value) != required or value.get("format") != "trainlog-sync-worker-report" or value.get("version") != 1: "run_id",
"producer_peer_id",
"consumer_peer_id",
"inbound_generation_id",
"outbound_generation_id",
"manifest_sha256",
"result",
"sessions_reconciled",
"domains",
"drafts",
"ai_midpoint",
"ai_post_sync",
}
if (
set(value) != required
or value.get("format") != "trainlog-sync-worker-report"
or value.get("version") != 1
):
raise RuntimeError("malformed peer worker report") raise RuntimeError("malformed peer worker report")
if value.get("run_id") != run_id or value.get("result") != "completed": if value.get("run_id") != run_id or value.get("result") != "completed":
raise RuntimeError("peer worker did not complete the correlated run") raise RuntimeError("peer worker did not complete the correlated run")
for identity, prefix in ((value["producer_peer_id"], "peer_"), for identity, prefix in (
(value["consumer_peer_id"], "peer_"), (value["producer_peer_id"], "peer_"),
(value["inbound_generation_id"], "gen_"), (value["consumer_peer_id"], "peer_"),
(value["outbound_generation_id"], "gen_")): (value["inbound_generation_id"], "gen_"),
if not isinstance(identity, str) or not re.fullmatch(re.escape(prefix)+r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",identity): (value["outbound_generation_id"], "gen_"),
):
if not isinstance(identity, str) or not re.fullmatch(
re.escape(prefix)
+ r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
identity,
):
raise RuntimeError("invalid worker identity") raise RuntimeError("invalid worker identity")
if type(value["sessions_reconciled"]) is not int or value["sessions_reconciled"] < 0: if type(value["sessions_reconciled"]) is not int or value["sessions_reconciled"] < 0:
raise RuntimeError("invalid sessions_reconciled") raise RuntimeError("invalid sessions_reconciled")
if not isinstance(value["domains"], dict) or not all(isinstance(k, str) and isinstance(v, bool) for k, v in value["domains"].items()): if not isinstance(value["domains"], dict) or not all(
isinstance(k, str) and isinstance(v, bool) for k, v in value["domains"].items()
):
raise RuntimeError("invalid domain coverage") raise RuntimeError("invalid domain coverage")
if not isinstance(value["drafts"], list) or len(value["drafts"]) > 32: if not isinstance(value["drafts"], list) or len(value["drafts"]) > 32:
raise RuntimeError("invalid draft summary") raise RuntimeError("invalid draft summary")
return value return value
def read_worker(child: subprocess.Popen[bytes], state_path: Path, state: dict, def read_worker(
run_id: str, timeout: int) -> tuple[dict, bytes]: child: subprocess.Popen[bytes], state_path: Path, state: dict, run_id: str, timeout: int
) -> tuple[dict, bytes]:
selector = selectors.DefaultSelector() selector = selectors.DefaultSelector()
assert child.stdout is not None and child.stderr is not None assert child.stdout is not None and child.stderr is not None
selector.register(child.stdout, selectors.EVENT_READ, "stdout") selector.register(child.stdout, selectors.EVENT_READ, "stdout")
selector.register(child.stderr, selectors.EVENT_READ, "stderr") selector.register(child.stderr, selectors.EVENT_READ, "stderr")
buffers = {"stdout": bytearray(), "stderr": bytearray()} buffers = {"stdout": bytearray(), "stderr": bytearray()}
line_buffer = bytearray(); final: dict | None = None line_buffer = bytearray()
final: dict | None = None
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
allowed = ["waiting_android_publication","running","local_import_committed", allowed = [
"published","waiting_acknowledgement","peer_consumed"] "waiting_android_publication",
"running",
"local_import_committed",
"published",
"waiting_acknowledgement",
"peer_consumed",
]
last_index = -1 last_index = -1
while selector.get_map(): while selector.get_map():
remaining = deadline - time.monotonic() remaining = deadline - time.monotonic()
if remaining <= 0: raise RuntimeError("peer worker timed out") if remaining <= 0:
for key, _ in selector.select(min(remaining, .25)): raise RuntimeError("peer worker timed out")
for key, _ in selector.select(min(remaining, 0.25)):
chunk = os.read(key.fileobj.fileno(), 4096) chunk = os.read(key.fileobj.fileno(), 4096)
if not chunk: selector.unregister(key.fileobj); continue if not chunk:
selector.unregister(key.fileobj)
continue
target = buffers[key.data] target = buffers[key.data]
if len(target) + len(chunk) > MAX_REPORT: raise RuntimeError(f"peer worker {key.data} exceeds 64 KiB") if len(target) + len(chunk) > MAX_REPORT:
raise RuntimeError(f"peer worker {key.data} exceeds 64 KiB")
target.extend(chunk) target.extend(chunk)
if key.data == "stdout": if key.data == "stdout":
line_buffer.extend(chunk) line_buffer.extend(chunk)
while b"\n" in line_buffer: while b"\n" in line_buffer:
raw, _, rest = line_buffer.partition(b"\n"); line_buffer[:] = rest raw, _, rest = line_buffer.partition(b"\n")
if len(raw) > 8192: raise RuntimeError("progress frame exceeds 8 KiB") line_buffer[:] = rest
if len(raw) > 8192:
raise RuntimeError("progress frame exceeds 8 KiB")
value = json.loads(raw.decode("utf-8", "strict")) value = json.loads(raw.decode("utf-8", "strict"))
if value.get("format") == "trainlog-sync-progress": if value.get("format") == "trainlog-sync-progress":
if set(value)-{"format","version","run_id","phase","producer_peer_id","consumer_peer_id","inbound_generation_id","outbound_generation_id","manifest_sha256"} or value.get("version") != 1 or value.get("run_id") != run_id or value.get("phase") not in allowed: if (
set(value)
- {
"format",
"version",
"run_id",
"phase",
"producer_peer_id",
"consumer_peer_id",
"inbound_generation_id",
"outbound_generation_id",
"manifest_sha256",
}
or value.get("version") != 1
or value.get("run_id") != run_id
or value.get("phase") not in allowed
):
raise RuntimeError("invalid progress frame") raise RuntimeError("invalid progress frame")
index=allowed.index(value["phase"]) index = allowed.index(value["phase"])
if index < last_index: raise RuntimeError("progress phase regressed") if index < last_index:
last_index=index;update(state_path,state,value["phase"],**{k:v for k,v in value.items() if k not in {"format","version","run_id","phase"}}) raise RuntimeError("progress phase regressed")
else: final=value last_index = index
update(
state_path,
state,
value["phase"],
**{
k: v
for k, v in value.items()
if k not in {"format", "version", "run_id", "phase"}
},
)
else:
final = value
child.wait() child.wait()
if line_buffer.strip(): raise RuntimeError("unterminated worker frame") if line_buffer.strip():
raise RuntimeError("unterminated worker frame")
if child.returncode != 0: if child.returncode != 0:
detail=bytes(buffers["stderr"]).decode("utf-8","replace").strip()[:MAX_DIAGNOSTIC] detail = bytes(buffers["stderr"]).decode("utf-8", "replace").strip()[:MAX_DIAGNOSTIC]
raise RuntimeError(f"peer worker exited {child.returncode}: {detail}") raise RuntimeError(f"peer worker exited {child.returncode}: {detail}")
if final is None: raise RuntimeError("peer worker omitted final report") if final is None:
return validate_result(final,run_id), bytes(buffers["stderr"]) raise RuntimeError("peer worker omitted final report")
return validate_result(final, run_id), bytes(buffers["stderr"])
def run(args: argparse.Namespace) -> int: def run(args: argparse.Namespace) -> int:
@ -167,15 +260,33 @@ def run(args: argparse.Namespace) -> int:
config = load_json(config_path, MAX_CONFIG) config = load_json(config_path, MAX_CONFIG)
if state.get("run_id") != args.run_id or state.get("request_id") != args.request_id: if state.get("run_id") != args.run_id or state.get("request_id") != args.request_id:
raise RuntimeError("admission identity mismatch") raise RuntimeError("admission identity mismatch")
if set(config) != {"format", "version", "enabled", "mode", "expected_peer_id", "transport_root", "owned_root", "timeout_seconds"}: if set(config) != {
"format",
"version",
"enabled",
"mode",
"expected_peer_id",
"transport_root",
"owned_root",
"timeout_seconds",
}:
raise RuntimeError("invalid trusted sync configuration") raise RuntimeError("invalid trusted sync configuration")
if config["format"] != "trainlog-sync-orchestrator-config" or config["version"] != 1 or config["enabled"] is not True: if (
config["format"] != "trainlog-sync-orchestrator-config"
or config["version"] != 1
or config["enabled"] is not True
):
raise RuntimeError("full-generation synchronization is disabled") raise RuntimeError("full-generation synchronization is disabled")
if config["mode"] not in ("directory", "mtp"): if config["mode"] not in ("directory", "mtp"):
raise RuntimeError("invalid transport mode") raise RuntimeError("invalid transport mode")
for key in ("transport_root","owned_root"): 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") if not isinstance(config[key], str) or not Path(config[key]).is_absolute():
if not isinstance(config["expected_peer_id"],str) or not re.fullmatch(r"peer_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",config["expected_peer_id"]): raise RuntimeError("invalid expected peer identity") raise RuntimeError("trusted paths must be absolute")
if not isinstance(config["expected_peer_id"], str) or not re.fullmatch(
r"peer_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
config["expected_peer_id"],
):
raise RuntimeError("invalid expected peer identity")
timeout = config["timeout_seconds"] timeout = config["timeout_seconds"]
if not isinstance(timeout, int) or timeout < 1 or timeout > 900: if not isinstance(timeout, int) or timeout < 1 or timeout > 900:
raise RuntimeError("invalid worker timeout") raise RuntimeError("invalid worker timeout")
@ -184,39 +295,96 @@ def run(args: argparse.Namespace) -> int:
lock_path = Path(args.database).parent / "sync.lock" lock_path = Path(args.database).parent / "sync.lock"
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
try: try:
try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: except BlockingIOError:
update(state_path, state, "failed", result="conflict", diagnostic="Another synchronization owns the common lock", finished_at=datetime.now(timezone.utc).isoformat()) update(
state_path,
state,
"failed",
result="conflict",
diagnostic="Another synchronization owns the common lock",
finished_at=datetime.now(timezone.utc).isoformat(),
)
return 4 return 4
update(state_path, state, "running", result="running", diagnostic="") update(state_path, state, "running", result="running", diagnostic="")
environment = os.environ.copy() environment = os.environ.copy()
environment.update({"TRAINLOG_SYNC_RUN_ID": args.run_id, environment.update(
"TRAINLOG_SYNC_TRIGGER": state["trigger"], {
"TRAINLOG_SYNC_DATABASE": args.database}) "TRAINLOG_SYNC_RUN_ID": args.run_id,
command=[sys.executable,str(Path(__file__).with_name("sync_peer_worker.py")),"--database",args.database,"--transport-root",config["transport_root"],"--owned-root",config["owned_root"],"--run-id",args.run_id,"--expected-peer",config["expected_peer_id"],"--timeout",str(timeout),"--mode",config["mode"]] "TRAINLOG_SYNC_TRIGGER": state["trigger"],
"TRAINLOG_SYNC_DATABASE": args.database,
}
)
command = [
sys.executable,
str(Path(__file__).with_name("sync_peer_worker.py")),
"--database",
args.database,
"--transport-root",
config["transport_root"],
"--owned-root",
config["owned_root"],
"--run-id",
args.run_id,
"--expected-peer",
config["expected_peer_id"],
"--timeout",
str(timeout),
"--mode",
config["mode"],
]
if config["mode"] == "mtp": if config["mode"] == "mtp":
adapter = os.environ.get("TRAINLOG_SYNC_MTP_ADAPTER", adapter = os.environ.get(
str(Path(__file__).resolve().parents[1] / "build/tui/trainlog-generation-mtp-adapter")) "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 not Path(adapter).is_absolute() or not os.access(adapter, os.X_OK):
raise RuntimeError("fixed MTP adapter executable is unavailable") raise RuntimeError("fixed MTP adapter executable is unavailable")
command.extend(["--mtp-adapter", adapter]) command.extend(["--mtp-adapter", adapter])
child = subprocess.Popen(command, stdin=subprocess.DEVNULL, child = subprocess.Popen(
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=environment, command,
preexec_fn=_child_setup) stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=environment,
preexec_fn=_child_setup,
)
_owned_process_group = child.pid _owned_process_group = child.pid
try: result,_ = read_worker(child,state_path,state,args.run_id,timeout) try:
result, _ = read_worker(child, state_path, state, args.run_id, timeout)
except Exception: except Exception:
if child.poll() is None: if child.poll() is None:
os.killpg(child.pid,signal.SIGTERM) os.killpg(child.pid, signal.SIGTERM)
try: child.wait(5) try:
except subprocess.TimeoutExpired: os.killpg(child.pid,signal.SIGKILL);child.wait() child.wait(5)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
raise raise
finally: finally:
_owned_process_group = None _owned_process_group = None
update(state_path, state, "completed", result="completed", diagnostic="", update(
inbound_generation_id=result["inbound_generation_id"],outbound_generation_id=result["outbound_generation_id"],producer_peer_id=result["producer_peer_id"],consumer_peer_id=result["consumer_peer_id"],sessions_reconciled=result["sessions_reconciled"],domains=result["domains"],drafts=result["drafts"],manifest_sha256=result["manifest_sha256"], state_path,
ai_midpoint=result["ai_midpoint"], ai_post_sync=result["ai_post_sync"], state,
finished_at=datetime.now(timezone.utc).isoformat()) "completed",
result="completed",
diagnostic="",
inbound_generation_id=result["inbound_generation_id"],
outbound_generation_id=result["outbound_generation_id"],
producer_peer_id=result["producer_peer_id"],
consumer_peer_id=result["consumer_peer_id"],
sessions_reconciled=result["sessions_reconciled"],
domains=result["domains"],
drafts=result["drafts"],
manifest_sha256=result["manifest_sha256"],
ai_midpoint=result["ai_midpoint"],
ai_post_sync=result["ai_post_sync"],
finished_at=datetime.now(timezone.utc).isoformat(),
)
return 0 return 0
finally: finally:
os.close(lock_fd) os.close(lock_fd)
@ -224,21 +392,39 @@ def run(args: argparse.Namespace) -> int:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--database", required=True); parser.add_argument("--state", required=True) parser.add_argument("--database", required=True)
parser.add_argument("--config", required=True); parser.add_argument("--run-id", required=True) parser.add_argument("--state", required=True)
parser.add_argument("--config", required=True)
parser.add_argument("--run-id", required=True)
parser.add_argument("--request-id", required=True) parser.add_argument("--request-id", required=True)
args = parser.parse_args() args = parser.parse_args()
signal.signal(signal.SIGTERM, _forward_termination) signal.signal(signal.SIGTERM, _forward_termination)
signal.signal(signal.SIGINT, _forward_termination) signal.signal(signal.SIGINT, _forward_termination)
try: return run(args) try:
except (OSError, ValueError, json.JSONDecodeError, RuntimeError, subprocess.TimeoutExpired) as error: return run(args)
except (
OSError,
ValueError,
json.JSONDecodeError,
RuntimeError,
subprocess.TimeoutExpired,
) as error:
try: try:
path = Path(args.state); state = load_json(path, MAX_REPORT) path = Path(args.state)
state = load_json(path, MAX_REPORT)
phase = "interrupted" if isinstance(error, OrchestratorInterrupted) else "failed" phase = "interrupted" if isinstance(error, OrchestratorInterrupted) else "failed"
update(path, state, phase, result=phase, diagnostic=str(error)[:MAX_DIAGNOSTIC], update(
finished_at=datetime.now(timezone.utc).isoformat()) path,
except Exception: pass state,
phase,
result=phase,
diagnostic=str(error)[:MAX_DIAGNOSTIC],
finished_at=datetime.now(timezone.utc).isoformat(),
)
except Exception:
pass
return 2 return 2
if __name__ == "__main__": raise SystemExit(main()) if __name__ == "__main__":
raise SystemExit(main())

View file

@ -5,6 +5,7 @@ The directory is the object-level seam implemented by the MTP adapter in a
deployed bundle and directly supplied by isolated tests. Business documents are deployed bundle and directly supplied by isolated tests. Business documents are
always produced and consumed by the existing generation services. always produced and consumed by the existing generation services.
""" """
from __future__ import annotations from __future__ import annotations
import argparse, hashlib, json, os, sys, time, uuid import argparse, hashlib, json, os, sys, time, uuid
from contextlib import closing, redirect_stdout from contextlib import closing, redirect_stdout
@ -13,107 +14,266 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import sync_generation_exchange as generation import sync_generation_exchange as generation
CAPS={"generation-manifest-v1","generation-ack-v1","causal-delete-v1","mobile-history-v4","execution-draft-v1"} CAPS = {
"generation-manifest-v1",
"generation-ack-v1",
"causal-delete-v1",
"mobile-history-v4",
"execution-draft-v1",
}
def emit(run_id:str,phase:str,**values:object)->None:
value={"format":"trainlog-sync-progress","version":1,"run_id":run_id,"phase":phase}|values
print(json.dumps(value,sort_keys=True,separators=(",",":")),flush=True)
def bounded(path:Path,limit:int=65536)->bytes: def emit(run_id: str, phase: str, **values: object) -> None:
value = {
"format": "trainlog-sync-progress",
"version": 1,
"run_id": run_id,
"phase": phase,
} | values
print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True)
def bounded(path: Path, limit: int = 65536) -> bytes:
with path.open("rb") as stream: with path.open("rb") as stream:
raw=stream.read(limit+1) raw = stream.read(limit + 1)
if len(raw)>limit: raise RuntimeError(f"{path.name} exceeds bound") if len(raw) > limit:
raise RuntimeError(f"{path.name} exceeds bound")
return raw return raw
def publish_json(path:Path,value:dict)->None:
"""Publish a coordination object only after all bytes are durable."""
temporary=path.with_name("."+path.name+".tmp-"+str(os.getpid()))
with temporary.open("wb") as stream:
stream.write(generation.canonical(value));stream.flush();os.fsync(stream.fileno())
os.chmod(temporary,0o600);os.replace(temporary,path)
def run_adapter(adapter:Path,operation:str,peer:str,root:Path,deadline:float,allow_missing_peer:bool=False)->bool: def publish_json(path: Path, value: dict) -> None:
remaining=max(1,int(deadline-time.monotonic())) """Publish a coordination object only after all bytes are durable."""
result=__import__("subprocess").run([str(adapter),operation,peer,str(root)],stdin=__import__("subprocess").DEVNULL, temporary = path.with_name("." + path.name + ".tmp-" + str(os.getpid()))
capture_output=True,timeout=min(remaining,30),check=False) with temporary.open("wb") as stream:
if result.returncode!=0: stream.write(generation.canonical(value))
diagnostic=(result.stderr.decode("utf-8","replace")[:1024] or "MTP adapter failed").strip() stream.flush()
if allow_missing_peer and "expected peer was not found" in diagnostic: return False os.fsync(stream.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
def run_adapter(
adapter: Path,
operation: str,
peer: str,
root: Path,
deadline: float,
allow_missing_peer: bool = False,
) -> bool:
remaining = max(1, int(deadline - time.monotonic()))
result = __import__("subprocess").run(
[str(adapter), operation, peer, str(root)],
stdin=__import__("subprocess").DEVNULL,
capture_output=True,
timeout=min(remaining, 30),
check=False,
)
if result.returncode != 0:
diagnostic = (
result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed"
).strip()
if allow_missing_peer and "expected peer was not found" in diagnostic:
return False
raise RuntimeError(diagnostic) raise RuntimeError(diagnostic)
return True return True
def wait_file(path:Path,deadline:float,pump=None)->None:
def wait_file(path: Path, deadline: float, pump=None) -> None:
while not path.is_file(): while not path.is_file():
if time.monotonic()>=deadline: raise RuntimeError(f"timeout waiting for {path.name}") if time.monotonic() >= deadline:
if pump is not None: pump() raise RuntimeError(f"timeout waiting for {path.name}")
time.sleep(.05) if pump is not None:
pump()
time.sleep(0.05)
def wait_json(path:Path,deadline:float,predicate,pump=None)->dict:
def wait_json(path: Path, deadline: float, predicate, pump=None) -> dict:
while True: while True:
wait_file(path,deadline,pump) wait_file(path, deadline, pump)
try: value=json.loads(bounded(path)) try:
except (OSError,json.JSONDecodeError): value=None value = json.loads(bounded(path))
if isinstance(value,dict) and predicate(value): return value except (OSError, json.JSONDecodeError):
if time.monotonic()>=deadline: raise RuntimeError(f"timeout waiting for correlated {path.name}") value = None
if pump is not None: pump() if isinstance(value, dict) and predicate(value):
time.sleep(.05) return value
if time.monotonic() >= deadline:
raise RuntimeError(f"timeout waiting for correlated {path.name}")
if pump is not None:
pump()
time.sleep(0.05)
def load_peer(root:Path,expected:str)->dict:
value=json.loads(bounded(root/"android-peer-v1.json")) def load_peer(root: Path, expected: str) -> dict:
if set(value)!={"format","version","peer_id","capabilities"} or value["format"]!="trainlog-sync-peer" or type(value["version"]) is not int or value["version"]!=1 or value["peer_id"]!=expected or not isinstance(value["capabilities"],list) or not CAPS.issubset(set(value["capabilities"])): value = json.loads(bounded(root / "android-peer-v1.json"))
if (
set(value) != {"format", "version", "peer_id", "capabilities"}
or value["format"] != "trainlog-sync-peer"
or type(value["version"]) is not int
or value["version"] != 1
or value["peer_id"] != expected
or not isinstance(value["capabilities"], list)
or not CAPS.issubset(set(value["capabilities"]))
):
raise RuntimeError("incompatible or unpaired Android peer advertisement") raise RuntimeError("incompatible or unpaired Android peer advertisement")
return value return value
def main()->int:
parser=argparse.ArgumentParser();parser.add_argument("--database",required=True,type=Path);parser.add_argument("--transport-root",required=True,type=Path);parser.add_argument("--owned-root",required=True,type=Path);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("--mtp-adapter",type=Path) def main() -> int:
args=parser.parse_args();deadline=time.monotonic()+args.timeout parser = argparse.ArgumentParser()
pump=None parser.add_argument("--database", required=True, type=Path)
if args.mode=="mtp": parser.add_argument("--transport-root", required=True, type=Path)
if args.mtp_adapter is None: raise RuntimeError("MTP adapter is required") parser.add_argument("--owned-root", required=True, type=Path)
pump=lambda:run_adapter(args.mtp_adapter,"pull",args.expected_peer,args.transport_root,deadline,True) 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("--mtp-adapter", type=Path)
args = parser.parse_args()
deadline = time.monotonic() + args.timeout
pump = None
if args.mode == "mtp":
if args.mtp_adapter is None:
raise RuntimeError("MTP adapter is required")
pump = lambda: run_adapter(
args.mtp_adapter, "pull", args.expected_peer, args.transport_root, deadline, True
)
# Android publishes its durable identity only while participating. A # Android publishes its durable identity only while participating. A
# missing expected advertisement is therefore a bounded wait state; # missing expected advertisement is therefore a bounded wait state;
# device, transport, ambiguity and malformed-object failures remain # device, transport, ambiguity and malformed-object failures remain
# immediate and distinct adapter errors. # immediate and distinct adapter errors.
wait_file(args.transport_root/"android-peer-v1.json",deadline,pump) wait_file(args.transport_root / "android-peer-v1.json", deadline, pump)
peer=load_peer(args.transport_root,args.expected_peer) peer = load_peer(args.transport_root, args.expected_peer)
with closing(generation.connect_database(args.database)) as db: with closing(generation.connect_database(args.database)) as db:
generation.require_schema(db);desktop_peer=generation.peer_identity(db,"desktop");db.commit() generation.require_schema(db)
request={"format":"trainlog-sync-generation-request","version":1,"run_id":args.run_id,"desktop_peer_id":desktop_peer,"android_peer_id":peer["peer_id"],"capabilities":sorted(CAPS)} desktop_peer = generation.peer_identity(db, "desktop")
request_path=args.transport_root/"request-v1.json";publish_json(request_path,request) db.commit()
if args.mode=="mtp": run_adapter(args.mtp_adapter,"push",args.expected_peer,args.transport_root,deadline) request = {
emit(args.run_id,"waiting_android_publication",producer_peer_id=peer["peer_id"],consumer_peer_id=desktop_peer) "format": "trainlog-sync-generation-request",
inbound_ref=args.transport_root/"android-generation-v1.json" "version": 1,
inbound=wait_json(inbound_ref,deadline,lambda value:value.get("run_id")==args.run_id,pump);inbound_dir=args.transport_root/inbound["relative_path"] "run_id": args.run_id,
ack=generation.consume_desktop(args.database,inbound_dir) "desktop_peer_id": desktop_peer,
ack_path=args.transport_root/"desktop-consumption-ack-v1.json";publish_json(ack_path,ack) "android_peer_id": peer["peer_id"],
if args.mode=="mtp": run_adapter(args.mtp_adapter,"push",args.expected_peer,args.transport_root,deadline) "capabilities": sorted(CAPS),
if ack["result"]!="consumed": raise RuntimeError("desktop rejected Android generation") }
emit(args.run_id,"local_import_committed",inbound_generation_id=ack["generation_id"],manifest_sha256=ack["manifest_sha256"]) request_path = args.transport_root / "request-v1.json"
outgoing_id="gen_"+str(uuid.uuid4()) publish_json(request_path, request)
if args.mode == "mtp":
run_adapter(args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline)
emit(
args.run_id,
"waiting_android_publication",
producer_peer_id=peer["peer_id"],
consumer_peer_id=desktop_peer,
)
inbound_ref = args.transport_root / "android-generation-v1.json"
inbound = wait_json(
inbound_ref, deadline, lambda value: value.get("run_id") == args.run_id, pump
)
inbound_dir = args.transport_root / inbound["relative_path"]
ack = generation.consume_desktop(args.database, inbound_dir)
ack_path = args.transport_root / "desktop-consumption-ack-v1.json"
publish_json(ack_path, ack)
if args.mode == "mtp":
run_adapter(args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline)
if ack["result"] != "consumed":
raise RuntimeError("desktop rejected Android generation")
emit(
args.run_id,
"local_import_committed",
inbound_generation_id=ack["generation_id"],
manifest_sha256=ack["manifest_sha256"],
)
outgoing_id = "gen_" + str(uuid.uuid4())
# Export codecs retain human diagnostics on stdout when invoked as CLIs. # Export codecs retain human diagnostics on stdout when invoked as CLIs.
# The worker stdout contract is typed NDJSON only, so route those bounded # The worker stdout contract is typed NDJSON only, so route those bounded
# diagnostics to the separately bounded stderr channel. # diagnostics to the separately bounded stderr channel.
with redirect_stdout(sys.stderr): with redirect_stdout(sys.stderr):
stage,manifest,digest=generation.capture_desktop(args.database,args.owned_root,peer["peer_id"],args.run_id,outgoing_id) stage, manifest, digest = generation.capture_desktop(
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id
)
del stage del stage
published=generation.publish(args.database,outgoing_id,args.transport_root/"desktop-objects") published = generation.publish(
ref={"format":"trainlog-sync-generation-reference","version":1,"run_id":args.run_id,"generation_id":outgoing_id,"manifest_sha256":digest,"relative_path":str(published.relative_to(args.transport_root))} args.database, outgoing_id, args.transport_root / "desktop-objects"
publish_json(args.transport_root/"desktop-generation-v1.json",ref) )
if args.mode=="mtp": run_adapter(args.mtp_adapter,"push",args.expected_peer,args.transport_root,deadline) ref = {
emit(args.run_id,"published",outbound_generation_id=outgoing_id,manifest_sha256=digest) "format": "trainlog-sync-generation-reference",
emit(args.run_id,"waiting_acknowledgement",outbound_generation_id=outgoing_id) "version": 1,
android_ack=args.transport_root/"android-consumption-ack-v1.json" "run_id": args.run_id,
android_ack_value=wait_json(android_ack,deadline,lambda value:value.get("run_id")==args.run_id and value.get("generation_id")==outgoing_id,pump) "generation_id": outgoing_id,
result=generation.accept_ack(args.database,android_ack) "manifest_sha256": digest,
if result not in ("acknowledged","unchanged"): raise RuntimeError(f"Android did not durably consume desktop generation: {result}: {android_ack_value.get('diagnostic','')}") "relative_path": str(published.relative_to(args.transport_root)),
}
publish_json(args.transport_root / "desktop-generation-v1.json", ref)
if args.mode == "mtp":
run_adapter(args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline)
emit(args.run_id, "published", outbound_generation_id=outgoing_id, manifest_sha256=digest)
emit(args.run_id, "waiting_acknowledgement", outbound_generation_id=outgoing_id)
android_ack = args.transport_root / "android-consumption-ack-v1.json"
android_ack_value = wait_json(
android_ack,
deadline,
lambda value: value.get("run_id") == args.run_id
and value.get("generation_id") == outgoing_id,
pump,
)
result = generation.accept_ack(args.database, android_ack)
if result not in ("acknowledged", "unchanged"):
raise RuntimeError(
f"Android did not durably consume desktop generation: {result}: {android_ack_value.get('diagnostic','')}"
)
with closing(generation.connect_database(args.database)) as db: with closing(generation.connect_database(args.database)) as db:
inbound_row=db.execute("SELECT result FROM sync_consumed_generations WHERE generation_id=?",(ack["generation_id"],)).fetchone() inbound_row = db.execute(
outbound_row=db.execute("SELECT status FROM sync_generations WHERE generation_id=?",(outgoing_id,)).fetchone() "SELECT result FROM sync_consumed_generations WHERE generation_id=?",
drafts=[{"draft_id":row[0],"state":row[1],"session_type":json.loads(row[2]).get("session_type","training"),"occurrence_count":len(json.loads(row[2]).get("exercises",[]))} for row in db.execute("SELECT session_id,state,payload_json FROM execution_drafts ORDER BY session_id LIMIT 32")] (ack["generation_id"],),
if inbound_row!=("consumed",) or outbound_row!=("acknowledged",): raise RuntimeError("generation evidence did not corroborate completion") ).fetchone()
emit(args.run_id,"peer_consumed",outbound_generation_id=outgoing_id) outbound_row = db.execute(
report={"format":"trainlog-sync-worker-report","version":1,"run_id":args.run_id,"producer_peer_id":peer["peer_id"],"consumer_peer_id":desktop_peer,"inbound_generation_id":ack["generation_id"],"outbound_generation_id":outgoing_id,"manifest_sha256":digest,"result":"completed","sessions_reconciled":0,"domains":{name:True for name in ("history-v4","execution-draft-v1","causal-delete-v1","equipment","aliases","profiles","body-zones","feedback")},"drafts":drafts,"ai_midpoint":{"result":"not_configured"},"ai_post_sync":{"result":"not_configured"}} "SELECT status FROM sync_generations WHERE generation_id=?", (outgoing_id,)
print(json.dumps(report,sort_keys=True,separators=(",",":")),flush=True);return 0 ).fetchone()
drafts = [
{
"draft_id": row[0],
"state": row[1],
"session_type": json.loads(row[2]).get("session_type", "training"),
"occurrence_count": len(json.loads(row[2]).get("exercises", [])),
}
for row in db.execute(
"SELECT session_id,state,payload_json FROM execution_drafts ORDER BY session_id LIMIT 32"
)
]
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
raise RuntimeError("generation evidence did not corroborate completion")
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
report = {
"format": "trainlog-sync-worker-report",
"version": 1,
"run_id": args.run_id,
"producer_peer_id": peer["peer_id"],
"consumer_peer_id": desktop_peer,
"inbound_generation_id": ack["generation_id"],
"outbound_generation_id": outgoing_id,
"manifest_sha256": digest,
"result": "completed",
"sessions_reconciled": 0,
"domains": {
name: True
for name in (
"history-v4",
"execution-draft-v1",
"causal-delete-v1",
"equipment",
"aliases",
"profiles",
"body-zones",
"feedback",
)
},
"drafts": drafts,
"ai_midpoint": {"result": "not_configured"},
"ai_post_sync": {"result": "not_configured"},
}
print(json.dumps(report, sort_keys=True, separators=(",", ":")), flush=True)
return 0
if __name__=="__main__": raise SystemExit(main())
if __name__ == "__main__":
raise SystemExit(main())