style(sync): expand Python and Kotlin workflows
This commit is contained in:
parent
6cd6219ac5
commit
7b769076c1
15 changed files with 2802 additions and 853 deletions
|
|
@ -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)
|
||||||
|
put(
|
||||||
|
"stores",
|
||||||
|
JSONObject().also { stores ->
|
||||||
|
PREFERENCES.forEach { name ->
|
||||||
|
stores.put(
|
||||||
|
name,
|
||||||
|
JSONObject().also { values ->
|
||||||
|
context
|
||||||
|
.getSharedPreferences(name, Context.MODE_PRIVATE)
|
||||||
|
.all
|
||||||
|
.toSortedMap()
|
||||||
|
.forEach { (key, value) ->
|
||||||
when (value) {
|
when (value) {
|
||||||
null -> values.put(key, JSONObject.NULL)
|
null -> values.put(key, JSONObject.NULL)
|
||||||
is String, is Boolean, is Int, is Long, is Float -> values.put(key, value)
|
is String,
|
||||||
is Set<*> -> values.put(key, JSONArray(value.filterIsInstance<String>().sorted()))
|
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")
|
else -> error("Unsupported preference type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
} })
|
)
|
||||||
}.toString()
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.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
|
||||||
|
val names = mutableSetOf<String>()
|
||||||
ZipInputStream(input).use { zip ->
|
ZipInputStream(input).use { zip ->
|
||||||
while (true) {
|
while (true) {
|
||||||
val entry = zip.nextEntry ?: break
|
val entry = zip.nextEntry ?: break
|
||||||
require(!entry.isDirectory && entry.name in setOf("database.sqlite", "preferences.json", "manifest.json") && names.add(entry.name)) { "Invalid backup entry." }
|
require(
|
||||||
val target = File(stage, entry.name); target.outputStream().use { output ->
|
!entry.isDirectory &&
|
||||||
val buffer = ByteArray(64 * 1024); var entryBytes = 0L
|
entry.name in
|
||||||
while (true) { val count = zip.read(buffer); if (count < 0) break; entryBytes += count; total += count
|
setOf("database.sqlite", "preferences.json", "manifest.json") &&
|
||||||
require(entryBytes <= MAX_ENTRY_BYTES && total <= MAX_TOTAL_BYTES) { "Backup exceeds bounds." }; output.write(buffer, 0, count) }
|
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." }
|
}
|
||||||
|
require(names == setOf("database.sqlite", "preferences.json", "manifest.json")) {
|
||||||
|
"Backup entries are incomplete."
|
||||||
|
}
|
||||||
val manifest = JSONObject(File(stage, "manifest.json").readText())
|
val manifest = JSONObject(File(stage, "manifest.json").readText())
|
||||||
require(manifest.getString("format") == FORMAT && manifest.getInt("version") == VERSION &&
|
require(
|
||||||
manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") &&
|
manifest.getString("format") == FORMAT &&
|
||||||
manifest.getInt("schema") in 17..20) { "Unsupported backup." }
|
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")
|
val expected = manifest.getJSONArray("entries")
|
||||||
require(expected.length() == 2)
|
require(expected.length() == 2)
|
||||||
val manifestPaths = mutableSetOf<String>()
|
val manifestPaths = mutableSetOf<String>()
|
||||||
for (index in 0 until expected.length()) { val item = expected.getJSONObject(index); val path = item.getString("path")
|
for (index in 0 until expected.length()) {
|
||||||
require(path in setOf("database.sqlite", "preferences.json") && manifestPaths.add(path)) { "Invalid manifest entry." }; val file = File(stage, path)
|
val item = expected.getJSONObject(index)
|
||||||
require(file.canonicalFile.parentFile == stage.canonicalFile && file.length() == item.getLong("size") && digest(file) == item.getString("sha256")) { "Backup digest mismatch." } }
|
val path = item.getString("path")
|
||||||
require(manifestPaths == setOf("database.sqlite", "preferences.json")) { "Backup manifest is incomplete." }
|
require(
|
||||||
require(validateDatabase(File(stage, "database.sqlite")) == manifest.getInt("schema"))
|
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())
|
val preferences = JSONObject(File(stage, "preferences.json").readText())
|
||||||
require(preferences.getString("format") == "trainlog-android-preferences" && preferences.getInt("version") == 1)
|
require(
|
||||||
VerifiedAndroidBackup(stage, File(stage, "database.sqlite"), File(stage, "preferences.json"))
|
preferences.getString("format") == "trainlog-android-preferences" &&
|
||||||
}.onFailure { stage.deleteRecursively() }
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,40 +20,83 @@ 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,
|
||||||
|
timeout: Duration = Duration.ofMinutes(5),
|
||||||
|
afterPublication: (() -> Unit)? = null,
|
||||||
|
): ForegroundGenerationResult =
|
||||||
|
try {
|
||||||
|
if (!directory.isDirectory)
|
||||||
|
throw SyncGenerationException("exchange directory is unavailable")
|
||||||
val peer = service.peerId()
|
val peer = service.peerId()
|
||||||
publish(File(directory, "android-peer-v1.json"), JSONObject()
|
publish(
|
||||||
.put("format", "trainlog-sync-peer").put("version", 1).put("peer_id", peer)
|
File(directory, "android-peer-v1.json"),
|
||||||
.put("capabilities", JSONArray(listOf("generation-manifest-v1", "generation-ack-v1",
|
JSONObject()
|
||||||
"causal-delete-v1", "mobile-history-v4", "execution-draft-v1"))).toString())
|
.put("format", "trainlog-sync-peer")
|
||||||
|
.put("version", 1)
|
||||||
|
.put("peer_id", peer)
|
||||||
|
.put(
|
||||||
|
"capabilities",
|
||||||
|
JSONArray(
|
||||||
|
listOf(
|
||||||
|
"generation-manifest-v1",
|
||||||
|
"generation-ack-v1",
|
||||||
|
"causal-delete-v1",
|
||||||
|
"mobile-history-v4",
|
||||||
|
"execution-draft-v1",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toString(),
|
||||||
|
)
|
||||||
val deadline = System.nanoTime() + timeout.toNanos()
|
val deadline = System.nanoTime() + timeout.toNanos()
|
||||||
val request = JSONObject(await(File(directory, "request-v1.json"), deadline).readText())
|
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")
|
if (request.getString("android_peer_id") != peer)
|
||||||
val captured = service.capture(directory, request.getString("desktop_peer_id"), request.getString("run_id"))
|
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"))
|
val published = service.publish(captured, File(directory, "android-objects"))
|
||||||
publish(File(directory, "android-generation-v1.json"), JSONObject()
|
publish(
|
||||||
.put("format", "trainlog-sync-generation-reference").put("version", 1)
|
File(directory, "android-generation-v1.json"),
|
||||||
.put("run_id", captured.runId).put("generation_id", captured.generationId)
|
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("manifest_sha256", captured.manifestSha256)
|
||||||
.put("relative_path", published.relativeTo(directory).path).toString())
|
.put("relative_path", published.relativeTo(directory).path)
|
||||||
|
.toString(),
|
||||||
|
)
|
||||||
afterPublication?.invoke()
|
afterPublication?.invoke()
|
||||||
service.acceptAcknowledgement(await(File(directory, "desktop-consumption-ack-v1.json"), deadline).readBytes())
|
service.acceptAcknowledgement(
|
||||||
val reference = JSONObject(await(File(directory, "desktop-generation-v1.json"), deadline).readText())
|
await(File(directory, "desktop-consumption-ack-v1.json"), deadline).readBytes()
|
||||||
val acknowledgement = service.consume(File(directory, reference.getString("relative_path")))
|
)
|
||||||
|
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)
|
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
|
||||||
ForegroundGenerationResult.Completed(captured.runId)
|
ForegroundGenerationResult.Completed(captured.runId)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
fun verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner() {
|
||||||
val source = System.getenv("TRAINLOG_BRIDGE_BACKUP_INPUT")?.let(::File)
|
val source = System.getenv("TRAINLOG_BRIDGE_BACKUP_INPUT")?.let(::File)
|
||||||
assumeTrue(source?.isFile == true)
|
assumeTrue(source?.isFile == true)
|
||||||
val context:Context=ApplicationProvider.getApplicationContext();val name="bridge-migrate-${UUID.randomUUID()}.db"
|
val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
val service=AndroidBackupService(context);var repository=TrainlogRepository(context,name)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,25 +46,43 @@ 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)
|
||||||
|
?: 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 }
|
.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 =
|
||||||
|
executor.submit<ForegroundGenerationResult> {
|
||||||
coordinator.run(transport, Duration.ofSeconds(30)) {
|
coordinator.run(transport, Duration.ofSeconds(30)) {
|
||||||
release?.let { awaitFile(File(it)) }
|
release?.let { awaitFile(File(it)) }
|
||||||
}
|
}
|
||||||
|
|
@ -70,16 +90,39 @@ class SyncDeploymentConversationTest {
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -30,82 +30,280 @@ class SyncGenerationServiceTest {
|
||||||
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))
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}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"
|
||||||
|
val root = Files.createTempDirectory("trainlog-generation-rollback-").toFile()
|
||||||
|
val source = TrainlogRepository(context, sourceName)
|
||||||
|
val destination = TrainlogRepository(context, destinationName)
|
||||||
try {
|
try {
|
||||||
val exercise=(source.createExercise(NewExerciseProfile("Rollback exercise",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
|
val exercise =
|
||||||
assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=5)))))) is SaveSessionResult.Saved)
|
(source.createExercise(
|
||||||
val destinationService=SyncGenerationService(destination);val identity=destinationService.capture(root,"peer_11111111-1111-4111-8111-111111111111")
|
NewExerciseProfile(
|
||||||
val peer=JSONObject(java.io.File(identity.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id")
|
"Rollback exercise",
|
||||||
val sourceService=SyncGenerationService(source);val captured=sourceService.capture(root,peer);val published=sourceService.publish(captured,java.io.File(root,"objects"))
|
RecordingMode.SETS,
|
||||||
val bad="{}".toByteArray();java.io.File(published,"feedback-v2.json").writeBytes(bad)
|
TrackingMode.REPS,
|
||||||
val manifest=JSONObject(java.io.File(published,"manifest.json").readText());val artifacts=manifest.getJSONArray("artifacts")
|
0,
|
||||||
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)})
|
)
|
||||||
|
) 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())
|
java.io.File(published, "manifest.json").writeText(manifest.toString())
|
||||||
assertEquals("rejected",JSONObject(destinationService.consume(published)).getString("result"))
|
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"
|
||||||
|
val root = Files.createTempDirectory("trainlog-generation-causal-").toFile()
|
||||||
|
val repo = TrainlogRepository(context, name)
|
||||||
try {
|
try {
|
||||||
val exercise=(repo.createExercise(NewExerciseProfile("Causal generation",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
|
val exercise =
|
||||||
assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=4)))))) is SaveSessionResult.Saved)
|
(repo.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Causal generation",
|
||||||
|
RecordingMode.SETS,
|
||||||
|
TrackingMode.REPS,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
) as CreateExerciseResult.Created)
|
||||||
|
.exercise
|
||||||
|
assertTrue(
|
||||||
|
repo.saveSession(
|
||||||
|
SessionDraft(
|
||||||
|
listOf(
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 4)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) is SaveSessionResult.Saved
|
||||||
|
)
|
||||||
val sessionId = repo.listSessions().single().sessionId
|
val sessionId = repo.listSessions().single().sessionId
|
||||||
assertTrue(repo.deleteCausally("session",sessionId,"peer_android_generation") is CausalDeleteResult.Applied)
|
assertTrue(
|
||||||
val service=SyncGenerationService(repo);val peer="peer_33333333-3333-4333-8333-333333333333"
|
repo.deleteCausally("session", sessionId, "peer_android_generation")
|
||||||
val first=service.capture(root,peer);val second=service.capture(root,peer)
|
is CausalDeleteResult.Applied
|
||||||
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"))
|
val service = SyncGenerationService(repo)
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db->
|
val peer = "peer_33333333-3333-4333-8333-333333333333"
|
||||||
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))}
|
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()
|
||||||
}
|
}
|
||||||
}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"
|
||||||
|
val repo = TrainlogRepository(context, name)
|
||||||
try {
|
try {
|
||||||
val first=(repo.createExercise(NewExerciseProfile("Snapshot A",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
|
val first =
|
||||||
assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=first,sets=listOf(SessionSetDraft(reps=3)))))) is SaveSessionResult.Saved)
|
(repo.createExercise(
|
||||||
val attempted=CountDownLatch(1);var writer:Thread?=null
|
NewExerciseProfile("Snapshot A", RecordingMode.SETS, TrackingMode.REPS, 0)
|
||||||
val captured=repo.captureSyncGenerationArtifacts{
|
) as CreateExerciseResult.Created)
|
||||||
writer=Thread{
|
.exercise
|
||||||
|
assertTrue(
|
||||||
|
repo.saveSession(
|
||||||
|
SessionDraft(
|
||||||
|
listOf(
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = first,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 3)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) is SaveSessionResult.Saved
|
||||||
|
)
|
||||||
|
val attempted = CountDownLatch(1)
|
||||||
|
var writer: Thread? = null
|
||||||
|
val captured =
|
||||||
|
repo.captureSyncGenerationArtifacts {
|
||||||
|
writer =
|
||||||
|
Thread {
|
||||||
attempted.countDown()
|
attempted.countDown()
|
||||||
repo.createExercise(NewExerciseProfile("Snapshot B",RecordingMode.SETS,TrackingMode.REPS,0))
|
repo.createExercise(
|
||||||
}.also{it.start()}
|
NewExerciseProfile(
|
||||||
|
"Snapshot B",
|
||||||
|
RecordingMode.SETS,
|
||||||
|
TrackingMode.REPS,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.also { it.start() }
|
||||||
assertTrue(attempted.await(5, TimeUnit.SECONDS))
|
assertTrue(attempted.await(5, TimeUnit.SECONDS))
|
||||||
}
|
}
|
||||||
val completedWriter = checkNotNull(writer)
|
val completedWriter = checkNotNull(writer)
|
||||||
completedWriter.join(5000);assertTrue(!completedWriter.isAlive)
|
completedWriter.join(5000)
|
||||||
assertEquals(1,JSONObject(captured.getValue("catalog")).getJSONArray("exercises").length())
|
assertTrue(!completedWriter.isAlive)
|
||||||
assertEquals(1,JSONObject(captured.getValue("exercise-profile-state")).getJSONArray("exercises").length())
|
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)
|
assertEquals(2, repo.listExercises().size)
|
||||||
}finally{repo.close();context.deleteDatabase(name)}
|
} finally {
|
||||||
|
repo.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -114,71 +312,209 @@ class SyncGenerationServiceTest {
|
||||||
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 destination = TrainlogRepository(context, destinationName)
|
||||||
var desktopDestination = TrainlogRepository(context, desktopDestinationName)
|
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(
|
||||||
|
"Generation exercise",
|
||||||
|
RecordingMode.SETS,
|
||||||
|
TrackingMode.REPS,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
) as CreateExerciseResult.Created
|
||||||
|
val saved =
|
||||||
|
source.saveSession(
|
||||||
|
SessionDraft(
|
||||||
|
listOf(
|
||||||
|
SessionExerciseDraft(
|
||||||
exercise = created.exercise,
|
exercise = created.exercise,
|
||||||
sets = listOf(SessionSetDraft(reps = 7, weightKg = 21.5)),
|
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 destinationPeer =
|
||||||
|
JSONObject(
|
||||||
|
java.io.File(identityCapture.stagingDirectory, "manifest.json").readText()
|
||||||
|
)
|
||||||
|
.getJSONObject("producer")
|
||||||
|
.getString("peer_id")
|
||||||
val sourceService = SyncGenerationService(source)
|
val sourceService = SyncGenerationService(source)
|
||||||
val captured = sourceService.capture(root, destinationPeer)
|
val captured = sourceService.capture(root, destinationPeer)
|
||||||
val published = sourceService.publish(captured, java.io.File(root, "objects"))
|
val published = sourceService.publish(captured, java.io.File(root, "objects"))
|
||||||
val before=published.listFiles()!!.associate{it.name to it.readBytes().contentHashCode()}
|
val before =
|
||||||
|
published.listFiles()!!.associate { it.name to it.readBytes().contentHashCode() }
|
||||||
assertEquals(published, sourceService.publish(captured, java.io.File(root, "objects")))
|
assertEquals(published, sourceService.publish(captured, java.io.File(root, "objects")))
|
||||||
assertEquals(before,published.listFiles()!!.associate{it.name to it.readBytes().contentHashCode()})
|
assertEquals(
|
||||||
|
before,
|
||||||
|
published.listFiles()!!.associate { it.name to it.readBytes().contentHashCode() },
|
||||||
|
)
|
||||||
val ack = destinationService.consume(published)
|
val ack = destinationService.consume(published)
|
||||||
assertEquals("acknowledged", sourceService.acceptAcknowledgement(ack.toByteArray()))
|
assertEquals("acknowledged", sourceService.acceptAcknowledgement(ack.toByteArray()))
|
||||||
source.close();destination.close()
|
source.close()
|
||||||
source=TrainlogRepository(context,sourceName);destination=TrainlogRepository(context,destinationName)
|
destination.close()
|
||||||
|
source = TrainlogRepository(context, sourceName)
|
||||||
|
destination = TrainlogRepository(context, destinationName)
|
||||||
assertEquals(1, destination.listSessions().size)
|
assertEquals(1, destination.listSessions().size)
|
||||||
assertEquals("unchanged",SyncGenerationService(source).acceptAcknowledgement(ack.toByteArray()))
|
assertEquals(
|
||||||
|
"unchanged",
|
||||||
|
SyncGenerationService(source).acceptAcknowledgement(ack.toByteArray()),
|
||||||
|
)
|
||||||
assertEquals(ack, SyncGenerationService(destination).consume(published))
|
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 =
|
||||||
|
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||||
|
it.parentFile
|
||||||
|
}
|
||||||
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||||
val desktopDb = java.io.File(root, "desktop-source.sqlite")
|
val desktopDb = java.io.File(root, "desktop-source.sqlite")
|
||||||
fun run(vararg command: String): String {
|
fun run(vararg command: String): String {
|
||||||
val process=ProcessBuilder(*command).directory(repositoryRoot).redirectErrorStream(true).start()
|
val process =
|
||||||
|
ProcessBuilder(*command)
|
||||||
|
.directory(repositoryRoot)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
val text = process.inputStream.bufferedReader().readText()
|
val text = process.inputStream.bufferedReader().readText()
|
||||||
assertEquals("command failed: ${command.joinToString(" ")}\n$text",0,process.waitFor())
|
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,
|
||||||
|
desktopConsumerDb.absolutePath,
|
||||||
|
)
|
||||||
|
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]
|
||||||
val androidToDesktop = sourceService.capture(root, desktopPeer)
|
val androidToDesktop = sourceService.capture(root, desktopPeer)
|
||||||
val androidPublished=sourceService.publish(androidToDesktop,java.io.File(root,"android-desktop-objects"))
|
val androidPublished =
|
||||||
|
sourceService.publish(
|
||||||
|
androidToDesktop,
|
||||||
|
java.io.File(root, "android-desktop-objects"),
|
||||||
|
)
|
||||||
val androidAckFile = java.io.File(root, "android-desktop-ack.json")
|
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)
|
run(
|
||||||
assertEquals("acknowledged",sourceService.acceptAcknowledgement(androidAckFile.readBytes()))
|
"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
|
val androidSessionId = source.listSessions().single().sessionId
|
||||||
assertEquals("1",run("sqlite3",desktopConsumerDb.absolutePath,"SELECT count(*) FROM sessions WHERE session_id='$androidSessionId';").trim())
|
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 androidPeer =
|
||||||
|
JSONObject(
|
||||||
|
java.io
|
||||||
|
.File(androidPeerCapture.stagingDirectory, "manifest.json")
|
||||||
|
.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 desktopPublished = java.io.File(desktopObjects, "generations/$desktopGeneration")
|
||||||
val desktopAck = SyncGenerationService(desktopDestination).consume(desktopPublished)
|
val desktopAck = SyncGenerationService(desktopDestination).consume(desktopPublished)
|
||||||
val desktopAckFile=java.io.File(root,"desktop-ack.json").apply{writeText(desktopAck)}
|
val desktopAckFile =
|
||||||
assertTrue(run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"accept-ack",desktopAckFile.absolutePath,"--database",desktopDb.absolutePath).contains("ACK_ACCEPT=acknowledged"))
|
java.io.File(root, "desktop-ack.json").apply { writeText(desktopAck) }
|
||||||
assertTrue(desktopDestination.listSessions().any{it.sessionId=="se_99999999-9999-4999-8999-999999999999"})
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,41 +3,135 @@ 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_")
|
||||||
|
state.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"run_id": run,
|
||||||
|
"request_id": request,
|
||||||
|
"trigger": "web",
|
||||||
|
"phase": "requested",
|
||||||
|
"progress_revision": 1,
|
||||||
|
"result": "running",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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
|
||||||
|
(root / "transport").mkdir()
|
||||||
|
(root / "owned").mkdir()
|
||||||
|
path = root / "config.json"
|
||||||
|
path.write_text(json.dumps(config))
|
||||||
return db, state, path, run, request
|
return db, state, path, run, request
|
||||||
|
|
||||||
def invoke(self, executable, values):
|
def invoke(self, executable, values):
|
||||||
db, state, config, run, request = 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)
|
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):
|
def test_browser_configuration_cannot_select_command_or_boolean_timeout(self):
|
||||||
for override in ({"peer_command":["evil"]},{"timeout_seconds":True},{"mode":"unsupported"}):
|
for override in (
|
||||||
|
{"peer_command": ["evil"]},
|
||||||
|
{"timeout_seconds": True},
|
||||||
|
{"mode": "unsupported"},
|
||||||
|
):
|
||||||
with self.subTest(override=override), tempfile.TemporaryDirectory() as directory:
|
with self.subTest(override=override), tempfile.TemporaryDirectory() as directory:
|
||||||
root=Path(directory);values=self.fixture(root,**override);result=self.invoke(ORCHESTRATOR,values)
|
root = Path(directory)
|
||||||
self.assertEqual(result.returncode,2);self.assertEqual(json.loads(values[1].read_text())["phase"],"failed")
|
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):
|
def test_stdout_bound_is_enforced_before_complete_accumulation(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root=Path(directory);sandbox=root/"program";sandbox.mkdir();(sandbox/"sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
|
root = Path(directory)
|
||||||
(sandbox/"sync_peer_worker.py").write_text("import sys,time\nsys.stdout.write('x'*70000);sys.stdout.flush();time.sleep(30)\n")
|
sandbox = root / "program"
|
||||||
values=self.fixture(root);result=self.invoke(sandbox/"sync_orchestrator.py",values)
|
sandbox.mkdir()
|
||||||
self.assertEqual(result.returncode,2);final=json.loads(values[1].read_text());self.assertIn("exceeds 64 KiB",final["diagnostic"])
|
(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):
|
def test_shutdown_reaps_ignoring_helper_and_descendant(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
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())
|
root = Path(directory)
|
||||||
(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)))
|
sandbox = root / "program"
|
||||||
values=self.fixture(root);db,state,config,run,request=values
|
sandbox.mkdir()
|
||||||
process=subprocess.Popen(["python3",str(sandbox/"sync_orchestrator.py"),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request])
|
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
|
deadline = time.monotonic() + 5
|
||||||
while not pidfile.is_file() and time.monotonic()<deadline: time.sleep(.02)
|
while not pidfile.is_file() and time.monotonic() < deadline:
|
||||||
process.send_signal(signal.SIGTERM);self.assertEqual(process.wait(timeout=10),2)
|
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()):
|
for pid in map(int, pidfile.read_text().split()):
|
||||||
with self.assertRaises(ProcessLookupError): os.kill(pid,0)
|
with self.assertRaises(ProcessLookupError):
|
||||||
|
os.kill(pid, 0)
|
||||||
self.assertEqual(json.loads(state.read_text())["phase"], "interrupted")
|
self.assertEqual(json.loads(state.read_text())["phase"], "interrupted")
|
||||||
|
|
||||||
if __name__=="__main__": unittest.main()
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
time.sleep(0.025)
|
||||||
raise AssertionError("condition did not become true")
|
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():
|
||||||
|
self.skipTest("pinned geckodriver unavailable")
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.firefox.service import Service
|
from selenium.webdriver.firefox.service import Service
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory:
|
with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory:
|
||||||
root=Path(directory);xdg=root/"data";xdg.mkdir();config=root/"config.json"
|
root = Path(directory)
|
||||||
|
xdg = root / "data"
|
||||||
|
xdg.mkdir()
|
||||||
|
config = root / "config.json"
|
||||||
mode = os.environ.get("TRAINLOG_WEB_E2E_TRANSPORT_MODE", "directory")
|
mode = os.environ.get("TRAINLOG_WEB_E2E_TRANSPORT_MODE", "directory")
|
||||||
transport=root/"transport";transport.mkdir();owned=root/"owned";owned.mkdir()
|
transport = root / "transport"
|
||||||
|
transport.mkdir()
|
||||||
|
owned = root / "owned"
|
||||||
|
owned.mkdir()
|
||||||
remote = (root / "device/Documents/Trainlog") if mode == "mtp" else transport
|
remote = (root / "device/Documents/Trainlog") if mode == "mtp" else transport
|
||||||
remote.mkdir(parents=True, exist_ok=True)
|
remote.mkdir(parents=True, exist_ok=True)
|
||||||
release=root/"release-android";android_log=(root/"android.log").open("wb")
|
release = root / "release-android"
|
||||||
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_log = (root / "android.log").open("wb")
|
||||||
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)
|
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:
|
try:
|
||||||
peer_file=await_condition(lambda: (remote/"android-peer-v1.json") if (remote/"android-peer-v1.json").is_file() else None,120)
|
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:
|
except Exception as error:
|
||||||
if android.poll() is None: os.killpg(android.pid,signal.SIGTERM)
|
if android.poll() is None:
|
||||||
android.wait(timeout=10);android_log.close()
|
os.killpg(android.pid, signal.SIGTERM)
|
||||||
raise AssertionError(f"Android bridge did not advertise:\n{(root/'android.log').read_text(errors='replace')}") from error
|
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())
|
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}))
|
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:
|
with socket.socket() as reserved:
|
||||||
reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1]
|
reserved.bind(("127.0.0.1", 0))
|
||||||
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)})
|
port = reserved.getsockname()[1]
|
||||||
if mode=="mtp": env.update({"TRAINLOG_SYNC_MTP_ADAPTER":str(ROOT/"build/tui/generation-mtp-adapter-double"),"TRAINLOG_MTP_DOUBLE_ROOT":str(root/"device")})
|
env = os.environ.copy()
|
||||||
server=subprocess.Popen([str(ROOT/"build/tui/trainlog"),"--web","--port",str(port)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
|
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
|
driver = None
|
||||||
try:
|
try:
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
with socket.socket() as probe:
|
with socket.socket() as probe:
|
||||||
if probe.connect_ex(("127.0.0.1",port))==0: break
|
if probe.connect_ex(("127.0.0.1", port)) == 0:
|
||||||
time.sleep(.05)
|
break
|
||||||
options=webdriver.FirefoxOptions();options.add_argument("-headless")
|
time.sleep(0.05)
|
||||||
|
options = webdriver.FirefoxOptions()
|
||||||
|
options.add_argument("-headless")
|
||||||
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
|
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
|
||||||
driver.get(f"http://127.0.0.1:{port}/")
|
driver.get(f"http://127.0.0.1:{port}/")
|
||||||
wait = WebDriverWait(driver, 15)
|
wait = WebDriverWait(driver, 15)
|
||||||
button=wait.until(lambda d:d.find_element(By.XPATH,"//button[normalize-space()='Synchroniser']"))
|
button = wait.until(
|
||||||
wait.until(lambda _ : button.is_enabled());button.click()
|
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")
|
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)
|
observed = await_condition(
|
||||||
self.assertEqual(observed["phase"],"local_import_committed",f"state={observed}\nandroid={(root/'android.log').read_text(errors='replace')}")
|
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
|
committed = observed
|
||||||
self.assertEqual(committed["result"], "running")
|
self.assertEqual(committed["result"], "running")
|
||||||
with sqlite3.connect(xdg / "trainlog" / "trainlog.db") as database:
|
with sqlite3.connect(xdg / "trainlog" / "trainlog.db") as database:
|
||||||
self.assertEqual(database.execute("SELECT COUNT(*) FROM execution_drafts").fetchone()[0],1)
|
self.assertEqual(
|
||||||
|
database.execute("SELECT COUNT(*) FROM execution_drafts").fetchone()[0], 1
|
||||||
|
)
|
||||||
release.touch()
|
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)
|
final = await_condition(
|
||||||
self.assertEqual(final["phase"],"completed",f"state={final}\nandroid={((root/'android.log').read_text(errors='replace'))}")
|
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)
|
wait.until(lambda d: "Synchronisation confirmée" in d.page_source)
|
||||||
summary=driver.find_element(By.XPATH,"//summary[contains(.,'Synchronisation confirmée')]");summary.click()
|
summary = driver.find_element(
|
||||||
wait.until(lambda d:"Brouillons reçus" in d.page_source and "active" in d.page_source)
|
By.XPATH, "//summary[contains(.,'Synchronisation confirmée')]"
|
||||||
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())
|
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(state["phase"], "completed")
|
||||||
self.assertEqual(android.wait(timeout=30),0,(root/"android.log").read_text(errors="replace"))
|
self.assertEqual(
|
||||||
|
android.wait(timeout=30), 0, (root / "android.log").read_text(errors="replace")
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if driver is not None: driver.quit()
|
if driver is not None:
|
||||||
|
driver.quit()
|
||||||
os.killpg(server.pid, signal.SIGTERM)
|
os.killpg(server.pid, signal.SIGTERM)
|
||||||
server.wait(timeout=10)
|
server.wait(timeout=10)
|
||||||
if android.poll() is None:
|
if android.poll() is None:
|
||||||
os.killpg(android.pid,signal.SIGTERM);android.wait(timeout=10)
|
os.killpg(android.pid, signal.SIGTERM)
|
||||||
|
android.wait(timeout=10)
|
||||||
android_log.close()
|
android_log.close()
|
||||||
|
|
||||||
if __name__=="__main__":unittest.main()
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
.addCategory(Intent.CATEGORY_OPENABLE), 2) } }
|
|
||||||
setContentView(LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; addView(status); addView(backup); addView(restore) })
|
|
||||||
}
|
}
|
||||||
|
val backup =
|
||||||
|
Button(this).apply {
|
||||||
|
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
|
||||||
|
if (request == 1)
|
||||||
|
contentResolver.openOutputStream(data.data!!, "w")!!.use {
|
||||||
status.text = service.create(repository, it).toString()
|
status.text = service.create(repository, it).toString()
|
||||||
} else if (request == 2) contentResolver.openInputStream(data.data!!)!!.use {
|
}
|
||||||
val verified = service.verify(it).getOrElse { error -> status.text = error.message; return }
|
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()
|
status.text = service.restore(repository, verified).toString()
|
||||||
recreate()
|
recreate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun onDestroy() { repository.close(); super.onDestroy() }
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
repository.close()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
ActiveSessionDraft(
|
||||||
|
exercises =
|
||||||
|
listOf(
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 7, weightKg = 20.0)),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
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)
|
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 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()
|
val verified = service.verify(ByteArrayInputStream(bytes.toByteArray())).getOrThrow()
|
||||||
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
|
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
|
||||||
repository=TrainlogRepository(context,name);assertEquals("21,",(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft.form.weightText)
|
repository = TrainlogRepository(context, name)
|
||||||
context.getDatabasePath(name).let { android.database.sqlite.SQLiteDatabase.openDatabase(it.path,null,android.database.sqlite.SQLiteDatabase.OPEN_READONLY).use { db -> assertEquals(17,db.version) } }
|
assertEquals(
|
||||||
} finally {repository.close();context.deleteDatabase(name)}
|
"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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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):
|
def replace(path: Path, old: str, new: str):
|
||||||
value = path.read_text()
|
value = path.read_text()
|
||||||
if value.count(old)!=1: raise RuntimeError(f"bridge patch anchor mismatch: {path}: {old[:40]}")
|
if value.count(old) != 1:
|
||||||
|
raise RuntimeError(f"bridge patch anchor mismatch: {path}: {old[:40]}")
|
||||||
path.write_text(value.replace(old, new))
|
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()
|
||||||
|
parser.add_argument("--output", required=True, type=Path)
|
||||||
|
parser.add_argument("--jdk", default="/usr/lib/jvm/java-17-openjdk")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.output.exists(): raise RuntimeError("bridge output already exists")
|
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:
|
||||||
|
raise RuntimeError("unsafe archive member")
|
||||||
archive.extractall(args.output, filter="data")
|
archive.extractall(args.output, filter="data")
|
||||||
android = args.output / "android"
|
android = args.output / "android"
|
||||||
repository = android / "app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt"
|
repository = android / "app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt"
|
||||||
replace(repository,"import java.text.Normalizer","import java.text.Normalizer\nimport java.io.File")
|
replace(
|
||||||
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 }")
|
repository,
|
||||||
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")
|
"import java.text.Normalizer",
|
||||||
shutil.copy2(ROOT/"tools/android_backup_bridge/BackupBridgeActivity.kt",android/"app/src/main/java/com/labfytools/trainlog/data/BackupBridgeActivity.kt")
|
"import java.text.Normalizer\nimport java.io.File",
|
||||||
shutil.copy2(ROOT/"tools/android_backup_bridge/BackupBridgeServiceTest.kt",android/"app/src/test/java/com/labfytools/trainlog/data/BackupBridgeServiceTest.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 }',
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
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"
|
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(
|
||||||
|
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"
|
gradle = android / "app/build.gradle.kts"
|
||||||
replace(gradle,"versionCode = 3","versionCode = providers.environmentVariable(\"TRAINLOG_BRIDGE_VERSION_CODE\").orNull?.toInt() ?: 4")
|
replace(
|
||||||
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")
|
gradle,
|
||||||
subprocess.run([str(android/"gradlew"),"--no-daemon",":app:testDebugUnitTest","--tests","com.labfytools.trainlog.data.BackupBridgeServiceTest",":app:assembleDebug"],cwd=android,env=env,check=True)
|
"versionCode = 3",
|
||||||
migration_env=os.environ.copy();migration_env["JAVA_HOME"]=args.jdk;migration_env["TRAINLOG_BRIDGE_BACKUP_INPUT"]=env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"]
|
'versionCode = providers.environmentVariable("TRAINLOG_BRIDGE_VERSION_CODE").orNull?.toInt() ?: 4',
|
||||||
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()}")
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
(args.output / "candidate-inventory.json").write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n")
|
for path in files
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(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)
|
||||||
|
|
|
||||||
|
|
@ -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,96 +81,175 @@ 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:
|
finally:
|
||||||
try: os.unlink(temporary)
|
os.close(directory)
|
||||||
except FileNotFoundError: pass
|
finally:
|
||||||
|
try:
|
||||||
|
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["producer_peer_id"], "peer_"),
|
||||||
(value["consumer_peer_id"], "peer_"),
|
(value["consumer_peer_id"], "peer_"),
|
||||||
(value["inbound_generation_id"], "gen_"),
|
(value["inbound_generation_id"], "gen_"),
|
||||||
(value["outbound_generation_id"], "gen_")):
|
(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):
|
):
|
||||||
|
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:
|
||||||
|
raise RuntimeError("peer worker omitted final report")
|
||||||
return validate_result(final, run_id), bytes(buffers["stderr"])
|
return validate_result(final, run_id), bytes(buffers["stderr"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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_RUN_ID": args.run_id,
|
||||||
"TRAINLOG_SYNC_TRIGGER": state["trigger"],
|
"TRAINLOG_SYNC_TRIGGER": state["trigger"],
|
||||||
"TRAINLOG_SYNC_DATABASE": args.database})
|
"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"]]
|
}
|
||||||
|
)
|
||||||
|
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)
|
|
||||||
except (OSError, ValueError, json.JSONDecodeError, RuntimeError, subprocess.TimeoutExpired) as error:
|
|
||||||
try:
|
try:
|
||||||
path = Path(args.state); state = load_json(path, MAX_REPORT)
|
return run(args)
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
ValueError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
RuntimeError,
|
||||||
|
subprocess.TimeoutExpired,
|
||||||
|
) as error:
|
||||||
|
try:
|
||||||
|
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())
|
||||||
|
|
|
||||||
|
|
@ -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,64 +14,129 @@ 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:
|
def emit(run_id: str, phase: str, **values: object) -> None:
|
||||||
value={"format":"trainlog-sync-progress","version":1,"run_id":run_id,"phase":phase}|values
|
value = {
|
||||||
|
"format": "trainlog-sync-progress",
|
||||||
|
"version": 1,
|
||||||
|
"run_id": run_id,
|
||||||
|
"phase": phase,
|
||||||
|
} | values
|
||||||
print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True)
|
print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True)
|
||||||
|
|
||||||
|
|
||||||
def bounded(path: Path, limit: int = 65536) -> bytes:
|
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:
|
def publish_json(path: Path, value: dict) -> None:
|
||||||
"""Publish a coordination object only after all bytes are durable."""
|
"""Publish a coordination object only after all bytes are durable."""
|
||||||
temporary = path.with_name("." + path.name + ".tmp-" + str(os.getpid()))
|
temporary = path.with_name("." + path.name + ".tmp-" + str(os.getpid()))
|
||||||
with temporary.open("wb") as stream:
|
with temporary.open("wb") as stream:
|
||||||
stream.write(generation.canonical(value));stream.flush();os.fsync(stream.fileno())
|
stream.write(generation.canonical(value))
|
||||||
os.chmod(temporary,0o600);os.replace(temporary,path)
|
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 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()))
|
remaining = max(1, int(deadline - time.monotonic()))
|
||||||
result=__import__("subprocess").run([str(adapter),operation,peer,str(root)],stdin=__import__("subprocess").DEVNULL,
|
result = __import__("subprocess").run(
|
||||||
capture_output=True,timeout=min(remaining,30),check=False)
|
[str(adapter), operation, peer, str(root)],
|
||||||
|
stdin=__import__("subprocess").DEVNULL,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=min(remaining, 30),
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
diagnostic=(result.stderr.decode("utf-8","replace")[:1024] or "MTP adapter failed").strip()
|
diagnostic = (
|
||||||
if allow_missing_peer and "expected peer was not found" in diagnostic: return False
|
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:
|
def load_peer(root: Path, expected: str) -> dict:
|
||||||
value = json.loads(bounded(root / "android-peer-v1.json"))
|
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"])):
|
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:
|
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)
|
parser = argparse.ArgumentParser()
|
||||||
args=parser.parse_args();deadline=time.monotonic()+args.timeout
|
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)
|
||||||
|
args = parser.parse_args()
|
||||||
|
deadline = time.monotonic() + args.timeout
|
||||||
pump = None
|
pump = None
|
||||||
if args.mode == "mtp":
|
if args.mode == "mtp":
|
||||||
if args.mtp_adapter is None: raise RuntimeError("MTP adapter is required")
|
if args.mtp_adapter is None:
|
||||||
pump=lambda:run_adapter(args.mtp_adapter,"pull",args.expected_peer,args.transport_root,deadline,True)
|
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
|
||||||
|
|
@ -78,42 +144,136 @@ def main()->int:
|
||||||
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",
|
||||||
|
"version": 1,
|
||||||
|
"run_id": args.run_id,
|
||||||
|
"desktop_peer_id": desktop_peer,
|
||||||
|
"android_peer_id": peer["peer_id"],
|
||||||
|
"capabilities": sorted(CAPS),
|
||||||
|
}
|
||||||
|
request_path = args.transport_root / "request-v1.json"
|
||||||
|
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_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"]
|
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 = generation.consume_desktop(args.database, inbound_dir)
|
||||||
ack_path=args.transport_root/"desktop-consumption-ack-v1.json";publish_json(ack_path,ack)
|
ack_path = args.transport_root / "desktop-consumption-ack-v1.json"
|
||||||
if args.mode=="mtp": run_adapter(args.mtp_adapter,"push",args.expected_peer,args.transport_root,deadline)
|
publish_json(ack_path, ack)
|
||||||
if ack["result"]!="consumed": raise RuntimeError("desktop rejected Android generation")
|
if args.mode == "mtp":
|
||||||
emit(args.run_id,"local_import_committed",inbound_generation_id=ack["generation_id"],manifest_sha256=ack["manifest_sha256"])
|
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())
|
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"
|
||||||
|
)
|
||||||
|
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)),
|
||||||
|
}
|
||||||
publish_json(args.transport_root / "desktop-generation-v1.json", ref)
|
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)
|
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, "published", outbound_generation_id=outgoing_id, manifest_sha256=digest)
|
||||||
emit(args.run_id, "waiting_acknowledgement", outbound_generation_id=outgoing_id)
|
emit(args.run_id, "waiting_acknowledgement", outbound_generation_id=outgoing_id)
|
||||||
android_ack = args.transport_root / "android-consumption-ack-v1.json"
|
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)
|
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)
|
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','')}")
|
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()
|
||||||
|
outbound_row = db.execute(
|
||||||
|
"SELECT status FROM sync_generations WHERE generation_id=?", (outgoing_id,)
|
||||||
|
).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)
|
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"}}
|
report = {
|
||||||
print(json.dumps(report,sort_keys=True,separators=(",",":")),flush=True);return 0
|
"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())
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue