Compare commits
No commits in common. "56ac1cdfd2ec206d134d8664a577999f50d09cb5" and "8aa3c2095448a3e9e7ac5fa5d37158565462c841" have entirely different histories.
56ac1cdfd2
...
8aa3c20954
24 changed files with 57 additions and 440 deletions
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -2,39 +2,10 @@
|
|||
|
||||
## 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
|
||||
candidate so user services cannot mix a versioned install with live-checkout
|
||||
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
|
||||
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
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -91,16 +91,6 @@ Android and the TUI in each stable release share the same Trainlog product
|
|||
version on both mirrors. Database schemas and JSON protocol versions are
|
||||
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
|
||||
|
||||
Desktop:
|
||||
|
|
|
|||
|
|
@ -35,16 +35,6 @@ val releaseSigningComplete = listOf(
|
|||
releaseKeyPassword,
|
||||
).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 {
|
||||
namespace = "com.labfytools.trainlog"
|
||||
compileSdk = 37
|
||||
|
|
@ -54,7 +44,7 @@ android {
|
|||
minSdk = 26
|
||||
targetSdk = 36
|
||||
|
||||
versionCode = trainlogVersionCode
|
||||
versionCode = 3
|
||||
versionName = "0.1.2"
|
||||
testInstrumentationRunner =
|
||||
"androidx.test.runner.AndroidJUnitRunner"
|
||||
|
|
|
|||
|
|
@ -36,27 +36,6 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
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(
|
||||
directory: File,
|
||||
timeout: Duration = Duration.ofMinutes(5),
|
||||
|
|
@ -109,20 +88,13 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
.toString(),
|
||||
)
|
||||
afterPublication?.invoke()
|
||||
// CONTRACT: durable objects from earlier conversations remain in
|
||||
// the exchange directory. Only this run's exact generation ACK
|
||||
// can advance its lineage; stale bytes are retained and ignored.
|
||||
val desktopAck =
|
||||
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline) {
|
||||
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))
|
||||
service.acceptAcknowledgement(
|
||||
await(File(directory, "desktop-consumption-ack-v1.json"), deadline).readBytes()
|
||||
)
|
||||
val reference =
|
||||
JSONObject(
|
||||
await(File(directory, "desktop-generation-v1.json"), deadline).readText()
|
||||
)
|
||||
val acknowledgement =
|
||||
service.consume(File(directory, reference.getString("relative_path")))
|
||||
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
|
||||
|
|
|
|||
|
|
@ -14,11 +14,6 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.withContext
|
||||
|
||||
sealed interface SyncRequestResult {
|
||||
/** The full-generation coordinator completed both durable peer ACKs. */
|
||||
data class Completed(
|
||||
val runId: String,
|
||||
) : SyncRequestResult
|
||||
|
||||
data class Requested(
|
||||
val requestId: String,
|
||||
) : SyncRequestResult
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ internal suspend fun publishBundleAndRequest(
|
|||
} catch (_: Exception) { false }
|
||||
if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.")
|
||||
return@withContext when (val result = SyncGenerationForegroundCoordinator(repository).run(canonicalExchangeDirectory())) {
|
||||
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
|
||||
is ForegroundGenerationResult.Completed -> SyncRequestResult.Requested(result.runId)
|
||||
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
|
||||
}
|
||||
}
|
||||
|
|
@ -336,14 +336,6 @@ fun SyncScreen(
|
|||
coroutineScope.launch {
|
||||
try {
|
||||
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 -> {
|
||||
success = true
|
||||
pendingRequestId = result.requestId
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@
|
|||
<string name="sync_flow_description">Android → PC then PC → Android, in one operation.</string>
|
||||
<string name="sync_authorize_first">File access is required. Authorize Documents/Trainlog first.</string>
|
||||
<string name="sync_request_sent">Request sent · waiting for the PC…</string>
|
||||
<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_folder_section">EXCHANGE FOLDER</string>
|
||||
<string name="sync_authorize_folder">Authorize access to the Trainlog folder</string>
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@
|
|||
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
|
||||
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
|
||||
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
|
||||
<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_folder_section">DOSSIER D’ÉCHANGE</string>
|
||||
<string name="sync_authorize_folder">Autoriser l’accès au dossier Trainlog</string>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
|
@ -27,19 +26,17 @@ class BridgeBackupMigrationTest {
|
|||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
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)
|
||||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(
|
||||
hadActiveDraft,
|
||||
repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded,
|
||||
"21,",
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
||||
.draft
|
||||
.form
|
||||
.weightText,
|
||||
)
|
||||
val migrated = context.getDatabasePath(name)
|
||||
assertEquals(historical, historicalDump(migrated, historical.keys))
|
||||
SQLiteDatabase.openDatabase(
|
||||
migrated.path,
|
||||
android.database.sqlite.SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
|
|
@ -49,81 +46,4 @@ class BridgeBackupMigrationTest {
|
|||
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,14 +57,6 @@ class SyncDeploymentConversationTest {
|
|||
var localWorker: Process? = null
|
||||
try {
|
||||
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 =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
|
|
|
|||
|
|
@ -956,11 +956,3 @@ The installed schema-v17 release has no backup UI. The reproducible
|
|||
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
|
||||
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,13 +1,12 @@
|
|||
# Current implementation state
|
||||
|
||||
The opt-in full-generation path is operational on the private daily
|
||||
installation. The loopback Web control and foreground Android coordinator use
|
||||
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
|
||||
persistent peer identities. The controlled rollout verified real backups, the
|
||||
schema-v17 to v20 Android update chain, desktop v18 to v21 migration, three
|
||||
complete hardware conversations, restart, and idempotent replay. Drive was not
|
||||
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.
|
||||
The development branch has opt-in full-generation coordination with durable Web
|
||||
progress and a foreground Android conversation. Its production generation MTP
|
||||
adapter is integrated and tested through an object-I/O boundary double. Android
|
||||
also has a complete same-installation backup/verify/restore container, and a
|
||||
non-installed schema-v17 bridge candidate is reproducibly built and tested.
|
||||
Physical-device MTP, release signing, real user backup/restore, main integration
|
||||
and deployment are not complete.
|
||||
|
||||
`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
|
||||
|
|
@ -76,7 +75,6 @@ parallel implementation of its rules.
|
|||
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_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) |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
# 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
|
||||
|
||||
The controlled private rollout is complete for the paired daily installation:
|
||||
physical libmtp, verified backups, signing continuity, migrations, correlated
|
||||
ACKs, restart, and idempotent replay passed. Generation mode remains an
|
||||
explicit per-installation opt-in; Drive is still unconfigured and unvalidated.
|
||||
Before controlled rollout, complete and validate the physical libmtp generation
|
||||
adapter, establish a legitimate installed-Android backup path, verify release
|
||||
signing continuity and obtain explicit rollout approval. Do not enable generation
|
||||
mode or advance `main` before those gates.
|
||||
|
||||
This document owns future work. The implemented baseline is summarized in
|
||||
[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
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_WEB_V1_NEXT_CONTRACT
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_DEPLOYMENT_READINESS_V1
|
||||
```
|
||||
|
||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||
|
|
@ -47,10 +47,9 @@ cross-platform draft revision identity and resource-bound admission are closed.
|
|||
`TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN`: both platforms expose explicit
|
||||
coherent capture, immutable manifest publication, whole-generation
|
||||
transactional consumption, durable correlated ACK and restart/replay entry
|
||||
points. The orchestrator, Web API and Web control are complete behind an
|
||||
explicit trusted opt-in. The authorized private direct-MTP rollout passed on
|
||||
real hardware; automatic V3 selection remains unchanged without that opt-in.
|
||||
The next work returns to separately contracted Web surfaces.
|
||||
points. The orchestrator, Web API and Web control are now complete behind an
|
||||
explicit trusted opt-in. Automatic V3 selection remains unchanged. The next
|
||||
boundary is deployment readiness; no rollout starts here.
|
||||
|
||||
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
|
||||
defines the complete-sync target and its bounded dependency order.
|
||||
|
|
|
|||
|
|
@ -776,19 +776,6 @@ idempotently. Causal V1 operation bytes remain unchanged across first emission
|
|||
and retransmission because generation membership lives only in
|
||||
`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
|
||||
libmtp, selects one exact advertised peer/capability set across bounded devices
|
||||
and storages, and mirrors only bounded regular object names. It downloads to
|
||||
|
|
|
|||
|
|
@ -1,17 +1,6 @@
|
|||
# Full-generation sync rollout readiness
|
||||
|
||||
## 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
|
||||
## Deployment-readiness candidate
|
||||
|
||||
The generation path remains disabled unless desktop configuration authorizes a
|
||||
specific persisted Android peer and Android contains the strict opt-in document
|
||||
|
|
@ -21,10 +10,12 @@ bounded typed progress and its descendant process group. The foreground Android
|
|||
coordinator advertises its real peer identity, publishes manifest-last bytes,
|
||||
consumes the return generation and emits a durable correlated ACK.
|
||||
|
||||
The isolated proof still uses the production generation adapter with only its
|
||||
typed libudev/libmtp I/O callbacks replaced by an object-store double. The
|
||||
separate deployment record owns the physical-phone evidence; neither proof
|
||||
generalizes pairing to another phone or enables generation mode by default.
|
||||
The integrated proof uses the production generation adapter with only its
|
||||
typed libudev/libmtp I/O callbacks replaced by an object-store double. This is
|
||||
software evidence for selection, bounds, publication ordering and the complete
|
||||
conversation, but not evidence for a particular phone's physical libmtp
|
||||
behavior. Generation mode must remain disabled until that hardware gate and
|
||||
the separately authorized rollout are complete.
|
||||
|
||||
### Candidate, backup and recovery
|
||||
|
||||
|
|
@ -35,17 +26,9 @@ generalizes pairing to another phone or enables generation mode by default.
|
|||
`trainlog-syncd`, the production generation MTP adapter and their matching
|
||||
Python codecs. User units must point only into one versioned bundle; they
|
||||
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
|
||||
release signing identity, verification of its public certificate and a valid
|
||||
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.
|
||||
increasing `versionCode`; this mission reads no signing secret.
|
||||
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
|
||||
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
|
||||
foreign-key checks.
|
||||
|
|
@ -63,10 +46,12 @@ generalizes pairing to another phone or enables generation mode by default.
|
|||
preserve pending generations, tombstones, finalizations and ACK ledgers; it
|
||||
must never trigger hidden eviction.
|
||||
|
||||
The grouped rollout approval covered the signed APK, desktop bundle, verified
|
||||
user backups, coordinated service quiescence, pairing, synthetic smoke and
|
||||
non-destructive hardware smoke. Those steps are complete for the recorded
|
||||
private installation. Drive remains separate and untested.
|
||||
Actual rollout needs one separate approval for the signed APK, desktop bundle,
|
||||
verified user backups, coordinated service quiescence, pairing, synthetic smoke
|
||||
and non-destructive hardware smoke. Drive remains separate and untested here.
|
||||
|
||||
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
|
||||
`/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit
|
||||
|
|
@ -113,6 +98,5 @@ operator diagnosis; this tranche adds no tombstone or evidence garbage
|
|||
collection. A migrated database is recovered from its verified backup, never by
|
||||
assuming an older binary reverses its migration.
|
||||
|
||||
The recorded private installation completed physical MTP, service coordination
|
||||
and APK/desktop deployment. Actual Drive configuration remains untested; the
|
||||
eight-generation admission bound and foreground-phone requirement remain.
|
||||
Physical MTP, actual Drive configuration, service coordination, installed APK,
|
||||
main merge and deployment are intentionally not performed here.
|
||||
|
|
|
|||
|
|
@ -523,11 +523,6 @@ canonical stable Android artifact. Release assets must come from the exact
|
|||
tagged source state, and the tag, Android `versionName`, Meson project version,
|
||||
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
|
||||
meson setup --reconfigure build
|
||||
meson compile -C build
|
||||
|
|
@ -1354,24 +1349,3 @@ and the version-thirteen and version-twelve methods in
|
|||
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 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,30 +66,6 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
||||
self.assertTrue((output / "bin/trainlog-syncd").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(
|
||||
any(
|
||||
row["path"] == "tools/trainlog_syncd.py"
|
||||
|
|
|
|||
|
|
@ -27,13 +27,6 @@ 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
|
||||
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
|
||||
# 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()
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ def main():
|
|||
":app:testDebugUnitTest",
|
||||
"--tests",
|
||||
"com.labfytools.trainlog.data.BackupBridgeServiceTest",
|
||||
":app:assembleRelease",
|
||||
":app:assembleDebug",
|
||||
],
|
||||
cwd=android,
|
||||
env=env,
|
||||
|
|
@ -105,7 +105,7 @@ def main():
|
|||
print(
|
||||
f"BRIDGE_RECIPE={subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()}"
|
||||
)
|
||||
print(f"BRIDGE_APK={android/'app/build/outputs/apk/release/app-release.apk'}")
|
||||
print(f"BRIDGE_APK={android/'app/build/outputs/apk/debug/app-debug.apk'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -42,10 +42,7 @@ def merge_revisions(db,table,parent_column,parent_id,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])
|
||||
if old is None:db.execute(f"INSERT INTO {table}(revision_id,{parent_column},created_at,raw_text) VALUES(?,?,?,?)",(revision[0],*expected));added+=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
|
||||
elif old==expected:skipped+=1
|
||||
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()))
|
||||
return added,skipped,current[2]
|
||||
|
|
@ -66,7 +63,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")
|
||||
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
|
||||
elif tuple(old)==expected:roots_skipped+=1
|
||||
elif old==expected:roots_skipped+=1
|
||||
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))
|
||||
seen=set()
|
||||
|
|
@ -79,7 +76,7 @@ def apply_feedback(db,root,complete_causal_envelope=False):
|
|||
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"])
|
||||
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 tuple(old)==expected:roots_skipped+=1
|
||||
elif old==expected:roots_skipped+=1
|
||||
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))
|
||||
return roots_added,roots_skipped,revisions_added,revisions_skipped
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ RUNTIME_TOOLS = [
|
|||
"sync_generation_exchange.py",
|
||||
"trainlog_sqlite.py",
|
||||
"validate_json.py",
|
||||
"validate_training_knowledge.py",
|
||||
"exercise_names.py",
|
||||
"import_mobile_export.py",
|
||||
"import_exercise_aliases.py",
|
||||
"import_equipment_definitions.py",
|
||||
|
|
@ -29,14 +27,10 @@ RUNTIME_TOOLS = [
|
|||
"causal_delete_exchange.py",
|
||||
"import_exercise_profile_state.py",
|
||||
"export_exercise_profile_state.py",
|
||||
"export_pc_catalog.py",
|
||||
"export_pc_mobile.py",
|
||||
"export_exercise_aliases.py",
|
||||
"export_equipment_definitions.py",
|
||||
"export_equipment_associations.py",
|
||||
"export_exercise_body_zones.py",
|
||||
"export_training_feedback.py",
|
||||
"export_ai_session_drafts.py",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -77,15 +71,15 @@ def main() -> int:
|
|||
shutil.copy2(source, args.output / "catalog" / source.name)
|
||||
launcher = args.output / "bin/trainlog"
|
||||
launcher.write_text(
|
||||
'#!/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'
|
||||
'#!/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'
|
||||
)
|
||||
sync_once_launcher = args.output / "bin/trainlog-sync-once"
|
||||
sync_once_launcher.write_text(
|
||||
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexec "$root/libexec/trainlog-sync-once" "$@"\n'
|
||||
'#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)\nexec "$root/libexec/trainlog-sync-once" "$@"\n'
|
||||
)
|
||||
syncd_launcher = args.output / "bin/trainlog-syncd"
|
||||
syncd_launcher.write_text(
|
||||
'#!/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'
|
||||
'#!/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'
|
||||
)
|
||||
files = sorted(path for path in args.output.rglob("*") if path.is_file())
|
||||
inventory = {
|
||||
|
|
@ -120,7 +114,6 @@ def main() -> int:
|
|||
"libudev",
|
||||
"libmtp",
|
||||
"notcurses",
|
||||
"coreutils-readlink",
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
import { beforeEach,describe,expect,it,vi } from 'vitest'
|
||||
import { fetchSyncStatus, newRequestId, startSync, syncIsActive } from './sync'
|
||||
import { fetchSyncStatus,startSync,syncIsActive } from './sync'
|
||||
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)}}
|
||||
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('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,25 +6,5 @@ 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 }
|
||||
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 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 newRequestId():string{return `sy_${crypto.randomUUID()}`}
|
||||
export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue