Compare commits

...

10 commits

24 changed files with 440 additions and 57 deletions

View file

@ -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

View file

@ -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:

View file

@ -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"

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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")
}
}
)
}
}
}
}
}
} }

View file

@ -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(

View file

@ -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.

View file

@ -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` |

View 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.

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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"

View file

@ -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")

View file

@ -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__":

View file

@ -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

View file

@ -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": [
{ {

View file

@ -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')
})
}) })

View file

@ -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)}