Compare commits

..

No commits in common. "a5cdbdf2b9a60ba0881368d8542e16cc19b4ff45" and "56ac1cdfd2ec206d134d8664a577999f50d09cb5" have entirely different histories.

72 changed files with 169 additions and 2413 deletions

View file

@ -2,42 +2,6 @@
## Unreleased — 0.1.2 ## Unreleased — 0.1.2
- Added compatible non-destructive generation archival on desktop schema v22
and Android schema v21. Only exactly acknowledged generations older than the
two active recovery tips leave admission after a verified durable payload
copy and audit record; all identities, manifests, ACKs, lineage and causal
evidence remain stored. Older Android stores can recover exact missing
producer-side ACK rows from the desktop consumer's durable ledger, with
strict run, peer, generation and manifest revalidation. A correlated Android
capacity error now reaches the Web without waiting for a transport timeout,
and the prepared-items tile can inspect every proposal rather than only its
first compact summary.
- Completed the controlled schema-v22/schema-v21 rollout without uninstalling
or clearing Android data. The production Android archive uses a supported
post-rename file durability barrier on emulated external storage, and the
desktop publishes each complete MTP generation before its correlated
reference. Two real post-migration bidirectional exchanges, separated by a
Trainlog-service restart, completed with durable ACKs and no session
duplication. The expected AI proposal retained its identity, six entries,
and `pending` state on Android and remained independently visible in Web.
- Fixed a production full-generation timeout caused by recursively uploading
retained staging and unrelated transport history on every MTP push. Each
phase now publishes a private bounded outbox containing only its request,
ACK, and current referenced generation, while preserving every retained
generation, tombstone, and causal proof. Transport expiry has a stable
browser-facing code and action instead of a truncated traceback.
The reciprocal pull now reads only current coordination objects and the
exactly referenced Android generation instead of recursively mirroring
retained Android and desktop history.
- Replaced the Web Next Session placeholder with a separate read-only Core
projection that distinguishes AI proposals from active/pending execution
drafts without redefining the frozen Dashboard field or counting targets as
performed work. The browser preserves the last successful durable view
across refresh failures and uses one abortable, per-run ordered sync poller.
- Completed the authorized private full-generation rollout over production - Completed the authorized private full-generation rollout over production
direct MTP: verified Android and desktop backups, signing-preserving Android direct MTP: verified Android and desktop backups, signing-preserving Android
updates, schema migrations, persistent peer pairing, bidirectional durable updates, schema migrations, persistent peer pairing, bidirectional durable

View file

@ -318,7 +318,7 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-17: As of 2026-09-17:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`; - `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v22 and Android SQLite schema v21; - desktop SQLite schema v21 and Android SQLite schema v20;
- Notcurses is the only active desktop terminal backend; - Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's - direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting; all-files access setting;

View file

@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) {
manifest.getInt("version") == VERSION && manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName && manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") && manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..21 manifest.getInt("schema") in 17..20
) { ) {
"Unsupported backup." "Unsupported backup."
} }
@ -313,7 +313,7 @@ internal class AndroidBackupService(private val context: Context) {
} }
) )
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..21) { "Unsupported Android schema ${db.version}." } require(db.version in 17..20) { "Unsupported Android schema ${db.version}." }
return db.version return db.version
} finally { } finally {
db.close() db.close()

View file

@ -17,58 +17,6 @@ internal sealed interface ForegroundGenerationResult {
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) { internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
private val service = SyncGenerationService(repository) private val service = SyncGenerationService(repository)
private fun requireExactKeys(value: JSONObject, expected: Set<String>, label: String) {
val actual = value.keys().asSequence().toSet()
if (actual != expected) throw SyncGenerationException("invalid $label fields")
}
private fun reconcileArchiveAcknowledgements(
raw: ByteArray,
runId: String,
androidPeerId: String,
desktopPeerId: String,
) {
/* WHY: releases before archive-v1 advanced generation status without
* retaining the consumer's ACK locally. The desktop consumer may
* return its exact durable ACK so the Android producer can repair
* that evidence gap without fabricating or weakening proof.
* CONTRACT: the envelope is strictly correlated and contains at most
* 32 ACKs; acceptAcknowledgement revalidates every ACK against the
* immutable local manifest before any archive can become eligible.
* INVARIANT: a missing, rejected, or ambiguous ACK leaves its
* generation active and protected. */
val envelope = JSONObject(raw.toString(Charsets.UTF_8))
requireExactKeys(
envelope,
setOf(
"format",
"version",
"run_id",
"android_peer_id",
"desktop_peer_id",
"acknowledgements",
),
"archive acknowledgement envelope",
)
if (
envelope.getString("format") != "trainlog-sync-archive-acknowledgements" ||
envelope.getInt("version") != 1 ||
envelope.getString("run_id") != runId ||
envelope.getString("android_peer_id") != androidPeerId ||
envelope.getString("desktop_peer_id") != desktopPeerId
) {
throw SyncGenerationException("archive acknowledgement envelope is not correlated")
}
val acknowledgements = envelope.getJSONArray("acknowledgements")
if (acknowledgements.length() > 32)
throw SyncGenerationException("too many archive acknowledgements")
for (index in 0 until acknowledgements.length()) {
service.acceptAcknowledgement(
acknowledgements.getJSONObject(index).toString().toByteArray(Charsets.UTF_8)
)
}
}
private fun publish(path: File, value: String) { private fun publish(path: File, value: String) {
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}") val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
FileOutputStream(temporary).use { stream -> FileOutputStream(temporary).use { stream ->
@ -133,41 +81,15 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
"causal-delete-v1", "causal-delete-v1",
"mobile-history-v4", "mobile-history-v4",
"execution-draft-v1", "execution-draft-v1",
"generation-archive-v1",
) )
), ),
) )
.toString(), .toString(),
) )
val deadline = System.nanoTime() + timeout.toNanos() val deadline = System.nanoTime() + timeout.toNanos()
val requestRaw = val request = JSONObject(await(File(directory, "request-v1.json"), deadline).readText())
awaitCorrelated(File(directory, "request-v1.json"), deadline) { candidate ->
val runId = candidate.optString("run_id")
runId.startsWith("sy_") &&
!repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
}
}
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
if (request.getString("android_peer_id") != peer) if (request.getString("android_peer_id") != peer)
throw SyncGenerationException("request targets another Android peer") throw SyncGenerationException("request targets another Android peer")
val archiveAckRaw =
awaitCorrelated(
File(directory, "desktop-archive-acknowledgements-v1.json"),
deadline,
) {
it.optString("run_id") == request.getString("run_id") &&
it.optString("android_peer_id") == peer
}
reconcileArchiveAcknowledgements(
archiveAckRaw,
request.getString("run_id"),
peer,
request.getString("desktop_peer_id"),
)
val captured = val captured =
service.capture( service.capture(
directory, directory,
@ -206,25 +128,6 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement) publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
ForegroundGenerationResult.Completed(captured.runId) ForegroundGenerationResult.Completed(captured.runId)
} catch (error: Exception) { } catch (error: Exception) {
if (error.message == "generation retention capacity exhausted") {
try {
val request = JSONObject(File(directory, "request-v1.json").readText())
publish(
File(directory, "android-generation-error-v1.json"),
JSONObject()
.put("format", "trainlog-sync-generation-error")
.put("version", 1)
.put("run_id", request.getString("run_id"))
.put("android_peer_id", service.peerId())
.put("code", "peer_capacity_exhausted")
.put("action", "Archive acknowledged generations, then retry explicitly.")
.toString(),
)
} catch (_: Exception) {
// Preserve the original local failure when error reporting
// cannot itself be durably published.
}
}
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed") ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
} }
} }

View file

@ -1,9 +1,7 @@
package com.labfytools.trainlog.data package com.labfytools.trainlog.data
import java.io.File import java.io.File
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.security.MessageDigest import java.security.MessageDigest
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@ -162,111 +160,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
* callers cannot supply or replace it. */ * callers cannot supply or replace it. */
internal fun peerId(): String = stablePeerId("android") internal fun peerId(): String = stablePeerId("android")
private fun archiveDigest(directory: File): String {
val digest = MessageDigest.getInstance("SHA-256")
directory.walkTopDown().filter { it.isFile }.sortedBy { it.relativeTo(directory).path }
.forEach { file ->
if (java.nio.file.Files.isSymbolicLink(file.toPath()))
throw SyncGenerationException("archive contains symbolic link")
val name = file.relativeTo(directory).invariantSeparatorsPath.toByteArray()
digest.update(ByteBuffer.allocate(4).putInt(name.size).array())
digest.update(name)
digest.update(MessageDigest.getInstance("SHA-256").digest(file.readBytes()))
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
internal fun archiveAcknowledged(ownedRoot: File, consumerPeerId: String): Int {
/* WHY: acknowledged payloads must not occupy active admission forever.
* CONTRACT: an exact consumed ACK is required and the newest two
* acknowledged lineage members remain active for recovery.
* INVARIANT: generation, artifact, ACK and causal rows remain intact;
* the ledger is committed only after a verified atomic archive copy. */
val rows = repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT g.generation_id,g.run_id,g.producer_peer_id,g.consumer_peer_id," +
"g.manifest_sha256,g.staging_path,g.manifest_json FROM sync_generations g " +
"WHERE g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id) " +
"ORDER BY g.generated_at DESC,g.generation_id DESC",
arrayOf(consumerPeerId),
).use { cursor ->
buildList {
while (cursor.moveToNext()) add(List(7) { cursor.getString(it) })
}
}
}
val root = File(File(ownedRoot, "archives"), "generations")
if (!root.exists() && !root.mkdirs())
throw SyncGenerationException("cannot create generation archive")
var count = 0
rows.drop(2).forEach { row ->
val eligible = repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT ack_id FROM sync_acknowledgements WHERE generation_id=? AND run_id=? " +
"AND producer_peer_id=? AND consumer_peer_id=? AND manifest_sha256=? " +
"AND result='consumed' AND durability='sqlite-commit-full' LIMIT 2",
arrayOf(row[0], row[1], row[2], row[3], row[4]),
).use { cursor ->
if (!cursor.moveToFirst()) null
else cursor.getString(0).also {
if (cursor.moveToNext()) throw SyncGenerationException("ambiguous ACK evidence")
}
}
} ?: return@forEach
val source = File(row[5])
if (Files.isSymbolicLink(source.toPath()))
throw SyncGenerationException("generation staging is a symbolic link")
validatePublished(source, row[3])
val destination = File(root, row[0])
val temporary = File(root, ".${row[0]}.tmp")
if (Files.isSymbolicLink(destination.toPath()))
throw SyncGenerationException("generation archive is a symbolic link")
if (!destination.exists()) {
if (Files.isSymbolicLink(temporary.toPath()))
throw SyncGenerationException("temporary generation archive is a symbolic link")
if (temporary.exists() && !temporary.deleteRecursively())
throw SyncGenerationException("cannot reset interrupted generation archive")
if (!source.copyRecursively(temporary, overwrite = false))
throw SyncGenerationException("cannot copy generation archive")
validatePublished(temporary, row[3])
temporary.walkTopDown().filter { it.isFile }.forEach { file ->
java.io.FileOutputStream(file, true).use { it.fd.sync() }
}
if (!temporary.renameTo(destination))
throw SyncGenerationException("cannot commit generation archive")
/* Android emulated external storage rejects directory fsync.
* Reopen and sync the commit marker after the atomic rename so
* the supported FUSE durability barrier covers the committed
* namespace before the internal SQLite ledger can advance. */
java.io.FileOutputStream(File(destination, "manifest.json"), true).use {
it.fd.sync()
}
}
validatePublished(destination, row[3])
val checksum = archiveDigest(destination)
val audit =
canonical(
JSONObject()
.put("format", "trainlog-sync-generation-archive-audit")
.put("version", 1)
.put("generation_id", row[0])
.put("run_id", row[1])
.put("manifest_sha256", row[4])
.put("ack_id", eligible)
.put("archive_sha256", checksum),
)
repository.inSyncGenerationTransaction { db ->
db.execSQL(
"INSERT OR IGNORE INTO sync_generation_archives VALUES(?,?,?,?,?,?)",
arrayOf(row[0], destination.absolutePath, row[4], checksum, OffsetDateTime.now().toString(), audit),
)
}
count += 1
}
return count
}
fun capture( fun capture(
ownedRoot: File, ownedRoot: File,
consumerPeerId: String, consumerPeerId: String,
@ -276,11 +169,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
requireId(consumerPeerId, "peer") requireId(consumerPeerId, "peer")
requireId(runId, "sy") requireId(runId, "sy")
requireId(generationId, "gen") requireId(generationId, "gen")
archiveAcknowledged(ownedRoot, consumerPeerId)
repository.inSyncGenerationTransaction { db -> repository.inSyncGenerationTransaction { db ->
val retained = val retained =
db.rawQuery( db.rawQuery(
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged') AND NOT EXISTS(SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)", "SELECT COUNT(*) FROM sync_generations WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged')",
arrayOf(consumerPeerId), arrayOf(consumerPeerId),
) )
.use { .use {
@ -977,32 +869,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
throw SyncGenerationException("ACK does not match pending generation") throw SyncGenerationException("ACK does not match pending generation")
val target = val target =
if (ack.getString("result") == "consumed") "acknowledged" else "rejected" if (ack.getString("result") == "consumed") "acknowledged" else "rejected"
/* CONTRACT: the correlated ACK is permanent causal
* evidence and is committed atomically with generation
* state. Replay is byte-identical or rejected below. */
db.execSQL(
"INSERT OR IGNORE INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
arrayOf(
ack.getString("ack_id"),
ack.getString("generation_id"),
ack.getString("run_id"),
ack.getString("producer_peer_id"),
ack.getString("consumer_peer_id"),
ack.getString("manifest_sha256"),
ack.getString("result"),
ack.getString("durability"),
ack.getString("consumed_at"),
ack.getString("diagnostic"),
claimed,
),
)
db.rawQuery(
"SELECT payload_sha256 FROM sync_acknowledgements WHERE ack_id=?",
arrayOf(ack.getString("ack_id")),
).use { stored ->
if (!stored.moveToFirst() || stored.getString(0) != claimed)
throw SyncGenerationException("conflicting ACK replay")
}
if (row.getString(4) in setOf("acknowledged", "rejected")) { if (row.getString(4) in setOf("acknowledged", "rejected")) {
if (row.getString(4) != target) if (row.getString(4) != target)
throw SyncGenerationException("conflicting ACK replay") throw SyncGenerationException("conflicting ACK replay")

View file

@ -7934,7 +7934,7 @@ private class TrainlogDatabaseHelper(
appContext, appContext,
databaseName, databaseName,
null, null,
21, 20,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -7979,7 +7979,6 @@ private class TrainlogDatabaseHelper(
createSyncDataLifecycleTables(db) createSyncDataLifecycleTables(db)
createCausalDeleteTables(db) createCausalDeleteTables(db)
createSyncGenerationTables(db) createSyncGenerationTables(db)
createSyncGenerationArchiveTable(db)
seedEquipment(db) seedEquipment(db)
} }
@ -8147,14 +8146,6 @@ private class TrainlogDatabaseHelper(
createSyncGenerationTables(db) createSyncGenerationTables(db)
version = 20 version = 20
} }
if (version < 21 && newVersion >= 21) {
/* WHY: acknowledged payloads must leave bounded active admission
* without losing immutable identity, lineage or ACK evidence.
* CONTRACT: v21 adds only an append-only verified archive ledger.
* INVARIANT: migration archives nothing and invents no ACK. */
createSyncGenerationArchiveTable(db)
version = 21
}
if (version != newVersion) { if (version != newVersion) {
error( error(
@ -8253,15 +8244,6 @@ private class TrainlogDatabaseHelper(
db.execSQL("CREATE TABLE IF NOT EXISTS sync_causal_publications(operation_id TEXT NOT NULL REFERENCES sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));") db.execSQL("CREATE TABLE IF NOT EXISTS sync_causal_publications(operation_id TEXT NOT NULL REFERENCES sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));")
} }
private fun createSyncGenerationArchiveTable(db: SQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS sync_generation_archives(" +
"generation_id TEXT PRIMARY KEY REFERENCES sync_generations(generation_id)," +
"archive_path TEXT NOT NULL UNIQUE,manifest_sha256 TEXT NOT NULL," +
"archive_sha256 TEXT NOT NULL,archived_at TEXT NOT NULL,audit_json TEXT NOT NULL);",
)
}
private fun createAiSessionDraftTables(db: SQLiteDatabase) { private fun createAiSessionDraftTables(db: SQLiteDatabase) {
db.execSQL( db.execSQL(
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" + "CREATE TABLE IF NOT EXISTS ai_session_drafts(" +

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() } TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(21, scalar(db, "PRAGMA user_version")) assertEquals(20, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) { for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(21, scalar(db, "PRAGMA user_version")) assertEquals(20, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and /* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */ * exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(21, scalar(db, "PRAGMA user_version")) assertEquals(20, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf( assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"), scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor -> db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(21, cursor.getInt(0)) assertTrue(cursor.moveToFirst()); assertEquals(20, cursor.getInt(0))
} }
db.rawQuery("SELECT id FROM exercises", null).use { cursor -> db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
null, null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY, android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
) )
.use { assertEquals(21, it.version) } .use { assertEquals(20, it.version) }
} finally { } finally {
repository.close() repository.close()
context.deleteDatabase(name) context.deleteDatabase(name)

View file

@ -1,7 +1,6 @@
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 com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.NewExerciseProfile
@ -87,21 +86,8 @@ class SyncDeploymentConversationTest {
ActiveSessionDraft(exercises = listOf(occurrence)) ActiveSessionDraft(exercises = listOf(occurrence))
), ),
) )
val release = System.getenv("TRAINLOG_SYNC_TEST_RELEASE_FILE")
val desktop = File(transport, "desktop.sqlite")
if (suppliedTransport == null) {
val fixture =
ProcessBuilder(
File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktop.absolutePath,
)
.directory(repositoryRoot)
.start()
assertEquals(0, fixture.waitFor())
}
val conversations = if (suppliedTransport == null) 24 else 1
repeat(conversations) {
val coordinator = SyncGenerationForegroundCoordinator(repository) val coordinator = SyncGenerationForegroundCoordinator(repository)
val release = System.getenv("TRAINLOG_SYNC_TEST_RELEASE_FILE")
val executor = java.util.concurrent.Executors.newSingleThreadExecutor() val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
val result = val result =
executor.submit<ForegroundGenerationResult> { executor.submit<ForegroundGenerationResult> {
@ -111,6 +97,15 @@ class SyncDeploymentConversationTest {
} }
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText()) val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
if (suppliedTransport == null) { if (suppliedTransport == null) {
val desktop = File(transport, "desktop.sqlite")
val fixture =
ProcessBuilder(
File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktop.absolutePath,
)
.directory(repositoryRoot)
.start()
assertEquals(0, fixture.waitFor())
localWorker = localWorker =
ProcessBuilder( ProcessBuilder(
"python3", "python3",
@ -132,55 +127,18 @@ class SyncDeploymentConversationTest {
.redirectErrorStream(true) .redirectErrorStream(true)
.start() .start()
} }
val completed = result.get(35, java.util.concurrent.TimeUnit.SECONDS)
executor.shutdownNow()
assertTrue( assertTrue(
"conversation ${it + 1}: $completed", result.get(35, java.util.concurrent.TimeUnit.SECONDS)
completed is ForegroundGenerationResult.Completed, is ForegroundGenerationResult.Completed
) )
var workerOutput = "" executor.shutdownNow()
localWorker?.let { worker ->
workerOutput = worker.inputStream.bufferedReader().readText()
assertEquals("worker failed:\n$workerOutput", 0, worker.waitFor())
assertTrue(workerOutput.contains("\"result\":\"completed\""))
}
if (suppliedTransport == null && it == 7) {
// Reproduce the deployed pre-archive state: those builds
// advanced producer status but did not retain received
// ACK rows. The next real worker conversation must return
// the desktop consumer's exact evidence and unblock the
// full active quota without deleting any generation.
repository.inSyncGenerationTransaction { database ->
database.delete("sync_acknowledgements", null, null)
database.rawQuery(
"SELECT COUNT(*) FROM sync_generations WHERE status='acknowledged'",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(8, cursor.getInt(0))
}
}
}
localWorker = null
}
repository.close() repository.close()
repository = TrainlogRepository(context, databaseName) repository = TrainlogRepository(context, databaseName)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded) assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded)
if (suppliedTransport == null) { localWorker?.let { worker ->
SQLiteDatabase.openDatabase( val output = worker.inputStream.bufferedReader().readText()
context.getDatabasePath(databaseName).absolutePath, assertEquals("worker failed:\n$output", 0, worker.waitFor())
null, assertTrue(output.contains("\"result\":\"completed\""))
SQLiteDatabase.OPEN_READONLY,
).use { database ->
database.rawQuery("SELECT COUNT(*) FROM sync_generation_archives", null).use {
assertTrue(it.moveToFirst())
assertTrue(it.getInt(0) >= 16)
}
database.rawQuery("SELECT COUNT(*) FROM sync_generations", null).use {
assertTrue(it.moveToFirst())
assertEquals(24, it.getInt(0))
}
}
} }
} finally { } finally {
localWorker?.destroyForcibly() localWorker?.destroyForcibly()

View file

@ -46,7 +46,6 @@ class SyncGenerationServiceTest {
SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READWRITE).use { db SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READWRITE).use { db
-> ->
listOf( listOf(
"sync_generation_archives",
"sync_causal_publications", "sync_causal_publications",
"sync_acknowledgements", "sync_acknowledgements",
"sync_consumed_generations", "sync_consumed_generations",
@ -63,7 +62,7 @@ class SyncGenerationServiceTest {
.use { db -> .use { db ->
db.rawQuery("PRAGMA user_version", null).use { db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())
assertEquals(21, it.getInt(0)) assertEquals(20, it.getInt(0))
} }
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(21, cursor.getInt(0)) assertEquals(20, cursor.getInt(0))
} }
db.rawQuery( db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(21, cursor.getInt(0)) assertEquals(20, cursor.getInt(0))
} }
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())

View file

@ -961,8 +961,6 @@ The authorized private rollout used the bridge only to add the backup surface
without clearing package data, verified the resulting plaintext archive, then without clearing package data, verified the resulting plaintext archive, then
installed the current signed release variant through a strictly increasing installed the current signed release variant through a strictly increasing
version-code chain. The final installed private candidate retains product version-code chain. The final installed private candidate retains product
version 0.1.2 and schema v21. Schema v21 adds the verified generation archive version 0.1.2 and schema v20. Its full-generation foreground coordinator waits
ledger without changing any domain identity. Its full-generation foreground for correlated objects, tolerates stale objects left by interrupted runs, and
coordinator waits for correlated objects, tolerates stale objects left by
interrupted runs, strictly revalidates desktop-retained historical ACKs, and
shows completion only after both durable peer acknowledgements. shows completion only after both durable peer acknowledgements.

View file

@ -50,8 +50,8 @@ parallel implementation of its rules.
| Boundary | Current state | | Boundary | Current state |
|---|---| |---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v22 | | Desktop SQLite | schema v21 |
| Android SQLite | schema v21 | | Android SQLite | schema v20 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only | | Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` | | Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
@ -219,26 +219,6 @@ persistence with optimistic conflict and CSRF/Origin protection. It provides no
business mutation or automatic browser launch. Analyse, Programmes, Sessions, business mutation or automatic browser launch. Analyse, Programmes, Sessions,
and Exercises remain explicit placeholders. and Exercises remain explicit placeholders.
The Next Session tile no longer treats the frozen Dashboard
`next_session.available` placeholder as a durable inventory. A separate bounded
`GET /api/v1/prepared-items` Core projection distinguishes imported AI
proposals from active or pending execution drafts and exposes stable identity,
state, title/date, occurrence count, and provenance. Reload and later sync
failure do not erase the last successfully read projection. Proposal targets
remain planning data and never alter Dashboard actual-work metrics.
Full-generation MTP publication now uses a private phase-owned outbox. The
request, desktop consumption ACK, and current desktop generation are published
only when their phase requires them; retained staging, inbound objects, legacy
files, prior ACKs, and causal evidence are not recursively re-uploaded. Adapter
timeouts are persisted as the stable `transport_timeout` status instead of a
truncated Python traceback. The browser polls through one abortable scheduler,
orders revisions per `run_id`, and rejects stale responses from older runs.
The reciprocal MTP read is bounded to the Android peer advertisement, current
generation reference and consumption ACK, plus the single Android generation
named by that reference. Historical generations and other retained evidence
are neither traversed nor deleted during polling.
## Data semantics ## Data semantics
- `exercise_id` identifies a catalogue exercise; `entry_id` identifies one - `exercise_id` identifies a catalogue exercise; `entry_id` identifies one
@ -341,11 +321,9 @@ Android resource-parity, language-owner, typed sync-presentation, and
stable-data presentation tests. A real-device/manual visual language-switch stable-data presentation tests. A real-device/manual visual language-switch
smoke remains a manual validation; it does not change the validated status. smoke remains a manual validation; it does not change the validated status.
Hardware-dependent MTP is not automated. The controlled real-device rollout Hardware-dependent MTP and the final real Drive plus Android-triggered AI-draft
validated two consecutive full-generation exchanges around a Trainlog-service smoke test are not automated. The latter is why
restart. The expected AI proposal was verified in the persistent Android store `TRAINLOG_AI_SESSION_DRAFT_V1` remains `VALIDATION_PENDING`.
and the read-only Web proposal projection with its original identity and
content; no proposal-to-execution-draft conversion was performed.
## Active limitations ## Active limitations
@ -353,18 +331,16 @@ content; no proposal-to-execution-draft conversion was performed.
desktop producers/consumers for `trainlog-causal-deletions` V1. All contracted desktop producers/consumers for `trainlog-causal-deletions` V1. All contracted
domains are durably protected; exact replay is idempotent and unprovable or domains are durably protected; exact replay is idempotent and unprovable or
concurrent ancestry conflicts before mutation. Protected legacy snapshots are concurrent ancestry conflicts before mutation. Protected legacy snapshots are
refused before resurrection. The default automatic daemon path remains V3, refused before resurrection. Staged generation acknowledgement is implemented,
while the trusted local Web opt-in now runs the deployed full-generation path. but active synchronization still selects V3: no staged-service activation,
Its acknowledged generations can leave the active admission window only deployment, or user-data migration occurred.
through the verified schema-v22/schema-v21 archive ledger; pending, rejected
and ambiguous generations remain protected.
The focused causal closeout additionally proves child-data delete/update The focused causal closeout additionally proves child-data delete/update
conflicts including change-and-revert, imported built-in and alias protection, conflicts including change-and-revert, imported built-in and alias protection,
active/pending/desktop draft-revision equivalence, protected companion refusal, active/pending/desktop draft-revision equivalence, protected companion refusal,
current-view retirement and bounded pre-effect admission. The later generation current-view retirement and bounded pre-effect admission. The later generation
slice additively advances schemas to desktop v22/Android v21 without changing slice additively advances schemas to desktop v21/Android v20 without changing
default automatic V3 selection. active V3 transport selection.
The residual causal evidence also proves legitimate Android and desktop The residual causal evidence also proves legitimate Android and desktop
producer directions separately for exercise retirement, populated body producer directions separately for exercise retirement, populated body
@ -382,6 +358,8 @@ pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
remain shell placeholders until their Core/API ownership and implementation remain shell placeholders until their Core/API ownership and implementation
order are explicitly contracted. order are explicitly contracted.
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review. - `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
- AI proposal exchange still awaits one real Drive plus Android-triggered
bidirectional smoke test.
- Session Generator V1 is hidden while V2 planning semantics are developed. - Session Generator V1 is hidden while V2 planning semantics are developed.
- Hardware MTP validation requires a connected unlocked Android device. - Hardware MTP validation requires a connected unlocked Android device.
- Scientific knowledge is bounded to reviewed catalog entries; unknown custom - Scientific knowledge is bounded to reviewed catalog entries; unknown custom

View file

@ -532,12 +532,6 @@ foreign-key checks. Reconciliation validation also uses coherent Android and
desktop v9 copies before applying migrations to the real stores. SQLite files desktop v9 copies before applying migrations to the real stores. SQLite files
are never synchronization artifacts. are never synchronization artifacts.
Desktop schema v22 adds `sync_generation_archives` as an append-only archive
ledger. A row retains the generation identity, verified manifest digest,
deterministic archive digest, durable location, timestamp and canonical audit
record. Generation, artifact, ACK, consumption and causal-publication rows are
not deleted or rewritten when a payload becomes archived.
The schemas deliberately differ where ownership differs. Desktop sessions own The schemas deliberately differ where ownership differs. Desktop sessions own
`ended_at`, notes, planned targets and session-specific load semantics; the `ended_at`, notes, planned targets and session-specific load semantics; the
Android capture schema does not. Desktop body observations may link to a Android capture schema does not. Desktop body observations may link to a

View file

@ -24,7 +24,7 @@ distinction between measured values and estimates.
## Current baseline ## Current baseline
Desktop schema v22, Android schema v21, Notcurses, direct Desktop schema v21, Android schema v20, Notcurses, direct
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body `Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft implemented. Session Generator V1 is hidden pending V2. AI session-draft
@ -34,9 +34,6 @@ CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
contract, frozen interactive grid, versioned layout persistence and factual contract, frozen interactive grid, versioned layout persistence and factual
tile rendering exist. The Dashboard's bounded progression and BODY ZONES tile rendering exist. The Dashboard's bounded progression and BODY ZONES
visualizations are implemented; the other four Web routes remain placeholders. visualizations are implemented; the other four Web routes remain placeholders.
The corrective prepared-item projection and bounded phase-owned MTP outbox are
implemented without opening the Sessions route, changing schemas, or changing
the frozen Dashboard and exchange contracts.
## Current cursor ## Current cursor

View file

@ -18,7 +18,7 @@ available because availability is independently guarded by causal state.
## Machine-exercise Phase 1 compatibility ## Machine-exercise Phase 1 compatibility
Desktop schema v22 and Android schema v21 retain mobile export V3, readable V1/V2 imports, Desktop schema v21 and Android schema v20 retain mobile export V3, readable V1/V2 imports,
equipment definitions V1, equipment associations V2, exercise aliases V1, and equipment definitions V1, equipment associations V2, exercise aliases V1, and
the BODY ZONES companion without wire-format changes. Machine metadata is not the BODY ZONES companion without wire-format changes. Machine metadata is not
silently added to a frozen artifact: stable exercise IDs and canonical names silently added to a frozen artifact: stable exercise IDs and canonical names
@ -756,32 +756,8 @@ transaction while calling the existing domain exporters. Both validate domain
and outer limits before recording a captured generation. Publication copies and outer limits before recording a captured generation. Publication copies
verified immutable bytes into `generations/<generation_id>/`, resumes only verified immutable bytes into `generations/<generation_id>/`, resumes only
byte-identical partial publication, and writes `manifest.json` last. Capacity byte-identical partial publication, and writes `manifest.json` last. Capacity
admits at most eight active outgoing generations per consumer. Acknowledged admits at most eight retained outgoing generations per consumer; exhaustion is
generations older than the two newest lineage members leave active admission recoverable and never evicts an unacknowledged generation or causal proof.
only after their exact consumed ACK, manifest, artifact sizes and digests have
been reconciled and a complete atomic archive copy has been verified and
journaled. Archive rows preserve identity, lineage, late-ACK and replay
protection; pending, rejected, ambiguous and recovery-tip generations remain
active. Archive directories are outside bounded MTP polling and are never
uploaded recursively. Failure or interruption leaves admission closed rather
than fabricating an ACK or discarding evidence.
Before Android captures for a negotiated `generation-archive-v1` conversation,
the desktop consumer publishes
`desktop-archive-acknowledgements-v1.json`. Its strictly correlated envelope
contains at most 32 exact consumed ACK documents already retained in the
desktop consumption ledger. This repairs the evidence gap left by older
Android producers that advanced generation status without retaining the
received ACK. Android revalidates every document against its immutable local
generation and manifest before storing it; missing, mismatched, rejected, or
ambiguous evidence cannot make a generation archivable. The object is a
bounded coordination artifact, not a new ACK source.
If Android still cannot admit a generation, it durably publishes the
run-correlated `android-generation-error-v1.json` with
`peer_capacity_exhausted`. The desktop worker and Web surface preserve that
cause and its archive/retry action instead of converting it to a later
transport timeout.
The required artifact order is causal deletions, catalog/profile identity, The required artifact order is causal deletions, catalog/profile identity,
custom-equipment definitions, V4 history, aliases and occurrence equipment, custom-equipment definitions, V4 history, aliases and occurrence equipment,
@ -822,31 +798,6 @@ truncation, and publishes generation manifests last. Production behavior is
tested by replacing only the typed libmtp/udev I/O callbacks with a filesystem tested by replacing only the typed libmtp/udev I/O callbacks with a filesystem
object double; the same compiled transport algorithm remains under test. object double; the same compiled transport algorithm remains under test.
Each worker push is additionally scoped by a private disposable outbox. The
first push contains only the correlated request; the post-import push adds the
desktop consumption ACK. Publication uses two causally ordered bounded pushes:
the immutable current desktop generation directory is committed first, with
its manifest last, and only then is its run-correlated reference made visible.
Android therefore cannot consume a reference to an in-progress MTP directory. The
durable transport root remains the owner of retained generations, staging,
legacy compatibility artifacts, and evidence, but those unrelated objects are
not recursively sent on every phase. Constructing an outbox does not delete or
rewrite any source object. A bounded adapter timeout is an ambiguous transport
failure with stable code `transport_timeout`; it is not evidence of rollback
and does not authorize automatic mutation replay.
MTP polling follows the same bounded ownership rule in the opposite direction.
It reads the peer advertisement, current Android generation reference and
current Android consumption ACK, then downloads only the generation directory
named by that validated reference. It does not recursively mirror retained
Android generations, desktop publications, or unrelated exchange history.
`GET /api/v1/prepared-items` is a separate read-only Web projection. It does not
change the frozen Dashboard `next_session.available` field or any exchange
format. It lists bounded durable desktop AI proposals and relevant execution
drafts as distinct kinds. Completed sessions remain outside this projection,
and finalized or causally deleted execution drafts are excluded.
## 19. Legacy active-protocol limitations ## 19. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator. The Web end-to-end tranche adds an explicit opt-in application orchestrator.

View file

@ -6,21 +6,6 @@ through the C adapter, observes the desktop commit at an Android test barrier,
then releases the real ACK. This proves the directory transport double, not then releases the real ACK. This proves the directory transport double, not
physical MTP. Orchestrator tests cover bounded streams and descendant cleanup; physical MTP. Orchestrator tests cover bounded streams and descendant cleanup;
deployment-tool tests cover consistent SQLite backup and candidate inventory. deployment-tool tests cover consistent SQLite backup and candidate inventory.
The incident regressions also prove that a phase-owned MTP outbox excludes
retained staging and unrelated legacy files, adapter expiry produces the stable
`transport_timeout` code, and operational failure classes remain distinct.
The C MTP regression keeps a malformed retained generation beside the current
reference and proves that pull downloads only the referenced generation while
still rejecting a truncated object inside that current generation.
`web_prepared_items` exercises the production SQLite projection and JSON
serializer with a proposal and an execution draft, then proves finalized and
causally deleted execution drafts are absent. The HTTP test covers the
read-only route and method rejection. Vitest distinguishes proposal, active
draft, loading, empty and failed-refresh states; it retains an already rendered
durable value across a reread failure. Sync-control tests cover a single
abortable polling scheduler, per-run revisions, rejection of an older run's
late terminal response, stable error/action rendering, and cleanup on unmount.
The `ai_history_export` test covers an empty database, a simple session, The `ai_history_export` test covers an empty database, a simple session,
distinct multi-occurrences of one exercise, ordered sets, exact occurrence distinct multi-occurrences of one exercise, ordered sets, exact occurrence
@ -1323,38 +1308,11 @@ Android generation service, desktop consumers and durable ACK path remain real.
backup including an active draft, raw partial input, stable peer identity and backup including an active draft, raw partial input, stable peer identity and
preferences, and rejects corrupt and traversal entries. The bridge build runs preferences, and rejects corrupt and traversal entries. The bridge build runs
the analogous test on the exact schema-v17 source and feeds its archive to the analogous test on the exact schema-v17 source and feeds its archive to
`BridgeBackupMigrationTest`, which verifies normal migration to schema v21. `BridgeBackupMigrationTest`, which verifies normal migration to schema v20.
These are software proofs. They do not claim physical USB/MTP behavior, These are software proofs. They do not claim physical USB/MTP behavior,
installation over the signed release, a real user backup, or a real-device installation over the signed release, a real user backup, or a real-device
restore. restore.
The production Android coordinator and desktop peer worker also complete 24
successive bidirectional conversations against their real SQLite services with
no cleanup between runs. The test crosses multiple archive cycles and checks
that the active draft survives. After the eighth exchange it also reproduces
the deployed legacy evidence gap by retaining acknowledged generations while
removing only their producer-side ACK rows; the next production conversation
must restore the desktop consumer's exact correlated ACKs, archive eligible
generations, and complete without deleting or duplicating any generation.
Focused archive tests cover interrupted-copy resume, invalid archives,
simulated insufficient space, missing and late ACKs, and idempotent ACK replay
after archival.
The controlled Samsung SM-G990B rollout migrated the authentic Android backup
from schema v20 to v21 and an authentic desktop copy from v21 to v22 before
installation. The installed release retained its existing certificate and
advanced from versionCode 6 through 7 to 8 without uninstalling or clearing
data. Real MTP validation preserved the failed-run evidence that exposed an
Android external-storage directory-fsync incompatibility and an early desktop
generation reference. After their bounded repairs, run
`sy_06200dd7-0d51-4f33-b80d-9976822ba87d` completed, the two Trainlog services
were restarted, and run `sy_783014b0-f3f0-4f1e-8f4f-723acfc02dbc` completed.
Both exchanged distinct generations and durable ACKs with zero session
reconciliation. The final Android backup passed integrity and foreign-key
checks at schema v21 with 13 generations, five verified archives, 13 ACKs,
five consumed generations, and six active generations. The expected AI
proposal remained `pending` with its original identity and six entries.
## Readability validation ## Readability validation
The normalized C scope is checked non-mutatively with the explicit The normalized C scope is checked non-mutatively with the explicit

View file

@ -1,124 +0,0 @@
#!/usr/bin/env python3
"""Failure-boundary tests for the production generation archive ledger."""
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import sync_generation_exchange as generation # noqa: E402
FIXTURE = ROOT / "build" / "tui" / "sync-generation-fixture"
PEER = "peer_11111111-1111-4111-8111-111111111111"
class GenerationArchiveTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="trainlog-archive-test-")
self.root = Path(self.temporary.name)
self.database = self.root / "desktop.sqlite"
subprocess.run([str(FIXTURE), str(self.database)], check=True)
self.owned = self.root / "owned"
self.objects = self.root / "objects"
def tearDown(self):
self.temporary.cleanup()
def acknowledge(self, generation_id: str, manifest: dict, digest: str) -> Path:
generation.publish(self.database, generation_id, self.objects)
with sqlite3.connect(self.database) as database:
producer = database.execute(
"SELECT producer_peer_id FROM sync_generations WHERE generation_id=?",
(generation_id,),
).fetchone()[0]
ack = generation.ack_document(
manifest["run_id"], generation_id, producer, PEER, digest,
"consumed", generation.now(), "",
)
path = self.root / f"{generation_id}.ack.json"
path.write_bytes(generation.canonical(ack))
self.assertEqual("acknowledged", generation.accept_ack(self.database, path))
return path
def create_acknowledged(self):
values = []
for _ in range(3):
stage, manifest, digest = generation.capture_desktop(
self.database, self.owned, PEER
)
del stage
ack = self.acknowledge(manifest["generation_id"], manifest, digest)
values.append((manifest, ack))
return values
def test_interrupted_archive_resumes_and_replay_stays_idempotent(self):
values = self.create_acknowledged()
oldest = values[0][0]["generation_id"]
temporary = self.owned / "archives" / "generations" / f".{oldest}.tmp"
temporary.mkdir(parents=True)
(temporary / "interrupted").write_text("partial")
self.assertEqual(1, generation.archive_acknowledged(self.database, self.owned, PEER))
self.assertFalse(temporary.exists())
self.assertEqual("unchanged", generation.accept_ack(self.database, values[0][1]))
with sqlite3.connect(self.database) as database:
self.assertEqual(
1,
database.execute(
"SELECT COUNT(*) FROM sync_generation_archives WHERE generation_id=?",
(oldest,),
).fetchone()[0],
)
def test_invalid_archive_and_no_space_never_commit_ledger(self):
values = self.create_acknowledged()
oldest = values[0][0]["generation_id"]
destination = self.owned / "archives" / "generations" / oldest
destination.mkdir(parents=True)
(destination / "manifest.json").write_text("{}")
with self.assertRaises(generation.GenerationError):
generation.archive_acknowledged(self.database, self.owned, PEER)
with sqlite3.connect(self.database) as database:
self.assertEqual(0, database.execute("SELECT COUNT(*) FROM sync_generation_archives").fetchone()[0])
destination.unlink(missing_ok=True) if destination.is_file() else None
for child in destination.iterdir():
child.unlink()
destination.rmdir()
with mock.patch.object(generation.shutil, "copytree", side_effect=OSError(28, "no space")):
with self.assertRaises(OSError):
generation.archive_acknowledged(self.database, self.owned, PEER)
with sqlite3.connect(self.database) as database:
self.assertEqual(0, database.execute("SELECT COUNT(*) FROM sync_generation_archives").fetchone()[0])
def test_missing_then_late_ack_is_protected_until_correlated(self):
values = self.create_acknowledged()
stage, manifest, digest = generation.capture_desktop(self.database, self.owned, PEER)
del stage
generation.publish(self.database, manifest["generation_id"], self.objects)
generation.archive_acknowledged(self.database, self.owned, PEER)
with sqlite3.connect(self.database) as database:
self.assertEqual(
"waiting_acknowledgement",
database.execute(
"SELECT status FROM sync_generations WHERE generation_id=?",
(manifest["generation_id"],),
).fetchone()[0],
)
self.assertEqual(
0,
database.execute(
"SELECT COUNT(*) FROM sync_generation_archives WHERE generation_id=?",
(manifest["generation_id"],),
).fetchone()[0],
)
self.acknowledge(manifest["generation_id"], manifest, digest)
self.assertGreaterEqual(generation.archive_acknowledged(self.database, self.owned, PEER), 0)
if __name__ == "__main__":
unittest.main()

View file

@ -1,119 +0,0 @@
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import sync_orchestrator # noqa: E402
import sync_peer_worker # noqa: E402
class SyncErrorClassificationTest(unittest.TestCase):
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
timeout = subprocess.TimeoutExpired(["adapter", "push"], 30)
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
with self.assertRaisesRegex(
RuntimeError,
r"^transport_timeout: MTP push did not finish within 30 seconds$",
) as caught:
sync_peer_worker.run_adapter(
Path("/adapter"),
"push",
"peer_00000000-0000-4000-8000-000000000001",
Path("/transport"),
sync_peer_worker.time.monotonic() + 60,
)
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")
def test_known_operational_failures_remain_distinct(self):
self.assertEqual(
sync_orchestrator.failure_code(RuntimeError("expected Android peer not found")),
"device_unavailable",
)
self.assertEqual(
sync_orchestrator.failure_code(RuntimeError("pending capacity exhausted")),
"capacity_exhausted",
)
self.assertEqual(
sync_orchestrator.failure_code(RuntimeError("revision conflict")),
"data_conflict",
)
def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "transport"
root.mkdir()
(root / "request-v1.json").write_text("{}")
(root / "staging").mkdir()
(root / "staging" / "retained.json").write_text("retained")
(root / "legacy.json").write_text("legacy")
observed = []
def inspect(command, **_kwargs):
outbox = Path(command[3])
observed.extend(
str(path.relative_to(outbox))
for path in outbox.rglob("*")
if path.is_file()
)
return subprocess.CompletedProcess(command, 0, b"", b"")
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=inspect):
sync_peer_worker.push_adapter(
Path("/adapter"),
"peer_00000000-0000-4000-8000-000000000001",
root,
["request-v1.json"],
sync_peer_worker.time.monotonic() + 60,
)
self.assertEqual(observed, ["request-v1.json"])
def test_desktop_generation_directory_precedes_reference(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
generation = (
"desktop-objects/generations/"
"gen_11111111-1111-4111-8111-111111111111"
)
(root / generation).mkdir(parents=True)
(root / generation / "manifest.json").write_text("manifest")
for name in (
"request-v1.json",
"desktop-archive-acknowledgements-v1.json",
"desktop-consumption-ack-v1.json",
"desktop-generation-v1.json",
):
(root / name).write_text("{}")
observed = []
def inspect(_adapter, _operation, _peer, outbox, _deadline):
observed.append(
sorted(
str(path.relative_to(outbox))
for path in outbox.rglob("*")
if path.is_file()
)
)
return True
with mock.patch.object(sync_peer_worker, "run_adapter", side_effect=inspect):
sync_peer_worker.push_desktop_generation(
Path("adapter"),
"peer",
root,
generation,
sync_peer_worker.time.monotonic() + 60,
)
self.assertEqual(len(observed), 2)
self.assertEqual(observed[0], [generation + "/manifest.json"])
self.assertNotIn("desktop-generation-v1.json", observed[0])
self.assertIn("desktop-generation-v1.json", observed[1])
if __name__ == "__main__":
unittest.main()

View file

@ -21,9 +21,8 @@ CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,produce
CREATE TABLE sync_generation_artifacts(generation_id TEXT,logical_name TEXT,format TEXT,version INTEGER,filename TEXT,size_bytes INTEGER,sha256 TEXT,required INTEGER,PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename)); CREATE TABLE sync_generation_artifacts(generation_id TEXT,logical_name TEXT,format TEXT,version INTEGER,filename TEXT,size_bytes INTEGER,sha256 TEXT,required INTEGER,PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename));
CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,consumed_at TEXT,result TEXT,durability TEXT,diagnostic TEXT,ack_json TEXT,UNIQUE(producer_peer_id,generation_id)); CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,consumed_at TEXT,result TEXT,durability TEXT,diagnostic TEXT,ack_json TEXT,UNIQUE(producer_peer_id,generation_id));
CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT); CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT);
CREATE TABLE sync_generation_archives(generation_id TEXT PRIMARY KEY,archive_path TEXT UNIQUE,manifest_sha256 TEXT,archive_sha256 TEXT,archived_at TEXT,audit_json TEXT);
CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id)); CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id));
PRAGMA user_version=22; PRAGMA user_version=21;
""" """
class GenerationTest(unittest.TestCase): class GenerationTest(unittest.TestCase):

View file

@ -76,9 +76,7 @@ class OrchestratorTest(unittest.TestCase):
values = self.fixture(root, **override) values = self.fixture(root, **override)
result = self.invoke(ORCHESTRATOR, values) result = self.invoke(ORCHESTRATOR, values)
self.assertEqual(result.returncode, 2) self.assertEqual(result.returncode, 2)
final = json.loads(values[1].read_text()) self.assertEqual(json.loads(values[1].read_text())["phase"], "failed")
self.assertEqual(final["phase"], "failed")
self.assertEqual(final["error_code"], "internal_error")
def test_stdout_bound_is_enforced_before_complete_accumulation(self): def test_stdout_bound_is_enforced_before_complete_accumulation(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0] version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22): if version not in (20, 21):
raise CausalError(f"desktop schema v20-v22 required, found v{version}") raise CausalError(f"desktop schema v20/v21 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str): def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection): def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0] version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22): if version not in (19, 20, 21):
raise LifecycleError(f"desktop schema v19-v22 required, found v{version}") raise LifecycleError(f"desktop schema v19/v20/v21 required, found v{version}")
def import_document(connection, document, own_transaction=True): def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22) SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21)
MEASUREMENT_FIELDS = ( MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",

View file

@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection): def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations.""" """Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction owned_transaction = not connection.in_transaction
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22): if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21):
raise ValueError("schema desktop v18-v22 requis") raise ValueError("schema desktop v18-v21 requis")
columns = {row[1] for row in connection.execute( columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")} "PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns: if "published_at" not in columns:

View file

@ -28,7 +28,7 @@ def main():
args = parser.parse_args() args = parser.parse_args()
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22): if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise ValueError("schema desktop v8 à v16 requis") raise ValueError("schema desktop v8 à v16 requis")
known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute( known_equipment.update(row[0] for row in connection.execute(

View file

@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22): if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise ValueError("schema desktop v8 à v16 requis") raise ValueError("schema desktop v8 à v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact") raise ValueError("causal equipment protection requires the staged artifact")

View file

@ -16,7 +16,7 @@ def main():
args = parser.parse_args() args = parser.parse_args()
con = connect_database(args.database) con = connect_database(args.database)
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22): if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise ValueError("schema desktop v12-v16 requis") raise ValueError("schema desktop v12-v16 requis")
rows = con.execute( rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "

View file

@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22): if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise ValueError("schema desktop v11-v16 requis") raise ValueError("schema desktop v11-v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact") raise ValueError("causal BODY ZONES protection requires the staged artifact")

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
version = con.execute("PRAGMA user_version").fetchone()[0] version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22): if version not in (17, 18, 19, 20, 21):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute(""" rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC # CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current # catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database. # canonical desktop schema rather than accept a stale pre-v8 database.
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22): if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

View file

@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0] schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22): if args.version == 4 and schema_version not in (19, 20, 21):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22) and not (args.version == 2 and schema_version == 10): if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute( if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():

View file

@ -10,7 +10,7 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database) db=connect_database(args.database)
try: try:
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22): raise ValueError("schema desktop v15-v22 requis") if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21): raise ValueError("schema desktop v15-v21 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction; INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind. any failure leaves no draft, child, or import-identity row behind.
""" """
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22): if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21):
raise ImportFailure("schema desktop v18-v22 requis") raise ImportFailure("schema desktop v18-v21 requis")
draft, entries = validate(payload, connection) draft, entries = validate(payload, connection)
digest = canonical_digest(payload) digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -187,7 +187,7 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8")) payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22): if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
fail("schema desktop v8 à v21 requis") fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export proof_path = args.mobile_export
if proof_path is None: if proof_path is None:

View file

@ -53,7 +53,7 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False): def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction.""" """Apply validated definitions without owning the surrounding transaction."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22): if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
fail("schema desktop v8 à v21 requis") fail("schema desktop v8 à v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact") fail("causal equipment protection requires the staged artifact")

View file

@ -56,7 +56,7 @@ def load(path):
def apply_aliases(con, aliases): def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction.""" """Apply parsed aliases inside the caller-owned transaction."""
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22): if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
fail("schema desktop v12-v21 requis") fail("schema desktop v12-v21 requis")
for source, canonical in aliases: for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()

View file

@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries.""" """Apply validated zone rows without owning transaction boundaries."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22): raise ImportFailure("schema desktop v11-v22 requis") if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21): raise ImportFailure("schema desktop v11-v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={} connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct # CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -49,7 +49,7 @@ def validate(item):
def apply_profile_state(con, root, allow_pending=False): def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22): fail("schema desktop v17-v22 requis") if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21): fail("schema desktop v17-v21 requis")
applied=equal=ancestor=pending=0;seen=set() applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]: for item in root["exercises"]:
validate(item);eid=item["exercise_id"] validate(item);eid=item["exercise_id"]

View file

@ -846,9 +846,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table. # readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22): if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
raise ImportFailure( raise ImportFailure(
f"base desktop schema v5 à v22 attendue, version trouvée: {version}" f"base desktop schema v5 à v21 attendue, version trouvée: {version}"
) )
@ -1978,8 +1978,8 @@ def apply_payload(connection, payload, trace_exercises=False):
require_supported_schema(connection) require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22): if payload["version"] == 4 and schema_version not in (19, 20, 21):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v22") raise ImportFailure("mobile V4 exige le schéma desktop v19, v20 ou v21")
if schema_version >= 20: if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute( protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False): def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0 roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON") db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22):fail("schema desktop v15-v22 requis") if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21):fail("schema desktop v15-v21 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set() seen=set()
for index,item in enumerate(root["exercise_feedback"]): for index,item in enumerate(root["exercise_feedback"]):

View file

@ -95,8 +95,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(), ).strip(),
"product_version": "0.1.2", "product_version": "0.1.2",
"desktop_schema": 22, "desktop_schema": 21,
"android_schema": 21, "android_schema": 20,
"protocols": [ "protocols": [
"mobile-export-v3", "mobile-export-v3",
"mobile-history-v4", "mobile-history-v4",
@ -104,7 +104,6 @@ def main() -> int:
"execution-draft-v1", "execution-draft-v1",
"generation-manifest-v1", "generation-manifest-v1",
"generation-ack-v1", "generation-ack-v1",
"generation-archive-v1",
], ],
"entry_points": [ "entry_points": [
"bin/trainlog", "bin/trainlog",

View file

@ -199,8 +199,8 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
if db.execute("PRAGMA user_version").fetchone()[0] != 22: if db.execute("PRAGMA user_version").fetchone()[0] != 21:
raise GenerationError("desktop schema v22 required") raise GenerationError("desktop schema v21 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str: def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -215,106 +215,6 @@ def peer_identity(db: sqlite3.Connection, kind: str) -> str:
return identity return identity
def archive_digest(directory: Path) -> str:
digest = hashlib.sha256()
for path in sorted(value for value in directory.rglob("*") if value.is_file()):
if path.is_symlink():
raise GenerationError("archive contains symbolic link")
relative = path.relative_to(directory).as_posix().encode("utf-8")
digest.update(len(relative).to_bytes(4, "big"))
digest.update(relative)
digest.update(bytes.fromhex(digest_file(path)[1]))
return digest.hexdigest()
def archive_acknowledged(database: Path, owned_root: Path, consumer: str) -> int:
"""Durably archive eligible acknowledged payloads without deleting evidence.
WHY: acknowledged generations otherwise consume the bounded active window
forever. CONTRACT: exact correlated consumed ACK evidence is required and
the newest two acknowledged lineage members remain active for recovery.
INVARIANT: generation, artifact, ACK and causal rows are never removed or
rewritten; a ledger row is committed only after a verified atomic copy.
"""
archive_root = owned_root / "archives" / "generations"
archive_root.mkdir(parents=True, exist_ok=True, mode=0o700)
with closing(connect_database(database)) as db:
require_schema(db)
rows = db.execute(
"SELECT g.generation_id,g.run_id,g.producer_peer_id,g.consumer_peer_id,"
"g.manifest_sha256,g.staging_path,g.manifest_json FROM sync_generations g "
"WHERE g.consumer_peer_id=? AND g.status='acknowledged' "
"AND NOT EXISTS(SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id) "
"ORDER BY g.generated_at DESC,g.generation_id DESC",
(consumer,),
).fetchall()
archived = 0
for generation, run, producer, target, manifest_sha, staging, manifest_json in rows[2:]:
with closing(connect_database(database)) as db:
ack = db.execute(
"SELECT ack_id FROM sync_acknowledgements WHERE generation_id=? AND run_id=? "
"AND producer_peer_id=? AND consumer_peer_id=? AND manifest_sha256=? "
"AND result='consumed' AND durability='sqlite-commit-full' LIMIT 2",
(generation, run, producer, target, manifest_sha),
).fetchall()
if len(ack) != 1:
continue
source = Path(staging)
if source.is_symlink():
raise GenerationError("generation staging is a symbolic link")
manifest = validate_manifest_bytes(manifest_json.encode(), target)
validate_published(source, target)
destination = archive_root / generation
temporary = archive_root / ("." + generation + ".tmp")
if destination.exists():
if destination.is_symlink():
raise GenerationError("generation archive is a symbolic link")
validate_published(destination, target)
checksum = archive_digest(destination)
else:
if temporary.exists():
if temporary.is_symlink():
raise GenerationError("temporary generation archive is a symbolic link")
shutil.rmtree(temporary)
shutil.copytree(source, temporary, copy_function=shutil.copy2)
validate_published(temporary, target)
checksum = archive_digest(temporary)
for copied in temporary.rglob("*"):
if copied.is_file():
with copied.open("rb") as stream:
os.fsync(stream.fileno())
os.replace(temporary, destination)
directory_fd = os.open(archive_root, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
audit = canonical({
"format": "trainlog-sync-generation-archive-audit",
"version": 1,
"generation_id": generation,
"run_id": run,
"manifest_sha256": manifest_sha,
"ack_id": ack[0][0],
"archive_sha256": checksum,
}).decode()
with closing(connect_database(database)) as db:
db.execute("BEGIN IMMEDIATE")
current = db.execute(
"SELECT status,manifest_sha256 FROM sync_generations WHERE generation_id=?",
(generation,),
).fetchone()
if current != ("acknowledged", manifest_sha):
raise GenerationError("generation changed during archive")
db.execute(
"INSERT OR IGNORE INTO sync_generation_archives VALUES(?,?,?,?,?,?)",
(generation, str(destination), manifest_sha, checksum, now(), audit),
)
db.commit()
archived += 1
return archived
def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None: def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None:
command = [sys.executable, str(ROOT / "tools" / tool)] command = [sys.executable, str(ROOT / "tools" / tool)]
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"): if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"):
@ -349,10 +249,9 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
run = run_id or "sy_" + str(uuid.uuid4()) run = run_id or "sy_" + str(uuid.uuid4())
if not valid_id(generation, "gen") or not valid_id(run, "sy"): if not valid_id(generation, "gen") or not valid_id(run, "sy"):
raise GenerationError("invalid generation/run") raise GenerationError("invalid generation/run")
archive_acknowledged(database, owned_root, consumer)
with closing(connect_database(database)) as admission: with closing(connect_database(database)) as admission:
require_schema(admission) require_schema(admission)
retained = admission.execute("SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged') AND NOT EXISTS(SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)", (consumer,)).fetchone()[0] retained = admission.execute("SELECT COUNT(*) FROM sync_generations WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged')", (consumer,)).fetchone()[0]
if retained >= MAX_RETAINED_PER_PEER: if retained >= MAX_RETAINED_PER_PEER:
raise GenerationError("generation retention capacity exhausted") raise GenerationError("generation retention capacity exhausted")
stage = owned_root / "staging" / generation stage = owned_root / "staging" / generation
@ -640,23 +539,10 @@ def accept_ack(database: Path, path: Path) -> str:
raise GenerationError("ACK has no pending generation") raise GenerationError("ACK has no pending generation")
ack = validate_ack(raw, row) ack = validate_ack(raw, row)
status = "acknowledged" if ack["result"] == "consumed" else "rejected" status = "acknowledged" if ack["result"] == "consumed" else "rejected"
db.execute(
"INSERT OR IGNORE INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(ack["ack_id"], ack["generation_id"], ack["run_id"],
ack["producer_peer_id"], ack["consumer_peer_id"],
ack["manifest_sha256"], ack["result"], ack["durability"],
ack["consumed_at"], ack["diagnostic"], ack["payload_sha256"]),
)
stored = db.execute(
"SELECT payload_sha256 FROM sync_acknowledgements WHERE ack_id=?",
(ack["ack_id"],),
).fetchone()
if stored != (ack["payload_sha256"],):
raise GenerationError("conflicting ACK replay")
if row[5] in ("acknowledged", "rejected"): if row[5] in ("acknowledged", "rejected"):
if row[5] != status: if row[5] != status:
raise GenerationError("conflicting ACK replay") raise GenerationError("conflicting ACK replay")
db.commit(); return "unchanged" db.rollback(); return "unchanged"
if row[5] != "waiting_acknowledgement": if row[5] != "waiting_acknowledgement":
raise GenerationError("generation is not awaiting ACK") raise GenerationError("generation is not awaiting ACK")
db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?", db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",

View file

@ -118,22 +118,6 @@ def update(state_path: Path, state: dict, phase: str, **fields: object) -> None:
atomic_write(state_path, state) atomic_write(state_path, state)
def failure_code(error: BaseException) -> str:
"""Map bounded operational evidence to one stable browser-facing code."""
diagnostic = str(error).lower()
if "transport_timeout:" in diagnostic or "peer worker timed out" in diagnostic:
return "transport_timeout"
if "peer_capacity_exhausted:" in diagnostic:
return "peer_capacity_exhausted"
if "expected android peer not found" in diagnostic:
return "device_unavailable"
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
return "capacity_exhausted"
if "conflict" in diagnostic or "wrong consumer" in diagnostic:
return "data_conflict"
return "internal_error"
def validate_result(value: dict, run_id: str) -> dict: def validate_result(value: dict, run_id: str) -> dict:
required = { required = {
"format", "format",
@ -319,7 +303,6 @@ def run(args: argparse.Namespace) -> int:
state, state,
"failed", "failed",
result="conflict", result="conflict",
error_code="sync_in_progress",
diagnostic="Another synchronization owns the common lock", diagnostic="Another synchronization owns the common lock",
finished_at=datetime.now(timezone.utc).isoformat(), finished_at=datetime.now(timezone.utc).isoformat(),
) )
@ -435,7 +418,6 @@ def main() -> int:
state, state,
phase, phase,
result=phase, result=phase,
error_code=("interrupted" if phase == "interrupted" else failure_code(error)),
diagnostic=str(error)[:MAX_DIAGNOSTIC], diagnostic=str(error)[:MAX_DIAGNOSTIC],
finished_at=datetime.now(timezone.utc).isoformat(), finished_at=datetime.now(timezone.utc).isoformat(),
) )

View file

@ -7,17 +7,7 @@ always produced and consumed by the existing generation services.
""" """
from __future__ import annotations from __future__ import annotations
import argparse, hashlib, json, os, sys, time, uuid
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from contextlib import closing, redirect_stdout from contextlib import closing, redirect_stdout
from pathlib import Path from pathlib import Path
@ -30,7 +20,6 @@ CAPS = {
"causal-delete-v1", "causal-delete-v1",
"mobile-history-v4", "mobile-history-v4",
"execution-draft-v1", "execution-draft-v1",
"generation-archive-v1",
} }
@ -54,12 +43,9 @@ def bounded(path: Path, limit: int = 65536) -> bytes:
def publish_json(path: Path, value: dict) -> None: def publish_json(path: Path, value: dict) -> None:
"""Publish a coordination object only after all bytes are durable.""" """Publish a coordination object only after all bytes are durable."""
raw = generation.canonical(value)
if len(raw) > 65536:
raise RuntimeError(f"{path.name} exceeds bound")
temporary = path.with_name("." + path.name + ".tmp-" + str(os.getpid())) temporary = path.with_name("." + path.name + ".tmp-" + str(os.getpid()))
with temporary.open("wb") as stream: with temporary.open("wb") as stream:
stream.write(raw) stream.write(generation.canonical(value))
stream.flush() stream.flush()
os.fsync(stream.fileno()) os.fsync(stream.fileno())
os.chmod(temporary, 0o600) os.chmod(temporary, 0o600)
@ -75,21 +61,13 @@ def run_adapter(
allow_missing_peer: bool = False, allow_missing_peer: bool = False,
) -> bool: ) -> bool:
remaining = max(1, int(deadline - time.monotonic())) remaining = max(1, int(deadline - time.monotonic()))
timeout = min(remaining, 30) result = __import__("subprocess").run(
try:
result = subprocess.run(
[str(adapter), operation, peer, str(root)], [str(adapter), operation, peer, str(root)],
stdin=subprocess.DEVNULL, stdin=__import__("subprocess").DEVNULL,
capture_output=True, capture_output=True,
timeout=timeout, timeout=min(remaining, 30),
check=False, check=False,
) )
except subprocess.TimeoutExpired as error:
# CONTRACT: an adapter timeout is an ambiguous transport failure. It
# never implies rollback, replay, or permission to retry a mutation.
raise RuntimeError(
f"transport_timeout: MTP {operation} did not finish within {timeout} seconds"
) from error
if result.returncode != 0: if result.returncode != 0:
diagnostic = ( diagnostic = (
result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed" result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed"
@ -104,70 +82,6 @@ def run_adapter(
return True return True
def push_adapter(
adapter: Path,
peer: str,
root: Path,
relative_paths: list[str],
deadline: float,
) -> None:
"""Publish only the phase-owned bounded MTP objects.
WHY: the durable transport root also retains inbound objects, legacy files,
and staging evidence; recursively uploading that root makes each exchange
grow with history and can time out before Android sees the request.
CONTRACT: callers name fixed relative files/directories already durably
published below ``root``. The adapter receives a private disposable view.
INVARIANT: no retained generation, ACK, tombstone, or source object is
deleted or modified while constructing the view.
"""
with tempfile.TemporaryDirectory(prefix="trainlog-mtp-outbox-", dir=root.parent) as directory:
outbox = Path(directory)
os.chmod(outbox, 0o700)
for relative in relative_paths:
source = root / relative
destination = outbox / relative
if (
source.is_symlink()
or not source.exists()
or not source.resolve().is_relative_to(root.resolve())
):
raise RuntimeError(f"invalid MTP outbox source: {relative}")
if source.is_dir() and any(path.is_symlink() for path in source.rglob("*")):
raise RuntimeError(f"MTP outbox source contains a symbolic link: {relative}")
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if source.is_dir():
shutil.copytree(source, destination, copy_function=shutil.copy2)
elif source.is_file():
shutil.copy2(source, destination)
else:
raise RuntimeError(f"invalid MTP outbox object: {relative}")
run_adapter(adapter, "push", peer, outbox, deadline)
def push_desktop_generation(
adapter: Path,
peer: str,
root: Path,
relative_path: str,
deadline: float,
) -> None:
"""Commit immutable bytes before making their correlated reference visible."""
push_adapter(adapter, peer, root, [relative_path], deadline)
push_adapter(
adapter,
peer,
root,
[
"request-v1.json",
"desktop-archive-acknowledgements-v1.json",
"desktop-consumption-ack-v1.json",
"desktop-generation-v1.json",
],
deadline,
)
def wait_file(path: Path, deadline: float, pump=None) -> None: def wait_file(path: Path, deadline: float, pump=None) -> None:
while not path.is_file(): while not path.is_file():
if time.monotonic() >= deadline: if time.monotonic() >= deadline:
@ -193,27 +107,6 @@ def wait_json(path: Path, deadline: float, predicate, pump=None) -> dict:
time.sleep(0.05) time.sleep(0.05)
def correlated_peer_error(root: Path, run_id: str) -> None:
path = root / "android-generation-error-v1.json"
if not path.is_file():
return
try:
value = json.loads(bounded(path))
except (OSError, json.JSONDecodeError):
return
if (
isinstance(value, dict)
and value.get("format") == "trainlog-sync-generation-error"
and value.get("version") == 1
and value.get("run_id") == run_id
and value.get("code") == "peer_capacity_exhausted"
):
raise RuntimeError(
"peer_capacity_exhausted: Android active generation capacity is exhausted; "
"archive acknowledged generations, then retry explicitly"
)
def load_peer(root: Path, expected: str) -> dict: def load_peer(root: Path, expected: str) -> dict:
value = json.loads(bounded(root / "android-peer-v1.json")) value = json.loads(bounded(root / "android-peer-v1.json"))
if ( if (
@ -273,29 +166,9 @@ def main() -> int:
} }
request_path = args.transport_root / "request-v1.json" request_path = args.transport_root / "request-v1.json"
publish_json(request_path, request) publish_json(request_path, request)
with closing(generation.connect_database(args.database)) as db:
acknowledgement_rows = db.execute(
"SELECT ack_json FROM sync_consumed_generations WHERE producer_peer_id=? "
"AND consumer_peer_id=? AND result='consumed' ORDER BY consumed_at,generation_id LIMIT 32",
(peer["peer_id"], desktop_peer),
).fetchall()
archive_acknowledgements = {
"format": "trainlog-sync-archive-acknowledgements",
"version": 1,
"run_id": args.run_id,
"android_peer_id": peer["peer_id"],
"desktop_peer_id": desktop_peer,
"acknowledgements": [json.loads(row[0]) for row in acknowledgement_rows],
}
archive_ack_path = args.transport_root / "desktop-archive-acknowledgements-v1.json"
publish_json(archive_ack_path, archive_acknowledgements)
if args.mode == "mtp": if args.mode == "mtp":
push_adapter( run_adapter(
args.mtp_adapter, args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
args.expected_peer,
args.transport_root,
["request-v1.json", "desktop-archive-acknowledgements-v1.json"],
deadline,
) )
emit( emit(
args.run_id, args.run_id,
@ -304,25 +177,16 @@ def main() -> int:
consumer_peer_id=desktop_peer, consumer_peer_id=desktop_peer,
) )
inbound_ref = args.transport_root / "android-generation-v1.json" inbound_ref = args.transport_root / "android-generation-v1.json"
def pump_inbound():
if pump is not None:
pump()
correlated_peer_error(args.transport_root, args.run_id)
inbound = wait_json( inbound = wait_json(
inbound_ref, deadline, lambda value: value.get("run_id") == args.run_id, pump_inbound inbound_ref, deadline, lambda value: value.get("run_id") == args.run_id, pump
) )
inbound_dir = args.transport_root / inbound["relative_path"] inbound_dir = args.transport_root / inbound["relative_path"]
ack = generation.consume_desktop(args.database, inbound_dir) ack = generation.consume_desktop(args.database, inbound_dir)
ack_path = args.transport_root / "desktop-consumption-ack-v1.json" ack_path = args.transport_root / "desktop-consumption-ack-v1.json"
publish_json(ack_path, ack) publish_json(ack_path, ack)
if args.mode == "mtp": if args.mode == "mtp":
push_adapter( run_adapter(
args.mtp_adapter, args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
args.expected_peer,
args.transport_root,
["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"],
deadline,
) )
if ack["result"] != "consumed": if ack["result"] != "consumed":
raise RuntimeError("desktop rejected Android generation") raise RuntimeError("desktop rejected Android generation")
@ -354,16 +218,8 @@ def main() -> int:
} }
publish_json(args.transport_root / "desktop-generation-v1.json", ref) publish_json(args.transport_root / "desktop-generation-v1.json", ref)
if args.mode == "mtp": if args.mode == "mtp":
# CONTRACT: the immutable generation must be fully visible before its run_adapter(
# mutable run-correlated reference. Publishing both in one MTP walk args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
# lets Android observe the reference while artifacts are still being
# transferred, turning a complete generation into a false rejection.
push_desktop_generation(
args.mtp_adapter,
args.expected_peer,
args.transport_root,
ref["relative_path"],
deadline,
) )
emit( emit(
args.run_id, args.run_id,

View file

@ -16,7 +16,7 @@
#include "trainlog/model.h" #include "trainlog/model.h"
#include "trainlog/status.h" #include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 22 #define TRAINLOG_DATABASE_SCHEMA_VERSION 21
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -1,49 +0,0 @@
/* Trainlog Web prepared-item V1 read model. */
#ifndef TRAINLOG_WEB_PREPARED_ITEMS_H
#define TRAINLOG_WEB_PREPARED_ITEMS_H
#include <stddef.h>
#include "trainlog/database.h"
#define TRAINLOG_WEB_PREPARED_ITEM_CAPACITY 32U
#define TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY (64U * 1024U)
typedef enum TrainlogWebPreparedItemKind {
TRAINLOG_WEB_PREPARED_AI_PROPOSAL = 0,
TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT = 1
} TrainlogWebPreparedItemKind;
typedef struct TrainlogWebPreparedItem {
TrainlogWebPreparedItemKind kind;
char identity[TRAINLOG_ID_MAX + 1U];
char state[32];
char title[TRAINLOG_NAME_MAX + 1U];
char planned_for[11];
size_t occurrence_count;
char provenance[32];
} TrainlogWebPreparedItem;
typedef struct TrainlogWebPreparedItems {
char generated_at[TRAINLOG_TIMESTAMP_MAX + 1U];
TrainlogWebPreparedItem items[TRAINLOG_WEB_PREPARED_ITEM_CAPACITY];
size_t item_count;
bool partial;
} TrainlogWebPreparedItems;
/* WHY: sync status is one run's evidence, not a durable inventory.
* CONTRACT: database/output are required and borrowed for the call. The
* projection is bounded, deterministic, read-only, and keeps AI proposals
* separate from executable drafts and completed sessions.
* INVARIANT: targets never become performed occurrences through this API. */
TrainlogStatus trainlog_web_prepared_items_load(
TrainlogDatabase *database, TrainlogWebPreparedItems *output);
/* Caller owns output. Success guarantees valid NUL-terminated JSON. */
TrainlogStatus trainlog_web_prepared_items_serialize(
const TrainlogWebPreparedItems *items,
char *output,
size_t capacity,
size_t *output_size);
#endif

View file

@ -192,8 +192,6 @@ trainlog_core_sources = files(
'src/json_writer.c', 'src/json_writer.c',
'src/web_dashboard.c', 'src/web_dashboard.c',
'src/web_dashboard_json.c', 'src/web_dashboard_json.c',
'src/web_prepared_items.c',
'src/web_prepared_items_json.c',
'src/duration.c', 'src/duration.c',
'src/id.c', 'src/id.c',
'src/timeutil.c', 'src/timeutil.c',
@ -337,15 +335,6 @@ test_web_dashboard = executable(
) )
test('web_dashboard', test_web_dashboard) test('web_dashboard', test_web_dashboard)
test_web_prepared_items = executable(
'test_web_prepared_items',
'tests/test_web_prepared_items.c',
dependencies: trainlog_core_dep,
include_directories: trainlog_private_include,
c_args: strict_c_args,
)
test('web_prepared_items', test_web_prepared_items)
test_dashboard_header = executable( test_dashboard_header = executable(
'test_dashboard_header', 'test_dashboard_header',
'tests/test_dashboard_header.c', 'tests/test_dashboard_header.c',
@ -770,12 +759,6 @@ test(
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'], args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
timeout: 120, timeout: 120,
) )
test(
'generation_archival',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_generation_archival.py'],
timeout: 120,
)
test( test(
'sync_orchestrator', 'sync_orchestrator',
find_program('python3'), find_program('python3'),
@ -786,11 +769,6 @@ test(
find_program('python3'), find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_deployment_tools.py'], args: [meson.project_source_root() / 'tests/test_sync_deployment_tools.py'],
) )
test(
'sync_error_classification',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_error_classification.py'],
)
test( test(
'schema_v21_migration', 'schema_v21_migration',
executable('test_schema_v21_migration', 'tests/test_schema_v21_migration.c', dependencies: trainlog_core_dep), executable('test_schema_v21_migration', 'tests/test_schema_v21_migration.c', dependencies: trainlog_core_dep),

View file

@ -870,20 +870,6 @@ static const char *const MIGRATE_V20_TO_V21_SQL =
"CREATE TABLE sync_causal_publications(operation_id TEXT NOT NULL REFERENCES sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));" "CREATE TABLE sync_causal_publications(operation_id TEXT NOT NULL REFERENCES sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));"
"PRAGMA user_version=21;COMMIT;"; "PRAGMA user_version=21;COMMIT;";
/* WHY: acknowledged payload copies cannot consume active admission forever,
* but generation identity, lineage and ACK evidence must remain permanent.
* CONTRACT: v22 adds an append-only archive ledger; it neither fabricates an
* ACK nor changes a generation status. INVARIANT: an archive row is valid only
* after production code has durably copied and verified the complete payload. */
static const char *const MIGRATE_V21_TO_V22_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE sync_generation_archives("
"generation_id TEXT PRIMARY KEY REFERENCES sync_generations(generation_id),"
"archive_path TEXT NOT NULL UNIQUE,manifest_sha256 TEXT NOT NULL,"
"archive_sha256 TEXT NOT NULL,archived_at TEXT NOT NULL,"
"audit_json TEXT NOT NULL);"
"PRAGMA user_version=22;COMMIT;";
/* CONTRACT: v18 was already deployed before bounded draft publication was /* CONTRACT: v18 was already deployed before bounded draft publication was
* added. Keep its version number and add only the nullable lifecycle cursor; * added. Keep its version number and add only the nullable lifecycle cursor;
* NULL deliberately means retryable/not yet published. */ * NULL deliberately means retryable/not yet published. */
@ -1579,7 +1565,7 @@ static TrainlogStatus initialize_or_validate_schema(
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else if (version == 11 || version == 12 || version == 13 || version == 14 || } else if (version == 11 || version == 12 || version == 13 || version == 14 ||
version == 15 || version == 16 || version == 17 || version == 18 || version == 15 || version == 16 || version == 17 || version == 18 ||
version == 19 || version == 20 || version == 21 || version == 22) { version == 19 || version == 20 || version == 21) {
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else { } else {
if (version == 1) { if (version == 1) {
@ -1752,9 +1738,6 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK && version < 21) { if (status == TRAINLOG_STATUS_OK && version < 21) {
status = execute_sql(database, MIGRATE_V20_TO_V21_SQL); status = execute_sql(database, MIGRATE_V20_TO_V21_SQL);
} }
if (status == TRAINLOG_STATUS_OK && version < 22) {
status = execute_sql(database, MIGRATE_V21_TO_V22_SQL);
}
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database); status = ensure_v18_ai_draft_publication_state(database);
} }
@ -1770,7 +1753,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic( set_open_diagnostic(
output_diagnostic, output_diagnostic,
output_diagnostic_capacity, output_diagnostic_capacity,
version == 0 ? "create schema v22" : "migrate database to schema v22", version == 0 ? "create schema v21" : "migrate database to schema v21",
database->connection, database->connection,
SQLITE_ERROR SQLITE_ERROR
); );

View file

@ -253,116 +253,6 @@ static TrainlogStatus pull_folder(const TrainlogGenerationMtpIo *io,
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
static TrainlogStatus pull_named_file(const TrainlogGenerationMtpIo *io,
const Selection *selection,
uint32_t parent,
const char *name,
const char *local,
bool required) {
TrainlogMtpEntry entry;
TrainlogStatus status = one_named(io, selection, parent, name, false, &entry);
char path[1024], temporary[1060];
struct stat value;
int written;
if (status == TRAINLOG_STATUS_NOT_FOUND && !required) {
return TRAINLOG_STATUS_OK;
}
if (status != TRAINLOG_STATUS_OK || entry.size_bytes > 65536U) {
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status;
}
written = snprintf(path, sizeof(path), "%s/%s", local, name);
if (written < 0 || (size_t)written >= sizeof(path)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
written = snprintf(temporary, sizeof(temporary), "%s.part.%ld", path, (long)getpid());
if (written < 0 || (size_t)written >= sizeof(temporary)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)unlink(temporary);
status = io->receive(selection->bus, selection->device, entry.item_id, temporary);
if (status == TRAINLOG_STATUS_OK &&
(stat(temporary, &value) != 0 || value.st_size < 0 ||
(uint64_t)value.st_size != entry.size_bytes || rename(temporary, path) != 0)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (status != TRAINLOG_STATUS_OK) {
(void)unlink(temporary);
}
return status;
}
static bool referenced_android_generation(const char *local, char *generation, size_t size) {
char path[1024], buffer[65537];
int fd, written;
ssize_t count;
yyjson_doc *document;
yyjson_val *root, *relative;
const char *value, *prefix = "android-objects/generations/";
bool valid = false;
written = snprintf(path, sizeof(path), "%s/android-generation-v1.json", local);
if (written < 0 || (size_t)written >= sizeof(path) ||
(fd = open(path, O_RDONLY | O_CLOEXEC)) < 0) {
return false;
}
count = read(fd, buffer, sizeof(buffer));
(void)close(fd);
if (count <= 0 || (size_t)count >= sizeof(buffer)) {
return false;
}
document = yyjson_read(buffer, (size_t)count, YYJSON_READ_NOFLAG);
if (document == NULL) {
return false;
}
root = yyjson_doc_get_root(document);
relative = yyjson_obj_get(root, "relative_path");
value = yyjson_get_str(relative);
if (yyjson_is_obj(root) && value != NULL && strncmp(value, prefix, strlen(prefix)) == 0) {
const char *leaf = value + strlen(prefix);
if (safe_leaf(leaf) && strlen(leaf) < size) {
(void)memcpy(generation, leaf, strlen(leaf) + 1U);
valid = true;
}
}
yyjson_doc_free(document);
return valid;
}
static TrainlogStatus pull_referenced_generation(const TrainlogGenerationMtpIo *io,
const Selection *selection,
const char *local,
const char *generation) {
TrainlogMtpEntry android_objects, generations, selected;
char path[1024];
uint64_t total = 0U;
size_t objects = 0U;
TrainlogStatus status;
status = one_named(io, selection, selection->root, "android-objects", true, &android_objects);
if (status == TRAINLOG_STATUS_OK) {
status = one_named(io, selection, android_objects.item_id, "generations", true, &generations);
}
if (status == TRAINLOG_STATUS_OK) {
status = one_named(io, selection, generations.item_id, generation, true, &selected);
}
if (status != TRAINLOG_STATUS_OK ||
snprintf(path, sizeof(path), "%s/android-objects/generations/%s", local, generation) < 0 ||
strlen(local) + strlen("/android-objects/generations/") + strlen(generation) >= sizeof(path)) {
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status;
}
char parent[1024];
if (snprintf(parent, sizeof(parent), "%s/android-objects/generations", local) < 0 ||
strlen(local) + strlen("/android-objects/generations") >= sizeof(parent) ||
mkdir_private(local) != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
char objects_path[1024];
if (snprintf(objects_path, sizeof(objects_path), "%s/android-objects", local) < 0 ||
strlen(local) + strlen("/android-objects") >= sizeof(objects_path) ||
mkdir_private(objects_path) != TRAINLOG_STATUS_OK || mkdir_private(parent) != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return pull_folder(io, selection, selected.item_id, path, 0U, &total, &objects);
}
static bool same_files(const char *left, const char *right) { static bool same_files(const char *left, const char *right) {
int left_fd = open(left, O_RDONLY | O_CLOEXEC); int left_fd = open(left, O_RDONLY | O_CLOEXEC);
int right_fd = open(right, O_RDONLY | O_CLOEXEC); int right_fd = open(right, O_RDONLY | O_CLOEXEC);
@ -569,8 +459,9 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
char *diagnostic, char *diagnostic,
size_t diagnostic_size) { size_t diagnostic_size) {
Selection selection; Selection selection;
uint64_t total = 0U;
size_t objects = 0U;
TrainlogStatus status; TrainlogStatus status;
char generation[256];
if (io == NULL || expected == NULL || local == NULL) { if (io == NULL || expected == NULL || local == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
@ -579,32 +470,7 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size); status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size);
} }
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
/* WHY: Documents/Trainlog retains prior generations and causal evidence. status = pull_folder(io, &selection, selection.root, local, 0U, &total, &objects);
* Recursively mirroring that durable history makes foreground sync cost
* grow with retention and can exhaust the run deadline before admission.
* CONTRACT: pull only current coordination objects and the one Android
* generation named by the current reference. Missing optional objects
* remain a normal polling state for the Python conversation.
* INVARIANT: no remote object, ACK, tombstone, or retained generation is
* removed or rewritten by this bounded read. */
status = pull_named_file(
io, &selection, selection.root, "android-peer-v1.json", local, true);
}
if (status == TRAINLOG_STATUS_OK) {
status = pull_named_file(
io, &selection, selection.root, "android-generation-v1.json", local, false);
}
if (status == TRAINLOG_STATUS_OK) {
status = pull_named_file(
io, &selection, selection.root, "android-consumption-ack-v1.json", local, false);
}
if (status == TRAINLOG_STATUS_OK) {
status = pull_named_file(
io, &selection, selection.root, "android-generation-error-v1.json", local, false);
}
if (status == TRAINLOG_STATUS_OK &&
referenced_android_generation(local, generation, sizeof(generation))) {
status = pull_referenced_generation(io, &selection, local, generation);
} }
return status; return status;
} }

View file

@ -1,174 +0,0 @@
#include "trainlog/web_prepared_items.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <sqlite3.h>
#include "database_internal.h"
static bool copy_column(sqlite3_stmt *statement, int column, char *output, size_t capacity) {
const unsigned char *text;
int bytes;
if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
output[0] = '\0';
return true;
}
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) {
return false;
}
text = sqlite3_column_text(statement, column);
bytes = sqlite3_column_bytes(statement, column);
if (text == NULL || bytes < 0 || (size_t)bytes >= capacity) {
return false;
}
(void)memcpy(output, text, (size_t)bytes);
output[bytes] = '\0';
return true;
}
static bool read_count(sqlite3_stmt *statement, int column, size_t *output) {
sqlite3_int64 value;
if (sqlite3_column_type(statement, column) != SQLITE_INTEGER) {
return false;
}
value = sqlite3_column_int64(statement, column);
if (value < 0 || (uint64_t)value > (uint64_t)SIZE_MAX) {
return false;
}
*output = (size_t)value;
return true;
}
static bool format_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
time_t now = time(NULL);
struct tm utc;
return now != (time_t)-1 && gmtime_r(&now, &utc) != NULL &&
strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
}
static TrainlogStatus load_ai_proposals(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
static const char SQL[] =
"SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,''),"
"COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' "
"END FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON "
"e.draft_row_id=d.id GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),"
"d.created_at,d.draft_id LIMIT ?1;";
sqlite3_stmt *statement = NULL;
int step;
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_int64(
statement, 1, (sqlite3_int64)(TRAINLOG_WEB_PREPARED_ITEM_CAPACITY + 1U)) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
TrainlogWebPreparedItem *item;
if (output->item_count == TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
output->partial = true;
continue;
}
item = &output->items[output->item_count];
item->kind = TRAINLOG_WEB_PREPARED_AI_PROPOSAL;
if (!copy_column(statement, 0, item->identity, sizeof(item->identity)) ||
!copy_column(statement, 1, item->title, sizeof(item->title)) ||
!copy_column(statement, 2, item->planned_for, sizeof(item->planned_for)) ||
!read_count(statement, 3, &item->occurrence_count) ||
!copy_column(statement, 4, item->state, sizeof(item->state))) {
goto fail;
}
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "ai_import");
++output->item_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_execution_drafts(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
static const char SQL[] =
"SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),''),"
"COALESCE(json_array_length(d.payload_json,'$.exercises'),0) FROM execution_drafts d "
"WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
"f.session_id=d.session_id) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
"c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) "
"ORDER BY CASE d.state WHEN 'active' THEN 0 ELSE 1 END,d.session_id LIMIT ?1;";
sqlite3_stmt *statement = NULL;
int step;
size_t remaining = TRAINLOG_WEB_PREPARED_ITEM_CAPACITY - output->item_count;
if (remaining == 0U) {
return TRAINLOG_STATUS_OK;
}
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_int64(statement, 1, (sqlite3_int64)(remaining + 1U)) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
TrainlogWebPreparedItem *item;
if (output->item_count == TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
output->partial = true;
continue;
}
item = &output->items[output->item_count];
item->kind = TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT;
if (!copy_column(statement, 0, item->identity, sizeof(item->identity)) ||
!copy_column(statement, 1, item->state, sizeof(item->state)) ||
!copy_column(statement, 2, item->title, sizeof(item->title)) ||
!copy_column(statement, 3, item->planned_for, sizeof(item->planned_for)) ||
!read_count(statement, 4, &item->occurrence_count)) {
goto fail;
}
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "execution_store");
++output->item_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_web_prepared_items_load(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
TrainlogStatus status;
TrainlogStatus end_status;
if (database == NULL || output == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(output, 0, sizeof(*output));
if (!format_now(output->generated_at)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
status = trainlog_database_read_snapshot_begin(database);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = load_ai_proposals(database, output);
if (status == TRAINLOG_STATUS_OK) {
status = load_execution_drafts(database, output);
}
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
return status == TRAINLOG_STATUS_OK ? end_status : status;
}

View file

@ -1,140 +0,0 @@
#include "trainlog/web_prepared_items.h"
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <utf8proc.h>
typedef struct JsonWriter {
char *output;
size_t capacity;
size_t length;
bool failed;
} JsonWriter;
static void append(JsonWriter *writer, const char *format, ...)
__attribute__((format(printf, 2, 3)));
static void append(JsonWriter *writer, const char *format, ...) {
va_list arguments;
int written;
if (writer->failed || writer->length >= writer->capacity) {
writer->failed = true;
return;
}
va_start(arguments, format);
written = vsnprintf(
writer->output + writer->length, writer->capacity - writer->length, format, arguments);
va_end(arguments);
if (written < 0 || (size_t)written >= writer->capacity - writer->length) {
writer->failed = true;
return;
}
writer->length += (size_t)written;
}
static bool valid_utf8(const char *value) {
const utf8proc_uint8_t *cursor = (const utf8proc_uint8_t *)value;
while (*cursor != '\0') {
utf8proc_int32_t codepoint;
utf8proc_ssize_t sequence = utf8proc_iterate(cursor, -1, &codepoint);
(void)codepoint;
if (sequence <= 0) {
return false;
}
cursor += (size_t)sequence;
}
return true;
}
static void string(JsonWriter *writer, const char *value) {
const unsigned char *cursor = (const unsigned char *)value;
if (!valid_utf8(value)) {
writer->failed = true;
return;
}
append(writer, "\"");
while (!writer->failed && *cursor != '\0') {
unsigned char byte = *cursor++;
if (byte == '"' || byte == '\\') {
append(writer, "\\%c", (int)byte);
} else if (byte == '\b') {
append(writer, "\\b");
} else if (byte == '\f') {
append(writer, "\\f");
} else if (byte == '\n') {
append(writer, "\\n");
} else if (byte == '\r') {
append(writer, "\\r");
} else if (byte == '\t') {
append(writer, "\\t");
} else if (byte < 0x20U) {
append(writer, "\\u%04x", (unsigned int)byte);
} else {
append(writer, "%c", (int)byte);
}
}
append(writer, "\"");
}
TrainlogStatus trainlog_web_prepared_items_serialize(
const TrainlogWebPreparedItems *items,
char *output,
size_t capacity,
size_t *output_size) {
JsonWriter writer = {output, capacity, 0U, false};
size_t index;
if (items == NULL || output == NULL || output_size == NULL || capacity == 0U ||
items->item_count > TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_size = 0U;
append(&writer, "{\"api_version\":1,\"generated_at\":");
string(&writer, items->generated_at);
append(&writer, ",\"partial\":%s,\"items\":[", items->partial ? "true" : "false");
for (index = 0U; index < items->item_count; ++index) {
const TrainlogWebPreparedItem *item = &items->items[index];
const char *kind;
if (item->kind == TRAINLOG_WEB_PREPARED_AI_PROPOSAL) {
kind = "ai_proposal";
} else if (item->kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT) {
kind = "execution_draft";
} else {
output[0] = '\0';
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
append(&writer, "%s{\"identity\":", index == 0U ? "" : ",");
string(&writer, item->identity);
append(&writer, ",\"kind\":");
string(&writer, kind);
append(&writer, ",\"title\":");
string(&writer, item->title);
append(&writer, ",\"planned_for\":");
if (item->planned_for[0] == '\0') {
append(&writer, "null");
} else {
string(&writer, item->planned_for);
}
append(&writer, ",\"state\":");
string(&writer, item->state);
append(&writer, ",\"occurrence_count\":%zu,\"provenance\":", item->occurrence_count);
string(&writer, item->provenance);
append(&writer, "}");
}
append(&writer, "]}\n");
if (writer.failed) {
if (capacity > 0U) {
output[0] = '\0';
}
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
*output_size = writer.length;
return TRAINLOG_STATUS_OK;
}

View file

@ -21,7 +21,6 @@
#include <yyjson.h> #include <yyjson.h>
#include "trainlog/web_dashboard.h" #include "trainlog/web_dashboard.h"
#include "trainlog/web_prepared_items.h"
#include "trainlog/dashboard_layout.h" #include "trainlog/dashboard_layout.h"
#include "web_assets.h" #include "web_assets.h"
@ -708,27 +707,6 @@ static enum MHD_Result handle_request(void *closure,
} }
return queue_json(connection, MHD_HTTP_OK, json, NULL); return queue_json(connection, MHD_HTTP_OK, json, NULL);
} }
if (strcmp(url, "/api/v1/prepared-items") == 0) {
char json[TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY];
size_t json_size = 0U;
TrainlogWebPreparedItems items;
if (!is_get) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"GET");
}
if (trainlog_web_prepared_items_load(context->database, &items) != TRAINLOG_STATUS_OK ||
trainlog_web_prepared_items_serialize(&items, json, sizeof(json), &json_size) !=
TRAINLOG_STATUS_OK) {
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"prepared_items_unavailable\"}\n",
NULL);
}
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
if (strcmp(url, "/api/v1/dashboard-layout") == 0) { if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
TrainlogDashboardLayout layout; TrainlogDashboardLayout layout;
TrainlogDashboardLayout saved; TrainlogDashboardLayout saved;

View file

@ -146,7 +146,7 @@ static TrainlogStatus receive(unsigned int b, unsigned int d, uint32_t id, const
if (!f) { if (!f) {
return TRAINLOG_STATUS_SYSTEM_ERROR; return TRAINLOG_STATUS_SYSTEM_ERROR;
} }
bytes = id == 12U ? 3U : o->size; bytes = id == 9U ? 3U : o->size;
if (fwrite(o->data, 1, bytes, f) != bytes) { if (fwrite(o->data, 1, bytes, f) != bytes) {
fclose(f); fclose(f);
return TRAINLOG_STATUS_SYSTEM_ERROR; return TRAINLOG_STATUS_SYSTEM_ERROR;
@ -232,20 +232,11 @@ static int test_pull_and_manifest_last(void) {
char *manifest; char *manifest;
reset(); reset();
CHECK(mkdtemp(root) != NULL); CHECK(mkdtemp(root) != NULL);
add(4, add(4, 2, true, "android-objects", NULL);
2, add(5, 4, true, "generations", NULL);
false, add(6, 5, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
"android-generation-v1.json", add(7, 6, false, "payload.json", "payload");
"{\"relative_path\":\"android-objects/generations/gen_11111111-1111-4111-8111-" add(8, 6, false, "manifest.json", "manifest");
"111111111111\"}");
add(5, 2, true, "android-objects", NULL);
add(6, 5, true, "generations", NULL);
add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
add(8, 7, false, "payload.json", "payload");
add(9, 7, false, "manifest.json", "manifest");
add(10, 6, true, "gen_22222222-2222-4222-8222-222222222222", NULL);
add(11, 10, false, "retained.bin", "old");
find(11)->size = 99U;
CHECK(trainlog_generation_mtp_pull( CHECK(trainlog_generation_mtp_pull(
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) == &io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
TRAINLOG_STATUS_OK); TRAINLOG_STATUS_OK);
@ -296,25 +287,12 @@ static int test_ambiguity_and_truncation(void) {
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) == &io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
TRAINLOG_STATUS_NOT_FOUND); TRAINLOG_STATUS_NOT_FOUND);
reset(); reset();
add(4, add(9, 2, false, "bad.bin", "abc");
2, find(9)->size = 99U;
false,
"android-generation-v1.json",
"{\"relative_path\":\"android-objects/generations/gen_11111111-1111-4111-8111-"
"111111111111\"}");
add(5, 2, true, "android-objects", NULL);
add(6, 5, true, "generations", NULL);
add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
add(12, 7, false, "bad.bin", "abc");
find(12)->size = 99U;
status = trainlog_generation_mtp_pull( status = trainlog_generation_mtp_pull(
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)); &io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag));
CHECK(status != TRAINLOG_STATUS_OK); CHECK(status != TRAINLOG_STATUS_OK);
CHECK(trainlog_test_join_path( CHECK(trainlog_test_join_path(path, sizeof(path), root, "bad.bin"));
path,
sizeof(path),
root,
"android-objects/generations/gen_11111111-1111-4111-8111-111111111111/bad.bin"));
CHECK(access(path, F_OK) != 0); CHECK(access(path, F_OK) != 0);
return 0; return 0;
} }

View file

@ -27,17 +27,16 @@ int main(void)
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK); TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
trainlog_database_close(database);database=NULL; trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK); CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK); CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
CHECK(sqlite3_close(raw)==SQLITE_OK); CHECK(sqlite3_close(raw)==SQLITE_OK);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U); CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
trainlog_database_close(database);database=NULL; trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK); CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(scalar(raw,"PRAGMA user_version")==22); CHECK(scalar(raw,"PRAGMA user_version")==21);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0); CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0); CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0); CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generation_archives")==0);
CHECK(sqlite3_close(raw)==SQLITE_OK);CHECK(unlink(path)==0); CHECK(sqlite3_close(raw)==SQLITE_OK);CHECK(unlink(path)==0);
return 0; return 0;
} }

View file

@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v22: SQLite rc=", CHECK(strncmp(diagnostic, "migrate database to schema v21: SQLite rc=",
strlen("migrate database to schema v22: SQLite rc=")) == 0); strlen("migrate database to schema v21: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL);
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0); CHECK(fd >= 0);
CHECK(close(fd) == 0); CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=23;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_exec(raw, "PRAGMA user_version=22;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 23 is newer") != NULL); CHECK(strstr(diagnostic, "schema version 22 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0); CHECK(unlink(path) == 0);
return true; return true;

View file

@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v22") != NULL); CHECK(strstr(diagnostic, "migrate database to schema v21") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -1,110 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>
#include "trainlog/database.h"
#include "trainlog/web_prepared_items.h"
#include "database_internal.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "check failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
return false; \
} \
} while (0)
static bool execute(TrainlogDatabase *database, const char *sql) {
char *error = NULL;
int status = sqlite3_exec(database->connection, sql, NULL, NULL, &error);
if (status != SQLITE_OK) {
(void)fprintf(stderr, "SQLite fixture failed: %s\n", error == NULL ? "unknown" : error);
}
sqlite3_free(error);
return status == SQLITE_OK;
}
static bool test_distinct_durable_items(void) {
TrainlogDatabase *database = NULL;
TrainlogWebPreparedItems items;
char json[TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY];
size_t json_size = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(execute(database,
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,"
"data_fields) VALUES('ex_00000000-0000-4000-8000-000000000001','Test','test','reps',"
"'sets',0);"
"INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes,"
"published_at) VALUES('aid_00000000-0000-4000-8000-000000000001',"
"'2026-09-16T10:00:00Z','2026-09-17','training','Prepared title',NULL,"
"'2026-09-16T11:00:00Z');"
"INSERT INTO ai_session_draft_entries(draft_row_id,entry_id,position,exercise_row_id,"
"source_exercise_id,recording_mode,tracking_mode,data_fields,equipment_id,load_mode,"
"target_sets,target_reps,target_duration_seconds,target_weight_kg,rest_seconds) "
"SELECT d.id,'sxe_00000000-0000-4000-8000-000000000001',0,e.id,e.exercise_id,'sets',"
"'reps',0,NULL,'none',2,8,NULL,NULL,60 FROM ai_session_drafts d,exercises e WHERE "
"e.exercise_id='ex_00000000-0000-4000-8000-000000000001';"
"INSERT INTO execution_drafts(session_id,session_type,source_session_id,started_at,"
"revision_id,parent_revision_id,state,payload_json) VALUES("
"'se_00000000-0000-4000-8000-000000000001','training',NULL,'2026-09-17T12:00:00Z',"
"'dr_one',NULL,'active','{\"exercises\":[{},{}]}');"));
{
TrainlogStatus status = trainlog_web_prepared_items_load(database, &items);
if (status != TRAINLOG_STATUS_OK) {
(void)fprintf(stderr, "prepared load failed: %d sqlite=%s\n", (int)status,
sqlite3_errmsg(database->connection));
}
CHECK(status == TRAINLOG_STATUS_OK);
}
CHECK(items.item_count == 2U);
CHECK(items.items[0].kind == TRAINLOG_WEB_PREPARED_AI_PROPOSAL);
CHECK(strcmp(items.items[0].state, "published") == 0);
CHECK(strcmp(items.items[0].title, "Prepared title") == 0);
CHECK(items.items[0].occurrence_count == 1U);
CHECK(items.items[1].kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT);
CHECK(strcmp(items.items[1].state, "active") == 0);
CHECK(items.items[1].occurrence_count == 2U);
CHECK(trainlog_web_prepared_items_serialize(
&items, json, sizeof(json), &json_size) == TRAINLOG_STATUS_OK);
CHECK(json_size > 0U);
CHECK(strstr(json, "\"kind\":\"ai_proposal\"") != NULL);
CHECK(strstr(json, "\"kind\":\"execution_draft\"") != NULL);
items.items[0].title[0] = (char)0xc3;
items.items[0].title[1] = '\0';
CHECK(trainlog_web_prepared_items_serialize(
&items, json, sizeof(json), &json_size) == TRAINLOG_STATUS_SYSTEM_ERROR);
CHECK(json_size == 0U && json[0] == '\0');
trainlog_database_close(database);
return true;
}
static bool test_finalized_and_deleted_drafts_are_hidden(void) {
TrainlogDatabase *database = NULL;
TrainlogWebPreparedItems items;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(execute(database,
"INSERT INTO execution_drafts(session_id,session_type,revision_id,state,payload_json) "
"VALUES('se_00000000-0000-4000-8000-000000000002','training','dr_two','pending',"
"'{\"exercises\":[]}'),('se_00000000-0000-4000-8000-000000000003','training',"
"'dr_three','pending','{\"exercises\":[]}');"
"INSERT INTO execution_draft_finalizations(session_id,final_revision_id,finalized_at) "
"VALUES('se_00000000-0000-4000-8000-000000000002','dr_final',"
"'2026-09-17T12:00:00Z');"
"INSERT INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted) "
"VALUES('execution_draft','se_00000000-0000-4000-8000-000000000003','dr_delete',1);"));
CHECK(trainlog_web_prepared_items_load(database, &items) == TRAINLOG_STATUS_OK);
CHECK(items.item_count == 0U);
trainlog_database_close(database);
return true;
}
int main(void) {
if (!test_distinct_durable_items() || !test_finalized_and_deleted_drafts_are_hidden()) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}

View file

@ -255,19 +255,6 @@ static bool test_http_contract(TrainlogDatabase *database) {
CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL); CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL);
CHECK(strstr(response, "\"window_days\":90") != NULL); CHECK(strstr(response, "\"window_days\":90") != NULL);
CHECK(strstr(response, "\"partial\":false") != NULL); CHECK(strstr(response, "\"partial\":false") != NULL);
CHECK(exchange(port,
"GET /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "\"api_version\":1") != NULL);
CHECK(strstr(response, "\"items\":[]") != NULL);
CHECK(exchange(port,
"POST /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Content-Length: 0\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 405") != NULL);
{ {
TrainlogDashboardLayout layout; TrainlogDashboardLayout layout;
char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY]; char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];

View file

@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest'
import { parsePreparedItems } from './preparedItems'
describe('projection des préparations', () => {
it('distingue une proposition IA d’un brouillon d’exécution', () => {
const value = parsePreparedItems({
api_version: 1,
generated_at: '2026-09-17T12:00:00Z',
partial: false,
items: [
{ identity: 'aid_one', kind: 'ai_proposal', title: 'Plan', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' },
{ identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' },
],
})
expect(value.items.map((item) => item.kind)).toEqual(['ai_proposal', 'execution_draft'])
})
it('refuse les compteurs et types non bornés', () => {
expect(() => parsePreparedItems({ api_version: 1, generated_at: 'x', partial: false, items: [{ identity: 'x', kind: 'session', title: '', planned_for: null, state: '', occurrence_count: -1, provenance: '' }] })).toThrow()
})
})

View file

@ -1,56 +0,0 @@
export type PreparedItemKind = 'ai_proposal' | 'execution_draft'
export interface PreparedItem {
identity: string
kind: PreparedItemKind
title: string
planned_for: string | null
state: string
occurrence_count: number
provenance: string
}
export interface PreparedItemsSnapshot {
api_version: 1
generated_at: string
partial: boolean
items: PreparedItem[]
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function validItem(value: unknown): value is PreparedItem {
if (!record(value)) return false
const kind = value.kind
return (kind === 'ai_proposal' || kind === 'execution_draft') &&
typeof value.identity === 'string' &&
typeof value.title === 'string' &&
(value.planned_for === null || typeof value.planned_for === 'string') &&
typeof value.state === 'string' &&
Number.isSafeInteger(value.occurrence_count) &&
Number(value.occurrence_count) >= 0 &&
typeof value.provenance === 'string'
}
export function parsePreparedItems(value: unknown): PreparedItemsSnapshot {
if (!record(value) || value.api_version !== 1 ||
typeof value.generated_at !== 'string' ||
typeof value.partial !== 'boolean' ||
!Array.isArray(value.items) ||
value.items.length > 32 ||
!value.items.every(validItem)) {
throw new TypeError('projection des préparations invalide')
}
return value as unknown as PreparedItemsSnapshot
}
export async function fetchPreparedItems(signal?: AbortSignal): Promise<PreparedItemsSnapshot> {
const response = await fetch('/api/v1/prepared-items', {
headers: { Accept: 'application/json' },
signal,
})
if (!response.ok) throw new Error(`prepared-items HTTP ${response.status}`)
return parsePreparedItems(await response.json())
}

View file

@ -1,126 +1,11 @@
export type SyncPhase = export type SyncPhase = 'idle' | 'requested' | 'waiting_android_publication' | 'running' | 'local_import_committed' | 'published' | 'waiting_acknowledgement' | 'peer_consumed' | 'completed' | 'failed' | 'interrupted' | 'explicitly_degraded'
| 'idle' export interface SyncDraftSummary { draft_id: string; state: 'active'|'pending'|'finalized'|'stale'; session_type: string; occurrence_count: number }
| 'requested' export interface SyncStatus { api_version?: number; enabled?: boolean; phase: SyncPhase; result: string; run_id?: string; request_id?: string; progress_revision?: number; diagnostic?: string; sessions_reconciled?: number|null; inbound_generation_id?: string|null; outbound_generation_id?: string|null; missing_capabilities?: string[]; domains?: Record<string,boolean>; drafts?: SyncDraftSummary[]; finished_at?: string|null }
| 'waiting_android_publication' let csrfToken=''
| 'running' const phases=new Set<SyncPhase>(['idle','requested','waiting_android_publication','running','local_import_committed','published','waiting_acknowledgement','peer_consumed','completed','failed','interrupted','explicitly_degraded'])
| 'local_import_committed' 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 }
| 'published' 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())}
| 'waiting_acknowledgement' 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)}
| 'peer_consumed'
| 'completed'
| 'failed'
| 'interrupted'
| 'explicitly_degraded'
export interface SyncDraftSummary {
draft_id: string
state: 'active' | 'pending' | 'finalized' | 'stale'
session_type: string
occurrence_count: number
}
export interface SyncStatus {
api_version?: number
enabled?: boolean
phase: SyncPhase
result: string
run_id?: string
request_id?: string
progress_revision?: number
diagnostic?: string
error_code?: string
sessions_reconciled?: number | null
inbound_generation_id?: string | null
outbound_generation_id?: string | null
missing_capabilities?: string[]
domains?: Record<string, boolean>
drafts?: SyncDraftSummary[]
started_at?: string | null
finished_at?: string | null
}
let csrfToken = ''
const phases = new Set<SyncPhase>([
'idle',
'requested',
'waiting_android_publication',
'running',
'local_import_committed',
'published',
'waiting_acknowledgement',
'peer_consumed',
'completed',
'failed',
'interrupted',
'explicitly_degraded',
])
function optionalString(value: unknown): boolean {
return value === undefined || value === null || typeof value === 'string'
}
function validDraft(value: unknown): value is SyncDraftSummary {
if (typeof value !== 'object' || value === null) return false
const draft = value as Partial<SyncDraftSummary>
return typeof draft.draft_id === 'string' &&
['active', 'pending', 'finalized', 'stale'].includes(draft.state ?? '') &&
typeof draft.session_type === 'string' &&
Number.isSafeInteger(draft.occurrence_count) &&
Number(draft.occurrence_count) >= 0
}
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' ||
!optionalString(status.run_id) ||
!optionalString(status.request_id) ||
!optionalString(status.diagnostic) ||
!optionalString(status.error_code) ||
!optionalString(status.started_at) ||
!optionalString(status.finished_at) ||
(status.progress_revision !== undefined &&
(!Number.isSafeInteger(status.progress_revision) || status.progress_revision < 0))) {
throw new Error('statut sync invalide')
}
if (status.drafts !== undefined &&
(!Array.isArray(status.drafts) || status.drafts.length > 32 || !status.drafts.every(validDraft))) {
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 { export function newRequestId(): string {
/* /*
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure * WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
@ -142,6 +27,4 @@ export function newRequestId(): string {
].join('-') ].join('-')
return `sy_${uuid}` return `sy_${uuid}`
} }
export function syncIsActive(phase: SyncPhase): boolean { export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}
return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase)
}

View file

@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { fetchHealth, type Health } from '../api/health' import { fetchHealth, type Health } from '../api/health'
import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard' import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard'
import { fetchPreparedItems, type PreparedItemsSnapshot } from '../api/preparedItems'
import { Footer } from '../components/Footer' import { Footer } from '../components/Footer'
import { Header } from '../components/Header' import { Header } from '../components/Header'
import { DashboardPage } from '../routes/DashboardPage' import { DashboardPage } from '../routes/DashboardPage'
@ -24,24 +23,10 @@ export function App() {
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null) const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null)
const [dashboardPending, setDashboardPending] = useState(true) const [dashboardPending, setDashboardPending] = useState(true)
const [dashboardFailed, setDashboardFailed] = useState(false) const [dashboardFailed, setDashboardFailed] = useState(false)
const [preparedItems, setPreparedItems] = useState<PreparedItemsSnapshot | null>(null)
const [preparedItemsPending, setPreparedItemsPending] = useState(true)
const [preparedItemsFailed, setPreparedItemsFailed] = useState(false)
const reloadDashboard = useCallback(() => { const reloadDashboard = useCallback(() => {
fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) }) fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) })
.catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false)) .catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false))
}, []) }, [])
const reloadPreparedItems = useCallback(() => {
setPreparedItemsPending(true)
fetchPreparedItems().then((value) => {
setPreparedItems(value)
setPreparedItemsFailed(false)
}).catch(() => {
// INVARIANT: a transient reread failure does not erase the last durable
// projection already shown to the user.
setPreparedItemsFailed(true)
}).finally(() => setPreparedItemsPending(false))
}, [])
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@ -54,25 +39,10 @@ export function App() {
}).catch(() => { }).catch(() => {
setDashboard(null); setDashboardFailed(true) setDashboard(null); setDashboardFailed(true)
}).finally(() => setDashboardPending(false)) }).finally(() => setDashboardPending(false))
fetchPreparedItems(controller.signal).then((value) => {
setPreparedItems(value)
setPreparedItemsFailed(false)
}).catch(() => {
setPreparedItemsFailed(true)
}).finally(() => setPreparedItemsPending(false))
return () => controller.abort() return () => controller.abort()
}, []) }, [])
const content = route.id === 'dashboard' ? ( const content = route.id === 'dashboard' ? <DashboardPage dashboard={dashboard} pending={dashboardPending} failed={dashboardFailed} /> :
<DashboardPage
dashboard={dashboard}
pending={dashboardPending}
failed={dashboardFailed}
preparedItems={preparedItems}
preparedItemsPending={preparedItemsPending}
preparedItemsFailed={preparedItemsFailed}
/>
) :
<PlaceholderPage title={placeholderContent[route.id][0]} <PlaceholderPage title={placeholderContent[route.id][0]}
eyebrow={placeholderContent[route.id][1]} eyebrow={placeholderContent[route.id][1]}
description={placeholderContent[route.id][2]} /> description={placeholderContent[route.id][2]} />
@ -80,10 +50,7 @@ export function App() {
return ( return (
<div className="app-shell"> <div className="app-shell">
<a className="skip-link" href="#main-content">Aller au contenu</a> <a className="skip-link" href="#main-content">Aller au contenu</a>
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={() => { <Header activeRoute={route} onNavigate={navigate} onSyncCommitted={reloadDashboard} />
reloadDashboard()
reloadPreparedItems()
}} />
<main className="app-main" id="main-content">{content}</main> <main className="app-main" id="main-content">{content}</main>
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} /> <Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
</div> </div>

View file

@ -1,61 +1,11 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent,render,screen,waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe,expect,it,vi } from 'vitest'
import { SyncControl } from './SyncControl' import { SyncControl } from './SyncControl'
const headers=new Headers({'X-Trainlog-CSRF-Token':'c'.repeat(64)})
const headers = new Headers({ 'X-Trainlog-CSRF-Token': 'c'.repeat(64) }) const reply=(value:unknown,status=200)=>({ok:status<300,status,headers,json:()=>Promise.resolve(value)})
describe('contrôle global de synchronisation',()=>{
function reply(value: unknown, status = 200) { it('démarre une fois, confirme par polling et expose le brouillon sans le relabeller',async()=>{const fetch=vi.fn()
return { ok: status < 300, status, headers, json: () => Promise.resolve(value) } .mockResolvedValueOnce(reply({phase:'idle',result:'idle'}))
} .mockResolvedValueOnce(reply({phase:'requested',result:'running'},202))
.mockResolvedValueOnce(reply({phase:'completed',result:'completed',progress_revision:7,sessions_reconciled:1,finished_at:'2026-09-17T12:00:00Z',drafts:[{draft_id:'draft_1',state:'pending',session_type:'strength',occurrence_count:2}]}));vi.stubGlobal('fetch',fetch);const committed=vi.fn();render(<SyncControl onCommitted={committed}/>);await waitFor(()=>expect(screen.getByRole('button',{name:'Synchroniser'})).toBeEnabled());fireEvent.click(screen.getByRole('button',{name:'Synchroniser'}));await waitFor(()=>expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(),{timeout:2500});fireEvent.click(screen.getByText('Synchronisation confirmée'));expect(screen.getByRole('region',{name:'Brouillons synchronisés'})).toHaveTextContent('pending');expect(committed).toHaveBeenCalledTimes(1);expect(fetch.mock.calls.filter(call=>String(call[0]).endsWith('/api/v1/sync'))).toHaveLength(1)})
describe('contrôle global de synchronisation', () => {
it('démarre une fois, confirme par polling et expose le brouillon sans le relabeller', async () => {
const runId = 'sy_00000000-0000-4000-8000-000000000001'
const fetch = vi.fn()
.mockResolvedValueOnce(reply({ phase: 'idle', result: 'idle' }))
.mockResolvedValueOnce(reply({ phase: 'requested', result: 'running', run_id: runId, progress_revision: 1 }, 202))
.mockResolvedValueOnce(reply({
phase: 'completed', result: 'completed', run_id: runId, progress_revision: 7,
sessions_reconciled: 1, finished_at: '2026-09-17T12:00:00Z',
drafts: [{ draft_id: 'draft_1', state: 'pending', session_type: 'strength', occurrence_count: 2 }],
}))
vi.stubGlobal('fetch', fetch)
const committed = vi.fn()
render(<SyncControl onCommitted={committed} />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Synchroniser' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Synchroniser' }))
await waitFor(() => expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(), { timeout: 2500 })
fireEvent.click(screen.getByText('Synchronisation confirmée'))
expect(screen.getByRole('region', { name: 'Brouillons synchronisés' })).toHaveTextContent('pending')
expect(committed).toHaveBeenCalledTimes(1)
expect(fetch.mock.calls.filter((call) => String(call[0]).endsWith('/api/v1/sync'))).toHaveLength(1)
})
it('refuse une réponse terminale d’un ancien run et poursuit le run admis', async () => {
const current = 'sy_00000000-0000-4000-8000-000000000002'
const stale = 'sy_00000000-0000-4000-8000-000000000001'
const fetch = vi.fn()
.mockResolvedValueOnce(reply({ phase: 'idle', result: 'idle' }))
.mockResolvedValueOnce(reply({ phase: 'running', result: 'running', run_id: current, progress_revision: 2, started_at: '2026-09-17T13:00:00Z' }, 202))
.mockResolvedValueOnce(reply({ phase: 'failed', result: 'failed', run_id: stale, progress_revision: 9, started_at: '2026-09-17T12:00:00Z' }))
.mockResolvedValueOnce(reply({ phase: 'completed', result: 'completed', run_id: current, progress_revision: 8, started_at: '2026-09-17T13:00:00Z' }))
vi.stubGlobal('fetch', fetch)
render(<SyncControl onCommitted={vi.fn()} />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Synchroniser' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Synchroniser' }))
await waitFor(() => expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(), { timeout: 3500 })
expect(screen.queryByText('Échec de synchronisation')).not.toBeInTheDocument()
})
it('affiche le code stable et l’action pour un timeout MTP', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(reply({
phase: 'failed', result: 'failed', error_code: 'transport_timeout',
diagnostic: 'transport_timeout: MTP push did not finish within 30 seconds',
})))
render(<SyncControl onCommitted={vi.fn()} />)
const summary = await screen.findByText('Échec de synchronisation')
fireEvent.click(summary)
expect(screen.getByText('Code : transport_timeout')).toBeInTheDocument()
expect(screen.getByText(/Vérifiez le dialogue USB\/MTP/)).toBeInTheDocument()
})
}) })

View file

@ -1,156 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useEffect,useRef,useState } from 'react'
import { fetchSyncStatus, newRequestId, startSync, syncIsActive, type SyncStatus } from '../api/sync' import { fetchSyncStatus,newRequestId,startSync,syncIsActive,type SyncStatus } from '../api/sync'
const labels:Record<string,string>={idle:'Synchronisation inactive',requested:'Synchronisation demandée',waiting_android_publication:'Publication Android requise',running:'Synchronisation en cours',local_import_committed:'Données desktop validées',published:'Génération retour publiée',waiting_acknowledgement:'Accusé Android attendu',peer_consumed:'Pair confirmé',completed:'Synchronisation confirmée',failed:'Échec de synchronisation',interrupted:'Synchronisation interrompue',explicitly_degraded:'Pair incompatible'}
const labels: Record<string, string> = { export function SyncControl({onCommitted}:{onCommitted:()=>void}){const [status,setStatus]=useState<SyncStatus|null>(null);const [error,setError]=useState('');const committed=useRef(-1)
idle: 'Synchronisation inactive', const accept=(next:SyncStatus)=>{setStatus(next);setError('');if(['local_import_committed','published','waiting_acknowledgement','peer_consumed','completed'].includes(next.phase)&&(next.progress_revision??0)>committed.current){committed.current=next.progress_revision??0;onCommitted()}}
requested: 'Synchronisation demandée', useEffect(()=>{let stopped=false;let timer:number|undefined;const poll=async()=>{try{const next=await fetchSyncStatus();if(stopped)return;accept(next);if(syncIsActive(next.phase))timer=window.setTimeout(poll,750)}catch{if(!stopped)setError('Statut de synchronisation indisponible')}};void poll();return()=>{stopped=true;if(timer!==undefined)window.clearTimeout(timer)}},[onCommitted])
waiting_android_publication: 'Publication Android requise', const start=async()=>{setError('');try{accept(await startSync(newRequestId()));const poll=async()=>{try{const next=await fetchSyncStatus();accept(next);if(syncIsActive(next.phase))window.setTimeout(poll,750)}catch{setError('Résultat inconnu : vérifiez le statut avant de réessayer')}};window.setTimeout(poll,250)}catch(reason){setError(reason instanceof Error?reason.message:'Démarrage impossible')}}
running: 'Synchronisation en cours', const active=status?syncIsActive(status.phase):false
local_import_committed: 'Données desktop validées', return <div className="sync-control"><button type="button" onClick={start} disabled={active||status?.enabled===false}>Synchroniser</button><details className="sync-details"><summary aria-live="polite">{error||labels[status?.phase??'idle']}</summary>{status?.diagnostic&&<p>{status.diagnostic}</p>}{status?.sessions_reconciled!==null&&status?.sessions_reconciled!==undefined&&<p>Séances rapprochées : {status.sessions_reconciled}</p>}{status?.phase==='waiting_android_publication'&&<p>Ouvrez l’écran de synchronisation Android pour publier une génération fraîche.</p>}{(status?.drafts?.length??0)>0&&<section aria-label="Brouillons synchronisés"><h2>Brouillons reçus</h2><ul>{status!.drafts!.map(d=><li key={d.draft_id}><code>{d.draft_id}</code> · {d.state} · {d.session_type} · {d.occurrence_count} occurrence(s)</li>)}</ul></section>}{status?.finished_at&&<p>Dernier résultat : {status.finished_at}</p>}</details></div>
published: 'Génération retour publiée',
waiting_acknowledgement: 'Accusé Android attendu',
peer_consumed: 'Pair confirmé',
completed: 'Synchronisation confirmée',
failed: 'Échec de synchronisation',
interrupted: 'Synchronisation interrompue',
explicitly_degraded: 'Pair incompatible',
}
const committedPhases = new Set([
'local_import_committed',
'published',
'waiting_acknowledgement',
'peer_consumed',
'completed',
])
function statusAction(status: SyncStatus | null): string {
if (status?.phase === 'waiting_android_publication') {
return 'Ouvrez l’écran de synchronisation Android et laissez le téléphone déverrouillé.'
}
if (status?.phase === 'failed' && status.error_code === 'device_unavailable') {
return 'Connectez et déverrouillez le téléphone, puis réessayez.'
}
if (status?.phase === 'failed' && status.error_code === 'transport_timeout') {
return 'Vérifiez le dialogue USB/MTP sur Android avant de relancer.'
}
if (status?.phase === 'failed' && status.error_code === 'peer_capacity_exhausted') {
return 'Conservez l’application Android ouverte : les générations acquittées doivent être archivées avant une relance explicite.'
}
return ''
}
export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
const [status, setStatus] = useState<SyncStatus | null>(null)
const [error, setError] = useState('')
const statusRef = useRef<SyncStatus | null>(null)
const committedByRun = useRef(new Map<string, number>())
const timer = useRef<number | undefined>(undefined)
const controller = useRef<AbortController | null>(null)
const mounted = useRef(false)
const accept = useCallback((next: SyncStatus): boolean => {
const current = statusRef.current
if (current?.run_id && next.run_id === current.run_id &&
(next.progress_revision ?? 0) < (current.progress_revision ?? 0)) {
return false
}
if (current?.run_id && next.run_id && next.run_id !== current.run_id &&
syncIsActive(current.phase)) {
if (!next.started_at || !current.started_at || next.started_at <= current.started_at) {
return false
}
}
statusRef.current = next
setStatus(next)
setError('')
if (next.run_id && committedPhases.has(next.phase)) {
const revision = next.progress_revision ?? 0
const prior = committedByRun.current.get(next.run_id) ?? -1
if (revision > prior) {
committedByRun.current.set(next.run_id, revision)
onCommitted()
}
}
return true
}, [onCommitted])
const schedulePoll = useCallback((delay: number) => {
if (timer.current !== undefined) window.clearTimeout(timer.current)
timer.current = window.setTimeout(async () => {
controller.current?.abort()
const nextController = new AbortController()
controller.current = nextController
try {
const next = await fetchSyncStatus(nextController.signal)
if (!mounted.current) return
const accepted = accept(next)
const current = accepted ? next : statusRef.current
if (current && syncIsActive(current.phase)) schedulePoll(750)
} catch (reason) {
if (!mounted.current || nextController.signal.aborted) return
setError('Statut indisponible : le dernier état confirmé reste affiché.')
}
}, delay)
}, [accept])
useEffect(() => {
mounted.current = true
schedulePoll(0)
return () => {
mounted.current = false
controller.current?.abort()
if (timer.current !== undefined) window.clearTimeout(timer.current)
}
}, [schedulePoll])
async function start() {
setError('')
try {
const admitted = await startSync(newRequestId())
accept(admitted)
schedulePoll(250)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Démarrage impossible')
}
}
const active = status ? syncIsActive(status.phase) : false
const action = statusAction(status)
return (
<div className="sync-control">
<button
type="button"
onClick={() => void start()}
disabled={active || status?.enabled === false}
>
Synchroniser
</button>
<details className="sync-details">
<summary aria-live="polite">{error || labels[status?.phase ?? 'idle']}</summary>
{action && <p>{action}</p>}
{status?.error_code && <p>Code : {status.error_code}</p>}
{status?.diagnostic && <p>{status.diagnostic}</p>}
{status?.sessions_reconciled !== null && status?.sessions_reconciled !== undefined && (
<p>Séances rapprochées : {status.sessions_reconciled}</p>
)}
{(status?.drafts?.length ?? 0) > 0 && (
<section aria-label="Brouillons synchronisés">
<h2>Brouillons reçus</h2>
<ul>
{status?.drafts?.map((draft) => (
<li key={draft.draft_id}>
<code>{draft.draft_id}</code> · {draft.state} · {draft.session_type} ·{' '}
{draft.occurrence_count} occurrence(s)
</li>
))}
</ul>
</section>
)}
{status?.started_at && <p>Démarrée : {status.started_at}</p>}
{status?.finished_at && <p>Dernier résultat : {status.finished_at}</p>}
</details>
</div>
)
} }

View file

@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout' import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout'
import { Tile } from '../components/Tile' import { Tile } from '../components/Tile'
import type { DashboardSnapshot } from '../api/dashboard' import type { DashboardSnapshot } from '../api/dashboard'
import type { PreparedItemsSnapshot } from '../api/preparedItems'
import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout' import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout'
import { import {
cloneLayout, cloneLayout,
@ -36,23 +35,9 @@ function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
return { columns: 12, rowHeight: 52 } return { columns: 12, rowHeight: 52 }
} }
interface DashboardGridProps { interface DashboardGridProps { dashboard?: DashboardSnapshot | null; pending?: boolean; failed?: boolean }
dashboard?: DashboardSnapshot | null
pending?: boolean
failed?: boolean
preparedItems?: PreparedItemsSnapshot | null
preparedItemsPending?: boolean
preparedItemsFailed?: boolean
}
export function DashboardGrid({ export function DashboardGrid({ dashboard = null, pending = false, failed = false }: DashboardGridProps) {
dashboard = null,
pending = false,
failed = false,
preparedItems = null,
preparedItemsPending = false,
preparedItemsFailed = false,
}: DashboardGridProps) {
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT)) const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [announcement, setAnnouncement] = useState('') const [announcement, setAnnouncement] = useState('')
@ -184,10 +169,7 @@ export function DashboardGrid({
const content = dashboardTiles[id] const content = dashboardTiles[id]
const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0] const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0]
const size = tileSize(dimensions) const size = tileSize(dimensions)
const available = id === 'next-session' const available = dashboard === null ? false : tileAvailable(dashboard, id)
? (preparedItems?.items.length ?? 0) > 0
: dashboard !== null && tileAvailable(dashboard, id)
const tilePending = id === 'next-session' ? preparedItemsPending : pending
const partial = dashboard?.meta.partial === true && id === 'max-records' const partial = dashboard?.meta.partial === true && id === 'max-records'
const TileContent = content.component const TileContent = content.component
return ( return (
@ -198,21 +180,10 @@ export function DashboardGrid({
size={size} size={size}
editable={editing && desktop} editable={editing && desktop}
onKeyDown={(event) => onTileKeyDown(event, id)} onKeyDown={(event) => onTileKeyDown(event, id)}
stateLabel={tilePending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'} stateLabel={pending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'} stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'}
> >
{id === 'next-session' ? ( {dashboard === null ? <div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div> : <TileContent snapshot={dashboard} size={size} />}
<NextSessionTile
size={size}
preparedItems={preparedItems}
pending={preparedItemsPending}
failed={preparedItemsFailed}
/>
) : dashboard === null ? (
<div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div>
) : (
<TileContent snapshot={dashboard} size={size} />
)}
</Tile> </Tile>
</div> </div>
) )
@ -224,7 +195,7 @@ export function DashboardGrid({
} }
function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean { function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean {
if (id === 'next-session') return false if (id === 'next-session') return snapshot.data.next_session.available
if (id === 'activity') return snapshot.data.activity.available if (id === 'activity') return snapshot.data.activity.available
if (id === 'progression') return snapshot.data.progression.available if (id === 'progression') return snapshot.data.progression.available
if (id === 'last-session') return snapshot.data.last_session.available if (id === 'last-session') return snapshot.data.last_session.available

View file

@ -6,8 +6,8 @@ import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistri
describe('tuiles Dashboard alimentées par le contrat', () => { describe('tuiles Dashboard alimentées par le contrat', () => {
it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => { it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => {
const snapshot = dashboardFixture() const snapshot = dashboardFixture()
const { rerender } = render(<NextSessionTile preparedItems={{api_version:1,generated_at:'2026-09-17T12:00:00Z',partial:false,items:[]}} pending={false} failed={false} size="large" />) const { rerender } = render(<NextSessionTile snapshot={snapshot} size="large" />)
expect(screen.getByText('Aucun élément disponible')).toBeInTheDocument() expect(screen.getByText('Aucune séance préparée')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('no_persisted_executable_plan') expect(document.body).not.toHaveTextContent('no_persisted_executable_plan')
rerender(<CardioTile snapshot={snapshot} size="large" />) rerender(<CardioTile snapshot={snapshot} size="large" />)
expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument() expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument()
@ -15,42 +15,6 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
expect(document.body).not.toHaveTextContent(/bpm|%/i) expect(document.body).not.toHaveTextContent(/bpm|%/i)
}) })
it('sépare proposition à valider et brouillon actif sans fabriquer de réalisé', () => {
const proposal = {
api_version: 1 as const,
generated_at: '2026-09-17T12:00:00Z',
partial: false,
items: [{ identity: 'aid_one', kind: 'ai_proposal' as const, title: 'Préparation', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' }],
}
const { rerender } = render(
<NextSessionTile size="large" preparedItems={proposal} pending={false} failed={false} />,
)
expect(screen.getByText('Proposition préparée · à valider')).toBeInTheDocument()
expect(screen.getByText('Préparation')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent(/réalisée|terminée/i)
rerender(<NextSessionTile size="large" preparedItems={{ ...proposal, items: [{ ...proposal.items[0], identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' }] }} pending={false} failed={false} />)
expect(screen.getByText('Brouillon d’exécution · à reprendre')).toBeInTheDocument()
})
it('conserve une lecture durable lorsque son actualisation échoue', () => {
render(<NextSessionTile size="medium" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [{ identity: 'aid_one', kind: 'ai_proposal', title: 'Conservée', planned_for: null, state: 'published', occurrence_count: 1, provenance: 'ai_import' }] }} pending={false} failed />)
expect(screen.getByText('Conservée')).toBeInTheDocument()
expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument()
expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument()
})
it('rend chaque proposition consultable sans la transformer en brouillon', () => {
render(<NextSessionTile size="large" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [
{ identity: 'aid_first', kind: 'ai_proposal', title: 'Première', planned_for: null, state: 'published', occurrence_count: 2, provenance: 'ai_import' },
{ identity: 'aid_expected', kind: 'ai_proposal', title: 'Attendue', planned_for: null, state: 'published', occurrence_count: 6, provenance: 'ai_import' },
] }} pending={false} failed={false} />)
const expected = document.querySelector('[data-prepared-identity="aid_expected"]')
expect(expected).toHaveTextContent('Attendue')
expect(expected).toHaveAttribute('data-prepared-identity', 'aid_expected')
expect(document.body).not.toHaveTextContent('Brouillon d’exécution · à reprendre')
})
it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => { it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => {
const { container } = render(<ActivityTile snapshot={dashboardFixture()} size={size} />) const { container } = render(<ActivityTile snapshot={dashboardFixture()} size={size} />)
const heatmap = screen.getByRole('img') const heatmap = screen.getByRole('img')

View file

@ -3,68 +3,13 @@ import { lazy, Suspense, type ComponentType } from 'react'
import type { TileSize } from './dashboardLayout' import type { TileSize } from './dashboardLayout'
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat' import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
import { BodyZoneFigure } from './BodyZoneFigure' import { BodyZoneFigure } from './BodyZoneFigure'
import type { PreparedItemsSnapshot } from '../api/preparedItems'
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart }))) const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize } interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize }
export interface NextSessionTileProps { export function NextSessionTile({ size }: TileDataProps) {
size: TileSize return <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>Aucune séance préparée</p>{size !== 'compact' && <small>Aucune séance préparée n’est actuellement disponible.</small>}{size === 'large' && <span className="future-action" aria-hidden="true">Préparation à venir</span>}</div>
preparedItems?: PreparedItemsSnapshot | null
pending?: boolean
failed?: boolean
}
export function NextSessionTile({
size,
preparedItems = null,
pending = false,
failed = false,
}: NextSessionTileProps) {
if (preparedItems === null) {
return <Unavailable text={failed ? 'Données indisponibles' : 'Chargement des préparations…'} />
}
const item = preparedItems.items[0]
if (item === undefined) {
return <Unavailable text={failed ? 'Données indisponibles' : 'Aucun élément disponible'} />
}
const proposal = item.kind === 'ai_proposal'
const heading = proposal
? 'Proposition préparée · à valider'
: item.state === 'active'
? 'Brouillon d’exécution · à reprendre'
: 'Brouillon d’exécution · en attente'
const title = item.title || (proposal ? 'Proposition sans titre' : 'Séance préparée')
return (
<div className="session-content">
<p className="primary-label">{heading}</p>
<strong>{title}</strong>
{size !== 'compact' && (
<>
<dl className="fact-list horizontal">
<Fact label="Éléments" value={String(item.occurrence_count)} />
<Fact label="Prévue" value={item.planned_for ? formatDate(item.planned_for) : 'Non renseignée'} />
</dl>
{preparedItems.items.length > 1 && (
<details className="prepared-items-list">
<summary>{preparedItems.items.length} éléments préparés</summary>
<ul>
{preparedItems.items.map((prepared) => (
<li key={`${prepared.kind}:${prepared.identity}`} data-prepared-identity={prepared.identity}>
{prepared.title || (prepared.kind === 'ai_proposal' ? 'Proposition sans titre' : 'Séance préparée')}
{' · '}{prepared.kind === 'ai_proposal' ? 'proposition' : 'brouillon d’exécution'}
</li>
))}
</ul>
</details>
)}
</>
)}
{failed && <small>Dernière lecture conservée · actualisation indisponible</small>}
{pending && !failed && <small>Actualisation…</small>}
</div>
)
} }
export function ActivityTile({ snapshot, size }: TileDataProps) { export function ActivityTile({ snapshot, size }: TileDataProps) {

View file

@ -1,37 +1,15 @@
import { DashboardGrid } from '../dashboard/DashboardGrid' import { DashboardGrid } from '../dashboard/DashboardGrid'
import type { DashboardSnapshot } from '../api/dashboard' import type { DashboardSnapshot } from '../api/dashboard'
import type { PreparedItemsSnapshot } from '../api/preparedItems'
interface DashboardPageProps { interface DashboardPageProps { dashboard: DashboardSnapshot | null; pending: boolean; failed: boolean }
dashboard: DashboardSnapshot | null
pending: boolean
failed: boolean
preparedItems: PreparedItemsSnapshot | null
preparedItemsPending: boolean
preparedItemsFailed: boolean
}
export function DashboardPage({ export function DashboardPage({ dashboard, pending, failed }: DashboardPageProps) {
dashboard,
pending,
failed,
preparedItems,
preparedItemsPending,
preparedItemsFailed,
}: DashboardPageProps) {
return ( return (
<section className="page" aria-labelledby="page-title"> <section className="page" aria-labelledby="page-title">
<div className="page-heading"> <div className="page-heading">
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div> <div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
</div> </div>
<DashboardGrid <DashboardGrid dashboard={dashboard} pending={pending} failed={failed} />
dashboard={dashboard}
pending={pending}
failed={failed}
preparedItems={preparedItems}
preparedItemsPending={preparedItemsPending}
preparedItemsFailed={preparedItemsFailed}
/>
</section> </section>
) )
} }