Compare commits
10 commits
8aa3c20954
...
56ac1cdfd2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ac1cdfd2 | ||
|
|
e64ea836f7 | ||
|
|
bad79ed926 | ||
|
|
d94ff495e3 | ||
|
|
09ada28c6b | ||
|
|
72c30ba7e0 | ||
|
|
99e6c0b073 | ||
|
|
05c93777d6 | ||
|
|
1552dcac1f | ||
|
|
0018edff26 |
24 changed files with 440 additions and 57 deletions
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -2,10 +2,39 @@
|
||||||
|
|
||||||
## Unreleased — 0.1.2
|
## Unreleased — 0.1.2
|
||||||
|
|
||||||
|
- Completed the authorized private full-generation rollout over production
|
||||||
|
direct MTP: verified Android and desktop backups, signing-preserving Android
|
||||||
|
updates, schema migrations, persistent peer pairing, bidirectional durable
|
||||||
|
ACKs, restart and idempotent replay. This did not publish a release or modify
|
||||||
|
v0.1.1.
|
||||||
|
|
||||||
|
- Fixed rollout-discovered feedback idempotence across SQLite row modes,
|
||||||
|
packaged the complete generation exporter closure, ignored stale Android
|
||||||
|
coordination objects until their run/generation correlation matches, and
|
||||||
|
made the Android screen report completed generation conversations instead of
|
||||||
|
waiting for a legacy receipt.
|
||||||
|
|
||||||
- Packaged `trainlog-sync-once` and `trainlog-syncd` with the relocatable sync
|
- Packaged `trainlog-sync-once` and `trainlog-syncd` with the relocatable sync
|
||||||
candidate so user services cannot mix a versioned install with live-checkout
|
candidate so user services cannot mix a versioned install with live-checkout
|
||||||
helpers during rollout.
|
helpers during rollout.
|
||||||
|
|
||||||
|
- Added a bounded Android package-version override and made the schema-v17
|
||||||
|
backup bridge produce a signed release APK, allowing a strictly increasing
|
||||||
|
private update chain without changing Trainlog schemas or protocols.
|
||||||
|
|
||||||
|
- Generalized bridge restore/migration validation to preserve every historical
|
||||||
|
SQLite value whether a real backup contains an active draft or legitimately
|
||||||
|
contains none.
|
||||||
|
|
||||||
|
- Made relocatable launchers resolve stable user-bin symlinks to their physical
|
||||||
|
versioned bundle before loading matching binaries and helpers.
|
||||||
|
|
||||||
|
- Made the Web sync button generate cryptographic UUIDv4 request IDs on the
|
||||||
|
loopback HTTP hostname where `crypto.randomUUID()` is unavailable.
|
||||||
|
|
||||||
|
- Included the exercise-name and training-knowledge modules required by the
|
||||||
|
packaged generation worker, with an isolated runtime-import regression.
|
||||||
|
|
||||||
- Closed the native candidate warning gate with checked path and HTTP response
|
- Closed the native candidate warning gate with checked path and HTTP response
|
||||||
bounds, strict GCC and Clang builds, full Clang ASan/UBSan validation, and a
|
bounds, strict GCC and Clang builds, full Clang ASan/UBSan validation, and a
|
||||||
stable concurrent object-I/O MTP double. Rebuilt the desktop, current Android
|
stable concurrent object-I/O MTP double. Rebuilt the desktop, current Android
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -91,6 +91,16 @@ Android and the TUI in each stable release share the same Trainlog product
|
||||||
version on both mirrors. Database schemas and JSON protocol versions are
|
version on both mirrors. Database schemas and JSON protocol versions are
|
||||||
independent.
|
independent.
|
||||||
|
|
||||||
|
### Daily synchronization on an opted-in installation
|
||||||
|
|
||||||
|
Connect and unlock the paired phone, keep Trainlog's Synchronization screen in
|
||||||
|
the foreground, then select **Synchronize** in the local Web Dashboard and
|
||||||
|
**Synchronize now** on Android. Wait until Web reports completion and Android
|
||||||
|
shows that synchronization completed on both Android and PC. A failure remains
|
||||||
|
durable and should be diagnosed before retrying; do not delete generation,
|
||||||
|
ACK, database, or backup files. Full-generation mode is a trusted local opt-in;
|
||||||
|
other installations continue to use the established V3 request/receipt path.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
Desktop:
|
Desktop:
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,16 @@ val releaseSigningComplete = listOf(
|
||||||
releaseKeyPassword,
|
releaseKeyPassword,
|
||||||
).all { !it.isNullOrBlank() }
|
).all { !it.isNullOrBlank() }
|
||||||
|
|
||||||
|
/* CONTRACT: a private rollout may need an update-only version code above the
|
||||||
|
* repository default while preserving versionName, schemas and protocols.
|
||||||
|
* INVARIANT: the override remains a positive Android package version code and
|
||||||
|
* never changes the source default used by ordinary builds. */
|
||||||
|
val trainlogVersionCode =
|
||||||
|
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
||||||
|
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
||||||
|
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||||
|
} ?: 3
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.labfytools.trainlog"
|
namespace = "com.labfytools.trainlog"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
|
|
@ -44,7 +54,7 @@ android {
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
versionCode = 3
|
versionCode = trainlogVersionCode
|
||||||
versionName = "0.1.2"
|
versionName = "0.1.2"
|
||||||
testInstrumentationRunner =
|
testInstrumentationRunner =
|
||||||
"androidx.test.runner.AndroidJUnitRunner"
|
"androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,27 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun awaitCorrelated(
|
||||||
|
path: File,
|
||||||
|
deadline: Long,
|
||||||
|
predicate: (JSONObject) -> Boolean,
|
||||||
|
): ByteArray {
|
||||||
|
while (true) {
|
||||||
|
if (path.isFile && path.length() <= 64 * 1024) {
|
||||||
|
try {
|
||||||
|
val raw = path.readBytes()
|
||||||
|
if (predicate(JSONObject(raw.toString(Charsets.UTF_8)))) return raw
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Publication replaces the file atomically, but an old or
|
||||||
|
// concurrently observed object is not evidence of failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (System.nanoTime() >= deadline)
|
||||||
|
throw SyncGenerationException("timeout waiting for correlated ${path.name}")
|
||||||
|
Thread.sleep(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun run(
|
fun run(
|
||||||
directory: File,
|
directory: File,
|
||||||
timeout: Duration = Duration.ofMinutes(5),
|
timeout: Duration = Duration.ofMinutes(5),
|
||||||
|
|
@ -88,13 +109,20 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
||||||
.toString(),
|
.toString(),
|
||||||
)
|
)
|
||||||
afterPublication?.invoke()
|
afterPublication?.invoke()
|
||||||
service.acceptAcknowledgement(
|
// CONTRACT: durable objects from earlier conversations remain in
|
||||||
await(File(directory, "desktop-consumption-ack-v1.json"), deadline).readBytes()
|
// the exchange directory. Only this run's exact generation ACK
|
||||||
)
|
// can advance its lineage; stale bytes are retained and ignored.
|
||||||
val reference =
|
val desktopAck =
|
||||||
JSONObject(
|
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline) {
|
||||||
await(File(directory, "desktop-generation-v1.json"), deadline).readText()
|
it.optString("run_id") == captured.runId &&
|
||||||
)
|
it.optString("generation_id") == captured.generationId
|
||||||
|
}
|
||||||
|
service.acceptAcknowledgement(desktopAck)
|
||||||
|
val referenceRaw =
|
||||||
|
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline) {
|
||||||
|
it.optString("run_id") == captured.runId
|
||||||
|
}
|
||||||
|
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
|
||||||
val acknowledgement =
|
val acknowledgement =
|
||||||
service.consume(File(directory, reference.getString("relative_path")))
|
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)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
sealed interface SyncRequestResult {
|
sealed interface SyncRequestResult {
|
||||||
|
/** The full-generation coordinator completed both durable peer ACKs. */
|
||||||
|
data class Completed(
|
||||||
|
val runId: String,
|
||||||
|
) : SyncRequestResult
|
||||||
|
|
||||||
data class Requested(
|
data class Requested(
|
||||||
val requestId: String,
|
val requestId: String,
|
||||||
) : SyncRequestResult
|
) : SyncRequestResult
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ internal suspend fun publishBundleAndRequest(
|
||||||
} catch (_: Exception) { false }
|
} catch (_: Exception) { false }
|
||||||
if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.")
|
if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.")
|
||||||
return@withContext when (val result = SyncGenerationForegroundCoordinator(repository).run(canonicalExchangeDirectory())) {
|
return@withContext when (val result = SyncGenerationForegroundCoordinator(repository).run(canonicalExchangeDirectory())) {
|
||||||
is ForegroundGenerationResult.Completed -> SyncRequestResult.Requested(result.runId)
|
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
|
||||||
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
|
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -336,6 +336,14 @@ fun SyncScreen(
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
when (val result = publishBundleAndRequest(repository, exporter, requestOutbox)) {
|
when (val result = publishBundleAndRequest(repository, exporter, requestOutbox)) {
|
||||||
|
is SyncRequestResult.Completed -> {
|
||||||
|
// The generation coordinator has already consumed the
|
||||||
|
// desktop return and published its durable ACK. Waiting
|
||||||
|
// for the legacy receipt here would leave a false spinner.
|
||||||
|
success = true
|
||||||
|
pendingRequestId = null
|
||||||
|
status = strings.getString(R.string.sync_generation_complete)
|
||||||
|
}
|
||||||
is SyncRequestResult.Requested -> {
|
is SyncRequestResult.Requested -> {
|
||||||
success = true
|
success = true
|
||||||
pendingRequestId = result.requestId
|
pendingRequestId = result.requestId
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@
|
||||||
<string name="sync_flow_description">Android → PC then PC → Android, in one operation.</string>
|
<string name="sync_flow_description">Android → PC then PC → Android, in one operation.</string>
|
||||||
<string name="sync_authorize_first">File access is required. Authorize Documents/Trainlog first.</string>
|
<string name="sync_authorize_first">File access is required. Authorize Documents/Trainlog first.</string>
|
||||||
<string name="sync_request_sent">Request sent · waiting for the PC…</string>
|
<string name="sync_request_sent">Request sent · waiting for the PC…</string>
|
||||||
|
<string name="sync_generation_complete">Synchronization completed on Android and PC.</string>
|
||||||
<string name="sync_android_unsupported">Android is not supported.</string>
|
<string name="sync_android_unsupported">Android is not supported.</string>
|
||||||
<string name="sync_folder_section">EXCHANGE FOLDER</string>
|
<string name="sync_folder_section">EXCHANGE FOLDER</string>
|
||||||
<string name="sync_authorize_folder">Authorize access to the Trainlog folder</string>
|
<string name="sync_authorize_folder">Authorize access to the Trainlog folder</string>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@
|
||||||
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
|
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
|
||||||
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
|
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
|
||||||
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
|
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
|
||||||
|
<string name="sync_generation_complete">Synchronisation terminée sur Android et PC.</string>
|
||||||
<string name="sync_android_unsupported">Android non supporté.</string>
|
<string name="sync_android_unsupported">Android non supporté.</string>
|
||||||
<string name="sync_folder_section">DOSSIER D’ÉCHANGE</string>
|
<string name="sync_folder_section">DOSSIER D’ÉCHANGE</string>
|
||||||
<string name="sync_authorize_folder">Autoriser l’accès au dossier Trainlog</string>
|
<string name="sync_authorize_folder">Autoriser l’accès au dossier Trainlog</string>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
|
@ -26,17 +27,19 @@ class BridgeBackupMigrationTest {
|
||||||
var repository = TrainlogRepository(context, name)
|
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() }
|
||||||
|
val historical = historicalDump(verified.database)
|
||||||
|
val activeDraft = historical.entries.single { it.key.startsWith("active_session_draft|") }
|
||||||
|
val hadActiveDraft = activeDraft.value.isNotEmpty()
|
||||||
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(
|
assertEquals(
|
||||||
"21,",
|
hadActiveDraft,
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded,
|
||||||
.draft
|
|
||||||
.form
|
|
||||||
.weightText,
|
|
||||||
)
|
)
|
||||||
android.database.sqlite.SQLiteDatabase.openDatabase(
|
val migrated = context.getDatabasePath(name)
|
||||||
context.getDatabasePath(name).path,
|
assertEquals(historical, historicalDump(migrated, historical.keys))
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
migrated.path,
|
||||||
null,
|
null,
|
||||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||||
)
|
)
|
||||||
|
|
@ -46,4 +49,81 @@ class BridgeBackupMigrationTest {
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONTRACT: every pre-migration column and value is compared exactly;
|
||||||
|
* additive v18-v20 tables/columns are outside the historical shape.
|
||||||
|
* INVARIANT: the supplied archive is opened read-only and only the
|
||||||
|
* test-owned restored database is migrated.
|
||||||
|
*/
|
||||||
|
private fun historicalDump(
|
||||||
|
databasePath: File,
|
||||||
|
shape: Set<String>? = null,
|
||||||
|
): Map<String, List<List<String>>> =
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
databasePath.path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
)
|
||||||
|
.use { database ->
|
||||||
|
val tables =
|
||||||
|
database
|
||||||
|
.rawQuery(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' " +
|
||||||
|
"AND name NOT LIKE 'sqlite_%' AND name<>'android_metadata' " +
|
||||||
|
"ORDER BY name;",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
.use { cursor ->
|
||||||
|
buildList {
|
||||||
|
while (cursor.moveToNext()) add(cursor.getString(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val specifications =
|
||||||
|
shape
|
||||||
|
?: tables
|
||||||
|
.map { table ->
|
||||||
|
val columns =
|
||||||
|
database.rawQuery("PRAGMA table_info(`$table`);", null).use {
|
||||||
|
cursor ->
|
||||||
|
buildList {
|
||||||
|
while (cursor.moveToNext()) add(cursor.getString(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"$table|${columns.joinToString(",")}"
|
||||||
|
}
|
||||||
|
.toSet()
|
||||||
|
specifications.associateWith { specification ->
|
||||||
|
val table = specification.substringBefore('|')
|
||||||
|
val columns = specification.substringAfter('|').split(',')
|
||||||
|
val selection = columns.joinToString(",") { "`$it`" }
|
||||||
|
database
|
||||||
|
.rawQuery(
|
||||||
|
"SELECT $selection FROM `$table` ORDER BY rowid;",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
.use { cursor ->
|
||||||
|
buildList {
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
add(
|
||||||
|
(0 until cursor.columnCount).map { column ->
|
||||||
|
when (cursor.getType(column)) {
|
||||||
|
android.database.Cursor.FIELD_TYPE_NULL -> "null"
|
||||||
|
android.database.Cursor.FIELD_TYPE_INTEGER ->
|
||||||
|
"integer:${cursor.getLong(column)}"
|
||||||
|
android.database.Cursor.FIELD_TYPE_FLOAT ->
|
||||||
|
"float:${java.lang.Double.toHexString(cursor.getDouble(column))}"
|
||||||
|
android.database.Cursor.FIELD_TYPE_STRING ->
|
||||||
|
"string:${cursor.getString(column)}"
|
||||||
|
android.database.Cursor.FIELD_TYPE_BLOB ->
|
||||||
|
"blob:${cursor.getBlob(column).contentToString()}"
|
||||||
|
else -> error("Unknown SQLite field type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,14 @@ class SyncDeploymentConversationTest {
|
||||||
var localWorker: Process? = null
|
var localWorker: Process? = null
|
||||||
try {
|
try {
|
||||||
transport.mkdirs()
|
transport.mkdirs()
|
||||||
|
// A previous interrupted exchange leaves durable coordination
|
||||||
|
// objects behind. The next run must wait for correlated replacements.
|
||||||
|
File(transport, "desktop-consumption-ack-v1.json").writeText(
|
||||||
|
"""{"run_id":"sy_11111111-1111-4111-8111-111111111111","generation_id":"gen_11111111-1111-4111-8111-111111111111"}"""
|
||||||
|
)
|
||||||
|
File(transport, "desktop-generation-v1.json").writeText(
|
||||||
|
"""{"run_id":"sy_11111111-1111-4111-8111-111111111111","generation_id":"gen_22222222-2222-4222-8222-222222222222"}"""
|
||||||
|
)
|
||||||
val exercise =
|
val exercise =
|
||||||
(repository.createExercise(
|
(repository.createExercise(
|
||||||
NewExerciseProfile(
|
NewExerciseProfile(
|
||||||
|
|
|
||||||
|
|
@ -956,3 +956,11 @@ The installed schema-v17 release has no backup UI. The reproducible
|
||||||
adds only the backup surface, assigns a higher candidate version code and
|
adds only the backup surface, assigns a higher candidate version code and
|
||||||
builds/tests an APK without installing it. Its synthetic v17 archive is also
|
builds/tests an APK without installing it. Its synthetic v17 archive is also
|
||||||
restored by current code and migrated through the normal v17-to-v20 path.
|
restored by current code and migrated through the normal v17-to-v20 path.
|
||||||
|
|
||||||
|
The authorized private rollout used the bridge only to add the backup surface
|
||||||
|
without clearing package data, verified the resulting plaintext archive, then
|
||||||
|
installed the current signed release variant through a strictly increasing
|
||||||
|
version-code chain. The final installed private candidate retains product
|
||||||
|
version 0.1.2 and schema v20. Its full-generation foreground coordinator waits
|
||||||
|
for correlated objects, tolerates stale objects left by interrupted runs, and
|
||||||
|
shows completion only after both durable peer acknowledgements.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
# Current implementation state
|
# Current implementation state
|
||||||
|
|
||||||
The development branch has opt-in full-generation coordination with durable Web
|
The opt-in full-generation path is operational on the private daily
|
||||||
progress and a foreground Android conversation. Its production generation MTP
|
installation. The loopback Web control and foreground Android coordinator use
|
||||||
adapter is integrated and tested through an object-I/O boundary double. Android
|
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
|
||||||
also has a complete same-installation backup/verify/restore container, and a
|
persistent peer identities. The controlled rollout verified real backups, the
|
||||||
non-installed schema-v17 bridge candidate is reproducibly built and tested.
|
schema-v17 to v20 Android update chain, desktop v18 to v21 migration, three
|
||||||
Physical-device MTP, release signing, real user backup/restore, main integration
|
complete hardware conversations, restart, and idempotent replay. Drive was not
|
||||||
and deployment are not complete.
|
configured and remains outside this local MTP result. This is a private 0.1.2
|
||||||
|
development deployment, not a public release; v0.1.1 is unchanged.
|
||||||
|
|
||||||
`TRAINLOG_NATIVE_BUILD_AND_CANDIDATE_V1` closes the native warning gate. Strict
|
`TRAINLOG_NATIVE_BUILD_AND_CANDIDATE_V1` closes the native warning gate. Strict
|
||||||
GCC 16.2.1 and Clang 22.1.8 builds now pass the same 81-test inventory, and the
|
GCC 16.2.1 and Clang 22.1.8 builds now pass the same 81-test inventory, and the
|
||||||
|
|
@ -75,6 +76,7 @@ parallel implementation of its rules.
|
||||||
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
|
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
|
||||||
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
|
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
|
||||||
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
|
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
|
||||||
|
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
|
||||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||||
| Complete synchronization gap contract | `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS` |
|
| Complete synchronization gap contract | `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS` |
|
||||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
|
|
|
||||||
67
docs/reviews/sync_rollout_v1_deployment.md
Normal file
67
docs/reviews/sync_rollout_v1_deployment.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Full-generation synchronization V1 private deployment record
|
||||||
|
|
||||||
|
Date: 2026-09-17
|
||||||
|
|
||||||
|
Status: `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS`
|
||||||
|
|
||||||
|
This record deliberately omits phone serials, peer IDs, generation IDs,
|
||||||
|
credential locations, user content, and private backup hashes. Complete causal
|
||||||
|
evidence is retained in the private rollout journal.
|
||||||
|
|
||||||
|
## Installed set
|
||||||
|
|
||||||
|
- Trainlog 0.1.2 development; desktop schema v21 and Android schema v20.
|
||||||
|
- Versioned relocatable desktop bundle behind stable user-bin symlinks.
|
||||||
|
- Android release-variant APK installed only through compatible signed updates;
|
||||||
|
the final private rollout version code is 6.
|
||||||
|
- Production direct-libmtp generation adapter with one explicitly paired peer.
|
||||||
|
- Loopback Web service and the user-session request daemon are active from the
|
||||||
|
same versioned installation. No live-checkout runtime helper is used.
|
||||||
|
|
||||||
|
## Data and migration evidence
|
||||||
|
|
||||||
|
- The original Android schema-v17 store was backed up through the signed bridge
|
||||||
|
UI and verified for manifest, digests, SQLite integrity, foreign keys and an
|
||||||
|
exact semantic match to the pre-update store. The real archive migrated on a
|
||||||
|
copy before live installation.
|
||||||
|
- A post-migration Android backup was also verified. The real source contained
|
||||||
|
no active draft, so draft restoration was correctly not applicable; no
|
||||||
|
synthetic draft was inserted.
|
||||||
|
- The desktop schema-v18 store was copied with SQLite's backup API, validated,
|
||||||
|
and migrated on an isolated copy before the live schema-v21 open. Historical
|
||||||
|
table values were preserved.
|
||||||
|
- Backups, failed generations, rejection ACKs and successful generation/ACK
|
||||||
|
evidence remain retained. No one-sided rollback was performed after causal
|
||||||
|
writes began.
|
||||||
|
|
||||||
|
## Operational evidence
|
||||||
|
|
||||||
|
- The actual foreground Android advertisement exposed the required persistent
|
||||||
|
identity and all five capabilities.
|
||||||
|
- Production libudev/libmtp discovery and object transfer completed on the
|
||||||
|
selected unlocked phone.
|
||||||
|
- Three final bidirectional conversations completed with a durable consumed ACK
|
||||||
|
on each peer and all eight required domains reported successful.
|
||||||
|
- A service/application restart and a second non-destructive exchange produced
|
||||||
|
no duplicate business data. Logical hashes for 23 desktop business tables
|
||||||
|
and 540 rows were identical before and after replay; integrity and foreign-key
|
||||||
|
checks passed.
|
||||||
|
- Android visibly returned to an enabled action and displayed synchronization
|
||||||
|
completion for both Android and PC.
|
||||||
|
|
||||||
|
## Rollout repairs
|
||||||
|
|
||||||
|
The real rollout exposed and closed four bounded defects: feedback idempotence
|
||||||
|
depended on the SQLite row representation; the desktop package omitted several
|
||||||
|
runtime exporters; Android accepted stale coordination filenames without
|
||||||
|
run/generation correlation; and its UI waited for a legacy receipt after the
|
||||||
|
generation coordinator had already completed. Regression tests cover each
|
||||||
|
affected boundary.
|
||||||
|
|
||||||
|
## Remaining limits
|
||||||
|
|
||||||
|
The phone must be connected, unlocked and in the foreground synchronization
|
||||||
|
flow. Retained outgoing generations remain subject to the explicit capacity of
|
||||||
|
eight per peer; evidence is never silently evicted. Drive was not configured or
|
||||||
|
tested. This private deployment does not publish 0.1.2, create a tag or release,
|
||||||
|
or alter the existing v0.1.1 release.
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
Before controlled rollout, complete and validate the physical libmtp generation
|
The controlled private rollout is complete for the paired daily installation:
|
||||||
adapter, establish a legitimate installed-Android backup path, verify release
|
physical libmtp, verified backups, signing continuity, migrations, correlated
|
||||||
signing continuity and obtain explicit rollout approval. Do not enable generation
|
ACKs, restart, and idempotent replay passed. Generation mode remains an
|
||||||
mode or advance `main` before those gates.
|
explicit per-installation opt-in; Drive is still unconfigured and unvalidated.
|
||||||
|
|
||||||
This document owns future work. The implemented baseline is summarized in
|
This document owns future work. The implemented baseline is summarized in
|
||||||
[current_state.md](current_state.md); completed narratives and evidence belong
|
[current_state.md](current_state.md); completed narratives and evidence belong
|
||||||
|
|
@ -38,7 +38,7 @@ visualizations are implemented; the other four Web routes remain placeholders.
|
||||||
## Current cursor
|
## Current cursor
|
||||||
|
|
||||||
```text
|
```text
|
||||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_DEPLOYMENT_READINESS_V1
|
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_WEB_V1_NEXT_CONTRACT
|
||||||
```
|
```
|
||||||
|
|
||||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||||
|
|
@ -47,9 +47,10 @@ cross-platform draft revision identity and resource-bound admission are closed.
|
||||||
`TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN`: both platforms expose explicit
|
`TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN`: both platforms expose explicit
|
||||||
coherent capture, immutable manifest publication, whole-generation
|
coherent capture, immutable manifest publication, whole-generation
|
||||||
transactional consumption, durable correlated ACK and restart/replay entry
|
transactional consumption, durable correlated ACK and restart/replay entry
|
||||||
points. The orchestrator, Web API and Web control are now complete behind an
|
points. The orchestrator, Web API and Web control are complete behind an
|
||||||
explicit trusted opt-in. Automatic V3 selection remains unchanged. The next
|
explicit trusted opt-in. The authorized private direct-MTP rollout passed on
|
||||||
boundary is deployment readiness; no rollout starts here.
|
real hardware; automatic V3 selection remains unchanged without that opt-in.
|
||||||
|
The next work returns to separately contracted Web surfaces.
|
||||||
|
|
||||||
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
|
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
|
||||||
defines the complete-sync target and its bounded dependency order.
|
defines the complete-sync target and its bounded dependency order.
|
||||||
|
|
|
||||||
|
|
@ -776,6 +776,19 @@ idempotently. Causal V1 operation bytes remain unchanged across first emission
|
||||||
and retransmission because generation membership lives only in
|
and retransmission because generation membership lives only in
|
||||||
`sync_causal_publications`.
|
`sync_causal_publications`.
|
||||||
|
|
||||||
|
The foreground Android coordinator correlates both durable desktop ACKs and
|
||||||
|
desktop generation references by the active run (and the Android generation
|
||||||
|
for its ACK). Objects retained from an interrupted conversation are ignored
|
||||||
|
until atomically replaced by matching objects. After both peer ACKs, Android
|
||||||
|
reports completion directly; it does not enter the legacy receipt wait state.
|
||||||
|
|
||||||
|
The 2026-09-17 private rollout exercised this path with the production libmtp
|
||||||
|
adapter on one paired Samsung phone. Three complete bidirectional conversations
|
||||||
|
passed, including service/application restart and an idempotent replay whose
|
||||||
|
23 desktop business tables and 540 rows retained identical logical hashes.
|
||||||
|
Rejected pre-fix generations and their ACKs were retained as causal evidence.
|
||||||
|
No Drive transport was configured or claimed by this validation.
|
||||||
|
|
||||||
The generation MTP adapter resolves `Documents/Trainlog` through libudev and
|
The generation MTP adapter resolves `Documents/Trainlog` through libudev and
|
||||||
libmtp, selects one exact advertised peer/capability set across bounded devices
|
libmtp, selects one exact advertised peer/capability set across bounded devices
|
||||||
and storages, and mirrors only bounded regular object names. It downloads to
|
and storages, and mirrors only bounded regular object names. It downloads to
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
# Full-generation sync rollout readiness
|
# Full-generation sync rollout readiness
|
||||||
|
|
||||||
## Deployment-readiness candidate
|
## Operational rollout status
|
||||||
|
|
||||||
|
`TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` on 2026-09-17 for the
|
||||||
|
authorized private daily installation. Verified real Android and desktop
|
||||||
|
backups were retained, migration copies passed before live updates, the signing
|
||||||
|
identity and increasing Android version-code chain were verified, and the
|
||||||
|
production libmtp adapter completed bidirectional manifest/ACK conversations.
|
||||||
|
Restart and idempotent replay also passed. Drive was not configured. No public
|
||||||
|
release or tag was created, and v0.1.1 was not modified. The sanitized evidence
|
||||||
|
record is [sync rollout V1 deployment](reviews/sync_rollout_v1_deployment.md).
|
||||||
|
|
||||||
|
## Deployment-readiness contract
|
||||||
|
|
||||||
The generation path remains disabled unless desktop configuration authorizes a
|
The generation path remains disabled unless desktop configuration authorizes a
|
||||||
specific persisted Android peer and Android contains the strict opt-in document
|
specific persisted Android peer and Android contains the strict opt-in document
|
||||||
|
|
@ -10,12 +21,10 @@ bounded typed progress and its descendant process group. The foreground Android
|
||||||
coordinator advertises its real peer identity, publishes manifest-last bytes,
|
coordinator advertises its real peer identity, publishes manifest-last bytes,
|
||||||
consumes the return generation and emits a durable correlated ACK.
|
consumes the return generation and emits a durable correlated ACK.
|
||||||
|
|
||||||
The integrated proof uses the production generation adapter with only its
|
The isolated proof still uses the production generation adapter with only its
|
||||||
typed libudev/libmtp I/O callbacks replaced by an object-store double. This is
|
typed libudev/libmtp I/O callbacks replaced by an object-store double. The
|
||||||
software evidence for selection, bounds, publication ordering and the complete
|
separate deployment record owns the physical-phone evidence; neither proof
|
||||||
conversation, but not evidence for a particular phone's physical libmtp
|
generalizes pairing to another phone or enables generation mode by default.
|
||||||
behavior. Generation mode must remain disabled until that hardware gate and
|
|
||||||
the separately authorized rollout are complete.
|
|
||||||
|
|
||||||
### Candidate, backup and recovery
|
### Candidate, backup and recovery
|
||||||
|
|
||||||
|
|
@ -26,9 +35,17 @@ the separately authorized rollout are complete.
|
||||||
`trainlog-syncd`, the production generation MTP adapter and their matching
|
`trainlog-syncd`, the production generation MTP adapter and their matching
|
||||||
Python codecs. User units must point only into one versioned bundle; they
|
Python codecs. User units must point only into one versioned bundle; they
|
||||||
must not combine a packaged binary with helpers from a live checkout.
|
must not combine a packaged binary with helpers from a live checkout.
|
||||||
|
Stable user-bin symlinks are supported: each launcher resolves the link to
|
||||||
|
its physical bundle before locating `libexec` and `tools`.
|
||||||
|
Packaging validation imports the production orchestrator and peer worker
|
||||||
|
from the staged `tools` directory so their complete local module closure is
|
||||||
|
present before installation.
|
||||||
2. `assembleDebug` is development evidence only. Rollout requires the existing
|
2. `assembleDebug` is development evidence only. Rollout requires the existing
|
||||||
release signing identity, verification of its public certificate and a valid
|
release signing identity, verification of its public certificate and a valid
|
||||||
increasing `versionCode`; this mission reads no signing secret.
|
increasing `versionCode`. A bounded `TRAINLOG_ANDROID_VERSION_CODE` build
|
||||||
|
override may establish an update-only private rollout chain without changing
|
||||||
|
the product version, schema or protocol. The bridge recipe also produces a
|
||||||
|
signed release variant for installation; debug APKs remain test evidence.
|
||||||
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
|
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
|
||||||
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
|
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
|
||||||
foreign-key checks.
|
foreign-key checks.
|
||||||
|
|
@ -46,12 +63,10 @@ the separately authorized rollout are complete.
|
||||||
preserve pending generations, tombstones, finalizations and ACK ledgers; it
|
preserve pending generations, tombstones, finalizations and ACK ledgers; it
|
||||||
must never trigger hidden eviction.
|
must never trigger hidden eviction.
|
||||||
|
|
||||||
Actual rollout needs one separate approval for the signed APK, desktop bundle,
|
The grouped rollout approval covered the signed APK, desktop bundle, verified
|
||||||
verified user backups, coordinated service quiescence, pairing, synthetic smoke
|
user backups, coordinated service quiescence, pairing, synthetic smoke and
|
||||||
and non-destructive hardware smoke. Drive remains separate and untested here.
|
non-destructive hardware smoke. Those steps are complete for the recorded
|
||||||
|
private installation. Drive remains separate and untested.
|
||||||
This is a non-executed rollout checklist. The development branch implements the
|
|
||||||
path; the daily installation still uses V3.
|
|
||||||
|
|
||||||
The latest software-only candidate set was rebuilt under the private root
|
The latest software-only candidate set was rebuilt under the private root
|
||||||
`/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit
|
`/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit
|
||||||
|
|
@ -98,5 +113,6 @@ operator diagnosis; this tranche adds no tombstone or evidence garbage
|
||||||
collection. A migrated database is recovered from its verified backup, never by
|
collection. A migrated database is recovered from its verified backup, never by
|
||||||
assuming an older binary reverses its migration.
|
assuming an older binary reverses its migration.
|
||||||
|
|
||||||
Physical MTP, actual Drive configuration, service coordination, installed APK,
|
The recorded private installation completed physical MTP, service coordination
|
||||||
main merge and deployment are intentionally not performed here.
|
and APK/desktop deployment. Actual Drive configuration remains untested; the
|
||||||
|
eight-generation admission bound and foreground-phone requirement remain.
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,11 @@ canonical stable Android artifact. Release assets must come from the exact
|
||||||
tagged source state, and the tag, Android `versionName`, Meson project version,
|
tagged source state, and the tag, Android `versionName`, Meson project version,
|
||||||
TUI product version, asset names, and release title must agree.
|
TUI product version, asset names, and release title must agree.
|
||||||
|
|
||||||
|
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive
|
||||||
|
integer while keeping `versionName=0.1.2`. This build-only override is used only
|
||||||
|
to maintain a strictly increasing installed → backup bridge → current APK
|
||||||
|
chain; it does not change any schema or exchange-format version.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
meson setup --reconfigure build
|
meson setup --reconfigure build
|
||||||
meson compile -C build
|
meson compile -C build
|
||||||
|
|
@ -1349,3 +1354,24 @@ and the version-thirteen and version-twelve methods in
|
||||||
bridge backup input, a real v9 fixture, a real v14 fixture/output directory,
|
bridge backup input, a real v9 fixture, a real v14 fixture/output directory,
|
||||||
and real v13/v12 fixtures with explicit output directories. Synthetic bridge
|
and real v13/v12 fixtures with explicit output directories. Synthetic bridge
|
||||||
and current-schema migration proofs run separately; no user database is read.
|
and current-schema migration proofs run separately; no user database is read.
|
||||||
|
|
||||||
|
The bridge migration test accepts both legitimate backup states: an active
|
||||||
|
draft may be present or absent. It snapshots every original table, column,
|
||||||
|
SQLite value type and value before restore, migrates through the production
|
||||||
|
repository owner, and compares that complete historical shape afterward. The
|
||||||
|
draft assertion follows the archived state instead of requiring the synthetic
|
||||||
|
fixture's sample draft.
|
||||||
|
|
||||||
|
## Private full-generation rollout validation
|
||||||
|
|
||||||
|
The authorized 2026-09-17 rollout added regression coverage for production
|
||||||
|
failures found only after installation: feedback import under `sqlite3.Row`,
|
||||||
|
the complete exporter inventory of the relocatable package, stale Android ACK
|
||||||
|
and generation-reference correlation, and the completed-generation UI state.
|
||||||
|
The real direct-libmtp smoke then completed three bidirectional conversations
|
||||||
|
on the paired phone. Restart and replay preserved identical logical hashes for
|
||||||
|
23 desktop business tables and 540 rows; SQLite integrity and foreign-key
|
||||||
|
checks passed, both outgoing desktop generations were acknowledged, and the
|
||||||
|
Android UI displayed completion. The private journal retains exact IDs and
|
||||||
|
hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone
|
||||||
|
was used only for non-destructive install, backup and manual sync validation.
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,30 @@ class DeploymentToolsTest(unittest.TestCase):
|
||||||
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
||||||
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
||||||
self.assertTrue((output / "libexec/trainlog-sync-once").is_file())
|
self.assertTrue((output / "libexec/trainlog-sync-once").is_file())
|
||||||
|
stable_bin = Path(directory) / "stable-bin"
|
||||||
|
stable_bin.mkdir()
|
||||||
|
(stable_bin / "trainlog").symlink_to(output / "bin/trainlog")
|
||||||
|
linked = subprocess.run(
|
||||||
|
[str(stable_bin / "trainlog"), "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(linked.returncode, 0, linked.stderr)
|
||||||
|
self.assertIn("trainlog 0.1.2", linked.stdout)
|
||||||
|
imported = subprocess.run(
|
||||||
|
[
|
||||||
|
"python3",
|
||||||
|
"-c",
|
||||||
|
"import pathlib; import sync_peer_worker; import sync_orchestrator; "
|
||||||
|
"import sync_generation_exchange as g; "
|
||||||
|
"assert all((pathlib.Path.cwd()/row[5]).is_file() "
|
||||||
|
"for row in g.ARTIFACTS)",
|
||||||
|
],
|
||||||
|
cwd=output / "tools",
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(imported.returncode, 0, imported.stderr)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any(
|
any(
|
||||||
row["path"] == "tools/trainlog_syncd.py"
|
row["path"] == "tools/trainlog_syncd.py"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ def main():
|
||||||
out=d/"out.json";run("python",ROOT/"tools/export_training_feedback.py",out,"--database",db);x=json.loads(out.read_text());assert x["version"]==2 and len(x["exercise_feedback"])==1 and len(x["session_followups"])==2
|
out=d/"out.json";run("python",ROOT/"tools/export_training_feedback.py",out,"--database",db);x=json.loads(out.read_text());assert x["version"]==2 and len(x["exercise_feedback"])==1 and len(x["session_followups"])==2
|
||||||
revision={"revision_id":"fr_55555555-5555-4555-8555-555555555555","created_at":"2026-09-03T10:00:00+02:00","raw_text":"ressenti corrigé"};x["exercise_feedback"][0]["revisions"].append(revision);artifact.write_text(json.dumps(x),encoding="utf8")
|
revision={"revision_id":"fr_55555555-5555-4555-8555-555555555555","created_at":"2026-09-03T10:00:00+02:00","raw_text":"ressenti corrigé"};x["exercise_feedback"][0]["revisions"].append(revision);artifact.write_text(json.dumps(x),encoding="utf8")
|
||||||
run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db);again=run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db).stdout;assert "revisions_added=0" in again
|
run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db);again=run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db).stdout;assert "revisions_added=0" in again
|
||||||
|
# The generation worker imports history first, leaving sqlite3.Row enabled
|
||||||
|
# on the shared connection. Identical feedback must remain idempotent there.
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0,str(ROOT/"tools"))
|
||||||
|
from import_training_feedback import apply_feedback,load
|
||||||
|
con=sqlite3.connect(db);con.row_factory=sqlite3.Row
|
||||||
|
con.execute("BEGIN IMMEDIATE");assert apply_feedback(con,load(artifact))==(0,3,0,4);con.rollback();con.close()
|
||||||
con=sqlite3.connect(db);assert con.execute("select count(*) from exercise_feedback_revisions").fetchone()[0]==2;assert con.execute("select raw_text from exercise_feedback").fetchone()[0]=="ressenti corrigé";con.close()
|
con=sqlite3.connect(db);assert con.execute("select count(*) from exercise_feedback_revisions").fetchone()[0]==2;assert con.execute("select raw_text from exercise_feedback").fetchone()[0]=="ressenti corrigé";con.close()
|
||||||
x["exercise_feedback"][0]["revisions"][-1]["raw_text"]="conflit";artifact.write_text(json.dumps(x),encoding="utf8");bad=subprocess.run(["python",str(ROOT/"tools/import_training_feedback.py"),str(artifact),"--database",str(db)],capture_output=True);assert bad.returncode!=0
|
x["exercise_feedback"][0]["revisions"][-1]["raw_text"]="conflit";artifact.write_text(json.dumps(x),encoding="utf8");bad=subprocess.run(["python",str(ROOT/"tools/import_training_feedback.py"),str(artifact),"--database",str(db)],capture_output=True);assert bad.returncode!=0
|
||||||
print("TRAINING_FEEDBACK_SYNC_TEST=PASS")
|
print("TRAINING_FEEDBACK_SYNC_TEST=PASS")
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ def main():
|
||||||
":app:testDebugUnitTest",
|
":app:testDebugUnitTest",
|
||||||
"--tests",
|
"--tests",
|
||||||
"com.labfytools.trainlog.data.BackupBridgeServiceTest",
|
"com.labfytools.trainlog.data.BackupBridgeServiceTest",
|
||||||
":app:assembleDebug",
|
":app:assembleRelease",
|
||||||
],
|
],
|
||||||
cwd=android,
|
cwd=android,
|
||||||
env=env,
|
env=env,
|
||||||
|
|
@ -105,7 +105,7 @@ def main():
|
||||||
print(
|
print(
|
||||||
f"BRIDGE_RECIPE={subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()}"
|
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/release/app-release.apk'}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,10 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
||||||
for revision in revisions:
|
for revision in revisions:
|
||||||
old=db.execute(f"SELECT {parent_column},created_at,raw_text FROM {table} WHERE revision_id=?",(revision[0],)).fetchone(); expected=(parent_id,revision[1],revision[2])
|
old=db.execute(f"SELECT {parent_column},created_at,raw_text FROM {table} WHERE revision_id=?",(revision[0],)).fetchone(); expected=(parent_id,revision[1],revision[2])
|
||||||
if old is None:db.execute(f"INSERT INTO {table}(revision_id,{parent_column},created_at,raw_text) VALUES(?,?,?,?)",(revision[0],*expected));added+=1
|
if old is None:db.execute(f"INSERT INTO {table}(revision_id,{parent_column},created_at,raw_text) VALUES(?,?,?,?)",(revision[0],*expected));added+=1
|
||||||
elif old==expected:skipped+=1
|
# INVARIANT: generation workers reuse the history importer connection,
|
||||||
|
# which installs sqlite3.Row. Idempotence compares persisted values,
|
||||||
|
# never the process-local row representation selected by a prior step.
|
||||||
|
elif tuple(old)==expected:skipped+=1
|
||||||
else:fail("HARD CONFLICT "+revision[0])
|
else:fail("HARD CONFLICT "+revision[0])
|
||||||
current=max(db.execute(f"SELECT revision_id,created_at,raw_text FROM {table} WHERE {parent_column}=?",(parent_id,)).fetchall(),key=lambda r:(instant(r[1]),r[0].encode()))
|
current=max(db.execute(f"SELECT revision_id,created_at,raw_text FROM {table} WHERE {parent_column}=?",(parent_id,)).fetchall(),key=lambda r:(instant(r[1]),r[0].encode()))
|
||||||
return added,skipped,current[2]
|
return added,skipped,current[2]
|
||||||
|
|
@ -63,7 +66,7 @@ def apply_feedback(db,root,complete_causal_envelope=False):
|
||||||
if not canonical or canonical[0]!=occurrence[1]:fail("exercise_id incompatible avec l'occurrence")
|
if not canonical or canonical[0]!=occurrence[1]:fail("exercise_id incompatible avec l'occurrence")
|
||||||
old=db.execute("SELECT session_exercise_row_id,observed_at FROM exercise_feedback WHERE feedback_id=?",(fid,)).fetchone();expected=(occurrence[0],item["observed_at"]);revisions=revision_list(item,fid,root["version"])
|
old=db.execute("SELECT session_exercise_row_id,observed_at FROM exercise_feedback WHERE feedback_id=?",(fid,)).fetchone();expected=(occurrence[0],item["observed_at"]);revisions=revision_list(item,fid,root["version"])
|
||||||
if old is None:db.execute("INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) VALUES(?,?,?,?)",(fid,*expected,revisions[0][2]));roots_added+=1
|
if old is None:db.execute("INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) VALUES(?,?,?,?)",(fid,*expected,revisions[0][2]));roots_added+=1
|
||||||
elif old==expected:roots_skipped+=1
|
elif tuple(old)==expected:roots_skipped+=1
|
||||||
else:fail("HARD CONFLICT "+fid)
|
else:fail("HARD CONFLICT "+fid)
|
||||||
added,skipped,current=merge_revisions(db,"exercise_feedback_revisions","feedback_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE exercise_feedback SET raw_text=? WHERE feedback_id=?",(current,fid))
|
added,skipped,current=merge_revisions(db,"exercise_feedback_revisions","feedback_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE exercise_feedback SET raw_text=? WHERE feedback_id=?",(current,fid))
|
||||||
seen=set()
|
seen=set()
|
||||||
|
|
@ -76,7 +79,7 @@ def apply_feedback(db,root,complete_causal_envelope=False):
|
||||||
if not session:fail("session introuvable")
|
if not session:fail("session introuvable")
|
||||||
old=db.execute("SELECT session_row_id,observed_at FROM session_followups WHERE followup_id=?",(fid,)).fetchone();expected=(session[0],item["observed_at"]);revisions=revision_list(item,fid,root["version"])
|
old=db.execute("SELECT session_row_id,observed_at FROM session_followups WHERE followup_id=?",(fid,)).fetchone();expected=(session[0],item["observed_at"]);revisions=revision_list(item,fid,root["version"])
|
||||||
if old is None:db.execute("INSERT INTO session_followups(followup_id,session_row_id,observed_at,raw_text) VALUES(?,?,?,?)",(fid,*expected,revisions[0][2]));roots_added+=1
|
if old is None:db.execute("INSERT INTO session_followups(followup_id,session_row_id,observed_at,raw_text) VALUES(?,?,?,?)",(fid,*expected,revisions[0][2]));roots_added+=1
|
||||||
elif old==expected:roots_skipped+=1
|
elif tuple(old)==expected:roots_skipped+=1
|
||||||
else:fail("HARD CONFLICT "+fid)
|
else:fail("HARD CONFLICT "+fid)
|
||||||
added,skipped,current=merge_revisions(db,"session_followup_revisions","followup_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE session_followups SET raw_text=? WHERE followup_id=?",(current,fid))
|
added,skipped,current=merge_revisions(db,"session_followup_revisions","followup_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE session_followups SET raw_text=? WHERE followup_id=?",(current,fid))
|
||||||
return roots_added,roots_skipped,revisions_added,revisions_skipped
|
return roots_added,roots_skipped,revisions_added,revisions_skipped
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ RUNTIME_TOOLS = [
|
||||||
"sync_generation_exchange.py",
|
"sync_generation_exchange.py",
|
||||||
"trainlog_sqlite.py",
|
"trainlog_sqlite.py",
|
||||||
"validate_json.py",
|
"validate_json.py",
|
||||||
|
"validate_training_knowledge.py",
|
||||||
|
"exercise_names.py",
|
||||||
"import_mobile_export.py",
|
"import_mobile_export.py",
|
||||||
"import_exercise_aliases.py",
|
"import_exercise_aliases.py",
|
||||||
"import_equipment_definitions.py",
|
"import_equipment_definitions.py",
|
||||||
|
|
@ -27,10 +29,14 @@ RUNTIME_TOOLS = [
|
||||||
"causal_delete_exchange.py",
|
"causal_delete_exchange.py",
|
||||||
"import_exercise_profile_state.py",
|
"import_exercise_profile_state.py",
|
||||||
"export_exercise_profile_state.py",
|
"export_exercise_profile_state.py",
|
||||||
|
"export_pc_catalog.py",
|
||||||
"export_pc_mobile.py",
|
"export_pc_mobile.py",
|
||||||
|
"export_exercise_aliases.py",
|
||||||
"export_equipment_definitions.py",
|
"export_equipment_definitions.py",
|
||||||
|
"export_equipment_associations.py",
|
||||||
"export_exercise_body_zones.py",
|
"export_exercise_body_zones.py",
|
||||||
"export_training_feedback.py",
|
"export_training_feedback.py",
|
||||||
|
"export_ai_session_drafts.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,15 +77,15 @@ def main() -> int:
|
||||||
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(
|
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'
|
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR="$root/tools"\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec "$root/libexec/trainlog" "$@"\n'
|
||||||
)
|
)
|
||||||
sync_once_launcher = args.output / "bin/trainlog-sync-once"
|
sync_once_launcher = args.output / "bin/trainlog-sync-once"
|
||||||
sync_once_launcher.write_text(
|
sync_once_launcher.write_text(
|
||||||
'#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)\nexec "$root/libexec/trainlog-sync-once" "$@"\n'
|
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexec "$root/libexec/trainlog-sync-once" "$@"\n'
|
||||||
)
|
)
|
||||||
syncd_launcher = args.output / "bin/trainlog-syncd"
|
syncd_launcher = args.output / "bin/trainlog-syncd"
|
||||||
syncd_launcher.write_text(
|
syncd_launcher.write_text(
|
||||||
'#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n'
|
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\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 = {
|
||||||
|
|
@ -114,6 +120,7 @@ def main() -> int:
|
||||||
"libudev",
|
"libudev",
|
||||||
"libmtp",
|
"libmtp",
|
||||||
"notcurses",
|
"notcurses",
|
||||||
|
"coreutils-readlink",
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import { beforeEach,describe,expect,it,vi } from 'vitest'
|
import { beforeEach,describe,expect,it,vi } from 'vitest'
|
||||||
import { fetchSyncStatus,startSync,syncIsActive } from './sync'
|
import { fetchSyncStatus, newRequestId, startSync, syncIsActive } from './sync'
|
||||||
const token='b'.repeat(64)
|
const token='b'.repeat(64)
|
||||||
function response(value:unknown,status=200){return {ok:status>=200&&status<300,status,headers:new Headers({'X-Trainlog-CSRF-Token':token}),json:()=>Promise.resolve(value)}}
|
function response(value:unknown,status=200){return {ok:status>=200&&status<300,status,headers:new Headers({'X-Trainlog-CSRF-Token':token}),json:()=>Promise.resolve(value)}}
|
||||||
describe('sync API',()=>{beforeEach(()=>vi.restoreAllMocks())
|
describe('sync API',()=>{beforeEach(()=>vi.restoreAllMocks())
|
||||||
it('obtient passivement le statut et utilise le jeton pour la mutation',async()=>{const fetch=vi.fn().mockResolvedValueOnce(response({phase:'idle',result:'disabled'})).mockResolvedValueOnce(response({phase:'requested',result:'running'},202));vi.stubGlobal('fetch',fetch);await fetchSyncStatus();await startSync('sy_11111111-1111-4111-8111-111111111111');expect(fetch).toHaveBeenCalledTimes(2);const init=fetch.mock.calls[1][1];expect(init.method).toBe('POST');expect(init.headers['X-Trainlog-CSRF-Token']).toBe(token)})
|
it('obtient passivement le statut et utilise le jeton pour la mutation',async()=>{const fetch=vi.fn().mockResolvedValueOnce(response({phase:'idle',result:'disabled'})).mockResolvedValueOnce(response({phase:'requested',result:'running'},202));vi.stubGlobal('fetch',fetch);await fetchSyncStatus();await startSync('sy_11111111-1111-4111-8111-111111111111');expect(fetch).toHaveBeenCalledTimes(2);const init=fetch.mock.calls[1][1];expect(init.method).toBe('POST');expect(init.headers['X-Trainlog-CSRF-Token']).toBe(token)})
|
||||||
it('rejette un statut inconnu et classe les phases actives',async()=>{vi.stubGlobal('fetch',vi.fn().mockResolvedValue(response({phase:'invented',result:'ok'})));await expect(fetchSyncStatus()).rejects.toThrow();expect(syncIsActive('waiting_acknowledgement')).toBe(true);expect(syncIsActive('completed')).toBe(false)})
|
it('rejette un statut inconnu et classe les phases actives',async()=>{vi.stubGlobal('fetch',vi.fn().mockResolvedValue(response({phase:'invented',result:'ok'})));await expect(fetchSyncStatus()).rejects.toThrow();expect(syncIsActive('waiting_acknowledgement')).toBe(true);expect(syncIsActive('completed')).toBe(false)})
|
||||||
|
it('crée un UUIDv4 sans dépendre de randomUUID', () => {
|
||||||
|
vi.stubGlobal('crypto', {
|
||||||
|
getRandomValues: (bytes: Uint8Array) => {
|
||||||
|
bytes.set(Array.from({ length: 16 }, (_, index) => index))
|
||||||
|
return bytes
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(newRequestId()).toBe('sy_00010203-0405-4607-8809-0a0b0c0d0e0f')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,25 @@ const phases=new Set<SyncPhase>(['idle','requested','waiting_android_publication
|
||||||
function parse(value:unknown):SyncStatus { if(typeof value!=='object'||value===null)throw new Error('statut sync invalide');const status=value as Partial<SyncStatus>;if(typeof status.phase!=='string'||!phases.has(status.phase as SyncPhase)||typeof status.result!=='string')throw new Error('statut sync invalide');if(status.drafts!==undefined&&(!Array.isArray(status.drafts)||status.drafts.length>32))throw new Error('résumé des brouillons invalide');return status as SyncStatus }
|
function parse(value:unknown):SyncStatus { if(typeof value!=='object'||value===null)throw new Error('statut sync invalide');const status=value as Partial<SyncStatus>;if(typeof status.phase!=='string'||!phases.has(status.phase as SyncPhase)||typeof status.result!=='string')throw new Error('statut sync invalide');if(status.drafts!==undefined&&(!Array.isArray(status.drafts)||status.drafts.length>32))throw new Error('résumé des brouillons invalide');return status as SyncStatus }
|
||||||
export async function fetchSyncStatus(signal?:AbortSignal):Promise<SyncStatus>{const response=await fetch('/api/v1/sync/status',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`sync status HTTP ${response.status}`);const token=response.headers.get('X-Trainlog-CSRF-Token')??'';if(!/^[0-9a-f]{64}$/.test(token))throw new Error('jeton CSRF absent');csrfToken=token;return parse(await response.json())}
|
export async function fetchSyncStatus(signal?:AbortSignal):Promise<SyncStatus>{const response=await fetch('/api/v1/sync/status',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`sync status HTTP ${response.status}`);const token=response.headers.get('X-Trainlog-CSRF-Token')??'';if(!/^[0-9a-f]{64}$/.test(token))throw new Error('jeton CSRF absent');csrfToken=token;return parse(await response.json())}
|
||||||
export async function startSync(requestId:string):Promise<SyncStatus>{if(!/^sy_[0-9a-f-]{36}$/.test(requestId)||csrfToken.length!==64)throw new Error('requête sync invalide');const response=await fetch('/api/v1/sync',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':csrfToken},body:JSON.stringify({request_id:requestId,trigger:'web'})});const value=await response.json();if(!response.ok)throw new Error(typeof value?.error==='string'?value.error:`sync HTTP ${response.status}`);return parse(value)}
|
export async function startSync(requestId:string):Promise<SyncStatus>{if(!/^sy_[0-9a-f-]{36}$/.test(requestId)||csrfToken.length!==64)throw new Error('requête sync invalide');const response=await fetch('/api/v1/sync',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':csrfToken},body:JSON.stringify({request_id:requestId,trigger:'web'})});const value=await response.json();if(!response.ok)throw new Error(typeof value?.error==='string'?value.error:`sync HTTP ${response.status}`);return parse(value)}
|
||||||
export function newRequestId():string{return `sy_${crypto.randomUUID()}`}
|
export function newRequestId(): string {
|
||||||
|
/*
|
||||||
|
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
|
||||||
|
* context, so randomUUID() is legitimately unavailable there.
|
||||||
|
* CONTRACT: getRandomValues() supplies the UUID entropy; Trainlog sets the
|
||||||
|
* RFC 4122 version/variant bits and never falls back to Math.random().
|
||||||
|
*/
|
||||||
|
const bytes = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||||
|
const hexadecimal = Array.from(bytes, (value) => value.toString(16).padStart(2, '0'))
|
||||||
|
const uuid = [
|
||||||
|
hexadecimal.slice(0, 4).join(''),
|
||||||
|
hexadecimal.slice(4, 6).join(''),
|
||||||
|
hexadecimal.slice(6, 8).join(''),
|
||||||
|
hexadecimal.slice(8, 10).join(''),
|
||||||
|
hexadecimal.slice(10, 16).join(''),
|
||||||
|
].join('-')
|
||||||
|
return `sy_${uuid}`
|
||||||
|
}
|
||||||
export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}
|
export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue