Compare commits
6 commits
56ac1cdfd2
...
a5cdbdf2b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5cdbdf2b9 | ||
|
|
d87d7cd302 | ||
|
|
908c38546c | ||
|
|
4c330be8c3 | ||
|
|
6f3ad45cb4 | ||
|
|
f7b556cc37 |
72 changed files with 2413 additions and 169 deletions
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -2,6 +2,42 @@
|
|||
|
||||
## 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
|
||||
direct MTP: verified Android and desktop backups, signing-preserving Android
|
||||
updates, schema migrations, persistent peer pairing, bidirectional durable
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ not contain cloud credentials and does not run `rclone`.
|
|||
As of 2026-09-17:
|
||||
|
||||
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
||||
- desktop SQLite schema v21 and Android SQLite schema v20;
|
||||
- desktop SQLite schema v22 and Android SQLite schema v21;
|
||||
- Notcurses is the only active desktop terminal backend;
|
||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||
all-files access setting;
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) {
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..20
|
||||
manifest.getInt("schema") in 17..21
|
||||
) {
|
||||
"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.version in 17..20) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..21) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,58 @@ internal sealed interface ForegroundGenerationResult {
|
|||
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
|
||||
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) {
|
||||
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
|
||||
FileOutputStream(temporary).use { stream ->
|
||||
|
|
@ -81,15 +133,41 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
"causal-delete-v1",
|
||||
"mobile-history-v4",
|
||||
"execution-draft-v1",
|
||||
"generation-archive-v1",
|
||||
)
|
||||
),
|
||||
)
|
||||
.toString(),
|
||||
)
|
||||
val deadline = System.nanoTime() + timeout.toNanos()
|
||||
val request = JSONObject(await(File(directory, "request-v1.json"), deadline).readText())
|
||||
val requestRaw =
|
||||
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)
|
||||
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 =
|
||||
service.capture(
|
||||
directory,
|
||||
|
|
@ -128,6 +206,25 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
|
||||
ForegroundGenerationResult.Completed(captured.runId)
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.security.MessageDigest
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
|
@ -160,6 +162,111 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
* callers cannot supply or replace it. */
|
||||
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(
|
||||
ownedRoot: File,
|
||||
consumerPeerId: String,
|
||||
|
|
@ -169,10 +276,11 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
requireId(consumerPeerId, "peer")
|
||||
requireId(runId, "sy")
|
||||
requireId(generationId, "gen")
|
||||
archiveAcknowledged(ownedRoot, consumerPeerId)
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
val retained =
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM sync_generations WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged')",
|
||||
"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)",
|
||||
arrayOf(consumerPeerId),
|
||||
)
|
||||
.use {
|
||||
|
|
@ -869,6 +977,32 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
throw SyncGenerationException("ACK does not match pending generation")
|
||||
val target =
|
||||
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) != target)
|
||||
throw SyncGenerationException("conflicting ACK replay")
|
||||
|
|
|
|||
|
|
@ -7934,7 +7934,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
20,
|
||||
21,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -7979,6 +7979,7 @@ private class TrainlogDatabaseHelper(
|
|||
createSyncDataLifecycleTables(db)
|
||||
createCausalDeleteTables(db)
|
||||
createSyncGenerationTables(db)
|
||||
createSyncGenerationArchiveTable(db)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -8146,6 +8147,14 @@ private class TrainlogDatabaseHelper(
|
|||
createSyncGenerationTables(db)
|
||||
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) {
|
||||
error(
|
||||
|
|
@ -8244,6 +8253,15 @@ 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));")
|
||||
}
|
||||
|
||||
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) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(20, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(21, scalar(db, "PRAGMA user_version"))
|
||||
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(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"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(20, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(21, scalar(db, "PRAGMA user_version"))
|
||||
/* v13 intentionally adds the frozen machine identities and
|
||||
* 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") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(20, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(21, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
|||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(20, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(21, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
|||
null,
|
||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
.use { assertEquals(20, it.version) }
|
||||
.use { assertEquals(21, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
|
|
@ -86,18 +87,9 @@ class SyncDeploymentConversationTest {
|
|||
ActiveSessionDraft(exercises = listOf(occurrence))
|
||||
),
|
||||
)
|
||||
val coordinator = SyncGenerationForegroundCoordinator(repository)
|
||||
val release = System.getenv("TRAINLOG_SYNC_TEST_RELEASE_FILE")
|
||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
val result =
|
||||
executor.submit<ForegroundGenerationResult> {
|
||||
coordinator.run(transport, Duration.ofSeconds(30)) {
|
||||
release?.let { awaitFile(File(it)) }
|
||||
}
|
||||
}
|
||||
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
|
||||
val desktop = File(transport, "desktop.sqlite")
|
||||
if (suppliedTransport == null) {
|
||||
val desktop = File(transport, "desktop.sqlite")
|
||||
val fixture =
|
||||
ProcessBuilder(
|
||||
File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||
|
|
@ -106,7 +98,20 @@ class SyncDeploymentConversationTest {
|
|||
.directory(repositoryRoot)
|
||||
.start()
|
||||
assertEquals(0, fixture.waitFor())
|
||||
localWorker =
|
||||
}
|
||||
val conversations = if (suppliedTransport == null) 24 else 1
|
||||
repeat(conversations) {
|
||||
val coordinator = SyncGenerationForegroundCoordinator(repository)
|
||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
val result =
|
||||
executor.submit<ForegroundGenerationResult> {
|
||||
coordinator.run(transport, Duration.ofSeconds(30)) {
|
||||
release?.let { awaitFile(File(it)) }
|
||||
}
|
||||
}
|
||||
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
|
||||
if (suppliedTransport == null) {
|
||||
localWorker =
|
||||
ProcessBuilder(
|
||||
"python3",
|
||||
File(repositoryRoot, "tools/sync_peer_worker.py").absolutePath,
|
||||
|
|
@ -126,19 +131,56 @@ class SyncDeploymentConversationTest {
|
|||
.directory(repositoryRoot)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
}
|
||||
val completed = result.get(35, java.util.concurrent.TimeUnit.SECONDS)
|
||||
executor.shutdownNow()
|
||||
assertTrue(
|
||||
"conversation ${it + 1}: $completed",
|
||||
completed is ForegroundGenerationResult.Completed,
|
||||
)
|
||||
var workerOutput = ""
|
||||
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
|
||||
}
|
||||
assertTrue(
|
||||
result.get(35, java.util.concurrent.TimeUnit.SECONDS)
|
||||
is ForegroundGenerationResult.Completed
|
||||
)
|
||||
executor.shutdownNow()
|
||||
repository.close()
|
||||
repository = TrainlogRepository(context, databaseName)
|
||||
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded)
|
||||
localWorker?.let { worker ->
|
||||
val output = worker.inputStream.bufferedReader().readText()
|
||||
assertEquals("worker failed:\n$output", 0, worker.waitFor())
|
||||
assertTrue(output.contains("\"result\":\"completed\""))
|
||||
if (suppliedTransport == null) {
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(databaseName).absolutePath,
|
||||
null,
|
||||
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 {
|
||||
localWorker?.destroyForcibly()
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class SyncGenerationServiceTest {
|
|||
SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READWRITE).use { db
|
||||
->
|
||||
listOf(
|
||||
"sync_generation_archives",
|
||||
"sync_causal_publications",
|
||||
"sync_acknowledgements",
|
||||
"sync_consumed_generations",
|
||||
|
|
@ -62,7 +63,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(20, it.getInt(0))
|
||||
assertEquals(21, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(20, cursor.getInt(0))
|
||||
assertEquals(21, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(20, cursor.getInt(0))
|
||||
assertEquals(21, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -961,6 +961,8 @@ The authorized private rollout used the bridge only to add the backup surface
|
|||
without clearing package data, verified the resulting plaintext archive, then
|
||||
installed the current signed release variant through a strictly increasing
|
||||
version-code chain. The final installed private candidate retains product
|
||||
version 0.1.2 and schema v20. Its full-generation foreground coordinator waits
|
||||
for correlated objects, tolerates stale objects left by interrupted runs, and
|
||||
version 0.1.2 and schema v21. Schema v21 adds the verified generation archive
|
||||
ledger without changing any domain identity. Its full-generation foreground
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | schema v21 |
|
||||
| Android SQLite | schema v20 |
|
||||
| Desktop SQLite | schema v22 |
|
||||
| Android SQLite | schema v21 |
|
||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||
| Desktop terminal backend | Notcurses only |
|
||||
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
|
||||
|
|
@ -219,6 +219,26 @@ persistence with optimistic conflict and CSRF/Origin protection. It provides no
|
|||
business mutation or automatic browser launch. Analyse, Programmes, Sessions,
|
||||
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
|
||||
|
||||
- `exercise_id` identifies a catalogue exercise; `entry_id` identifies one
|
||||
|
|
@ -321,9 +341,11 @@ Android resource-parity, language-owner, typed sync-presentation, and
|
|||
stable-data presentation tests. A real-device/manual visual language-switch
|
||||
smoke remains a manual validation; it does not change the validated status.
|
||||
|
||||
Hardware-dependent MTP and the final real Drive plus Android-triggered AI-draft
|
||||
smoke test are not automated. The latter is why
|
||||
`TRAINLOG_AI_SESSION_DRAFT_V1` remains `VALIDATION_PENDING`.
|
||||
Hardware-dependent MTP is not automated. The controlled real-device rollout
|
||||
validated two consecutive full-generation exchanges around a Trainlog-service
|
||||
restart. The expected AI proposal was verified in the persistent Android store
|
||||
and the read-only Web proposal projection with its original identity and
|
||||
content; no proposal-to-execution-draft conversion was performed.
|
||||
|
||||
## Active limitations
|
||||
|
||||
|
|
@ -331,16 +353,18 @@ smoke test are not automated. The latter is why
|
|||
desktop producers/consumers for `trainlog-causal-deletions` V1. All contracted
|
||||
domains are durably protected; exact replay is idempotent and unprovable or
|
||||
concurrent ancestry conflicts before mutation. Protected legacy snapshots are
|
||||
refused before resurrection. Staged generation acknowledgement is implemented,
|
||||
but active synchronization still selects V3: no staged-service activation,
|
||||
deployment, or user-data migration occurred.
|
||||
refused before resurrection. The default automatic daemon path remains V3,
|
||||
while the trusted local Web opt-in now runs the deployed full-generation path.
|
||||
Its acknowledged generations can leave the active admission window only
|
||||
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
|
||||
conflicts including change-and-revert, imported built-in and alias protection,
|
||||
active/pending/desktop draft-revision equivalence, protected companion refusal,
|
||||
current-view retirement and bounded pre-effect admission. The later generation
|
||||
slice additively advances schemas to desktop v21/Android v20 without changing
|
||||
active V3 transport selection.
|
||||
slice additively advances schemas to desktop v22/Android v21 without changing
|
||||
default automatic V3 selection.
|
||||
|
||||
The residual causal evidence also proves legitimate Android and desktop
|
||||
producer directions separately for exercise retirement, populated body
|
||||
|
|
@ -358,8 +382,6 @@ pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
|||
remain shell placeholders until their Core/API ownership and implementation
|
||||
order are explicitly contracted.
|
||||
- `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.
|
||||
- Hardware MTP validation requires a connected unlocked Android device.
|
||||
- Scientific knowledge is bounded to reviewed catalog entries; unknown custom
|
||||
|
|
|
|||
|
|
@ -532,6 +532,12 @@ foreign-key checks. Reconciliation validation also uses coherent Android and
|
|||
desktop v9 copies before applying migrations to the real stores. SQLite files
|
||||
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
|
||||
`ended_at`, notes, planned targets and session-specific load semantics; the
|
||||
Android capture schema does not. Desktop body observations may link to a
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ distinction between measured values and estimates.
|
|||
|
||||
## Current baseline
|
||||
|
||||
Desktop schema v21, Android schema v20, Notcurses, direct
|
||||
Desktop schema v22, Android schema v21, Notcurses, direct
|
||||
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
|
||||
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
|
||||
implemented. Session Generator V1 is hidden pending V2. AI session-draft
|
||||
|
|
@ -34,6 +34,9 @@ CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
|
|||
contract, frozen interactive grid, versioned layout persistence and factual
|
||||
tile rendering exist. The Dashboard's bounded progression and BODY ZONES
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ available because availability is independently guarded by causal state.
|
|||
|
||||
## Machine-exercise Phase 1 compatibility
|
||||
|
||||
Desktop schema v21 and Android schema v20 retain mobile export V3, readable V1/V2 imports,
|
||||
Desktop schema v22 and Android schema v21 retain mobile export V3, readable V1/V2 imports,
|
||||
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||
silently added to a frozen artifact: stable exercise IDs and canonical names
|
||||
|
|
@ -756,8 +756,32 @@ transaction while calling the existing domain exporters. Both validate domain
|
|||
and outer limits before recording a captured generation. Publication copies
|
||||
verified immutable bytes into `generations/<generation_id>/`, resumes only
|
||||
byte-identical partial publication, and writes `manifest.json` last. Capacity
|
||||
admits at most eight retained outgoing generations per consumer; exhaustion is
|
||||
recoverable and never evicts an unacknowledged generation or causal proof.
|
||||
admits at most eight active outgoing generations per consumer. Acknowledged
|
||||
generations older than the two newest lineage members leave active admission
|
||||
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,
|
||||
custom-equipment definitions, V4 history, aliases and occurrence equipment,
|
||||
|
|
@ -798,6 +822,31 @@ truncation, and publishes generation manifests last. Production behavior is
|
|||
tested by replacing only the typed libmtp/udev I/O callbacks with a filesystem
|
||||
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
|
||||
|
||||
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,21 @@ 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
|
||||
physical MTP. Orchestrator tests cover bounded streams and descendant cleanup;
|
||||
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,
|
||||
distinct multi-occurrences of one exercise, ordered sets, exact occurrence
|
||||
|
|
@ -1308,11 +1323,38 @@ Android generation service, desktop consumers and durable ACK path remain real.
|
|||
backup including an active draft, raw partial input, stable peer identity and
|
||||
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
|
||||
`BridgeBackupMigrationTest`, which verifies normal migration to schema v20.
|
||||
`BridgeBackupMigrationTest`, which verifies normal migration to schema v21.
|
||||
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
|
||||
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
|
||||
|
||||
The normalized C scope is checked non-mutatively with the explicit
|
||||
|
|
|
|||
124
tests/test_generation_archival.py
Normal file
124
tests/test_generation_archival.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#!/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()
|
||||
119
tests/test_sync_error_classification.py
Normal file
119
tests/test_sync_error_classification.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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()
|
||||
|
|
@ -21,8 +21,9 @@ 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_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_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));
|
||||
PRAGMA user_version=21;
|
||||
PRAGMA user_version=22;
|
||||
"""
|
||||
|
||||
class GenerationTest(unittest.TestCase):
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@ class OrchestratorTest(unittest.TestCase):
|
|||
values = self.fixture(root, **override)
|
||||
result = self.invoke(ORCHESTRATOR, values)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertEqual(json.loads(values[1].read_text())["phase"], "failed")
|
||||
final = json.loads(values[1].read_text())
|
||||
self.assertEqual(final["phase"], "failed")
|
||||
self.assertEqual(final["error_code"], "internal_error")
|
||||
|
||||
def test_stdout_bound_is_enforced_before_complete_accumulation(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
|||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (20, 21):
|
||||
raise CausalError(f"desktop schema v20/v21 required, found v{version}")
|
||||
if version not in (20, 21, 22):
|
||||
raise CausalError(f"desktop schema v20-v22 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21):
|
||||
raise LifecycleError(f"desktop schema v19/v20/v21 required, found v{version}")
|
||||
if version not in (19, 20, 21, 22):
|
||||
raise LifecycleError(f"desktop schema v19-v22 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22)
|
||||
MEASUREMENT_FIELDS = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21):
|
||||
raise ValueError("schema desktop v18-v21 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22):
|
||||
raise ValueError("schema desktop v18-v22 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
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):
|
||||
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):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
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):
|
||||
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):
|
||||
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():
|
||||
raise ValueError("causal equipment protection requires the staged artifact")
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
raise ValueError("schema desktop v12-v16 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
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():
|
||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (17, 18, 19, 20, 21):
|
||||
if version not in (17, 18, 19, 20, 21, 22):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# 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):
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if args.version == 4 and schema_version not in (19, 20, 21):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22):
|
||||
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) 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, 22) and not (args.version == 2 and schema_version == 10):
|
||||
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(
|
||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
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 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 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()
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None):
|
|||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||
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):
|
||||
raise ImportFailure("schema desktop v18-v21 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22):
|
||||
raise ImportFailure("schema desktop v18-v22 requis")
|
||||
draft, entries = validate(payload, connection)
|
||||
digest = canonical_digest(payload)
|
||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ def main():
|
|||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
connection = connect_database(args.database)
|
||||
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):
|
||||
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):
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
proof_path = args.mobile_export
|
||||
if proof_path is None:
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def validate(payload, reserved):
|
|||
|
||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||
"""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):
|
||||
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):
|
||||
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():
|
||||
fail("causal equipment protection requires the staged artifact")
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""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):
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
fail("schema desktop v12-v21 requis")
|
||||
for source, canonical in aliases:
|
||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
|
|||
|
||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||
"""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): raise ImportFailure("schema desktop v11-v21 requis")
|
||||
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 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={}
|
||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ def validate(item):
|
|||
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")
|
||||
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): fail("schema desktop v17-v21 requis")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22): fail("schema desktop v17-v22 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -846,9 +846,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# 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):
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v21 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v22 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1978,8 +1978,8 @@ def apply_payload(connection, payload, trace_exercises=False):
|
|||
require_supported_schema(connection)
|
||||
|
||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19, v20 ou v21")
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v22")
|
||||
if schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21):fail("schema desktop v15-v21 requis")
|
||||
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 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()
|
||||
for index,item in enumerate(root["exercise_feedback"]):
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ def main() -> int:
|
|||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.2",
|
||||
"desktop_schema": 21,
|
||||
"android_schema": 20,
|
||||
"desktop_schema": 22,
|
||||
"android_schema": 21,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
"mobile-history-v4",
|
||||
|
|
@ -104,6 +104,7 @@ def main() -> int:
|
|||
"execution-draft-v1",
|
||||
"generation-manifest-v1",
|
||||
"generation-ack-v1",
|
||||
"generation-archive-v1",
|
||||
],
|
||||
"entry_points": [
|
||||
"bin/trainlog",
|
||||
|
|
|
|||
|
|
@ -199,8 +199,8 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
|||
|
||||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 21:
|
||||
raise GenerationError("desktop schema v21 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 22:
|
||||
raise GenerationError("desktop schema v22 required")
|
||||
|
||||
|
||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||
|
|
@ -215,6 +215,106 @@ def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
|||
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:
|
||||
command = [sys.executable, str(ROOT / "tools" / tool)]
|
||||
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"):
|
||||
|
|
@ -249,9 +349,10 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
run = run_id or "sy_" + str(uuid.uuid4())
|
||||
if not valid_id(generation, "gen") or not valid_id(run, "sy"):
|
||||
raise GenerationError("invalid generation/run")
|
||||
archive_acknowledged(database, owned_root, consumer)
|
||||
with closing(connect_database(database)) as admission:
|
||||
require_schema(admission)
|
||||
retained = admission.execute("SELECT COUNT(*) FROM sync_generations WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged')", (consumer,)).fetchone()[0]
|
||||
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]
|
||||
if retained >= MAX_RETAINED_PER_PEER:
|
||||
raise GenerationError("generation retention capacity exhausted")
|
||||
stage = owned_root / "staging" / generation
|
||||
|
|
@ -539,10 +640,23 @@ def accept_ack(database: Path, path: Path) -> str:
|
|||
raise GenerationError("ACK has no pending generation")
|
||||
ack = validate_ack(raw, row)
|
||||
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] != status:
|
||||
raise GenerationError("conflicting ACK replay")
|
||||
db.rollback(); return "unchanged"
|
||||
db.commit(); return "unchanged"
|
||||
if row[5] != "waiting_acknowledgement":
|
||||
raise GenerationError("generation is not awaiting ACK")
|
||||
db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",
|
||||
|
|
|
|||
|
|
@ -118,6 +118,22 @@ def update(state_path: Path, state: dict, phase: str, **fields: object) -> None:
|
|||
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:
|
||||
required = {
|
||||
"format",
|
||||
|
|
@ -303,6 +319,7 @@ def run(args: argparse.Namespace) -> int:
|
|||
state,
|
||||
"failed",
|
||||
result="conflict",
|
||||
error_code="sync_in_progress",
|
||||
diagnostic="Another synchronization owns the common lock",
|
||||
finished_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
|
@ -418,6 +435,7 @@ def main() -> int:
|
|||
state,
|
||||
phase,
|
||||
result=phase,
|
||||
error_code=("interrupted" if phase == "interrupted" else failure_code(error)),
|
||||
diagnostic=str(error)[:MAX_DIAGNOSTIC],
|
||||
finished_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,17 @@ always produced and consumed by the existing generation services.
|
|||
"""
|
||||
|
||||
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 pathlib import Path
|
||||
|
||||
|
|
@ -20,6 +30,7 @@ CAPS = {
|
|||
"causal-delete-v1",
|
||||
"mobile-history-v4",
|
||||
"execution-draft-v1",
|
||||
"generation-archive-v1",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -43,9 +54,12 @@ def bounded(path: Path, limit: int = 65536) -> bytes:
|
|||
|
||||
def publish_json(path: Path, value: dict) -> None:
|
||||
"""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()))
|
||||
with temporary.open("wb") as stream:
|
||||
stream.write(generation.canonical(value))
|
||||
stream.write(raw)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
|
|
@ -61,13 +75,21 @@ def run_adapter(
|
|||
allow_missing_peer: bool = False,
|
||||
) -> bool:
|
||||
remaining = max(1, int(deadline - time.monotonic()))
|
||||
result = __import__("subprocess").run(
|
||||
[str(adapter), operation, peer, str(root)],
|
||||
stdin=__import__("subprocess").DEVNULL,
|
||||
capture_output=True,
|
||||
timeout=min(remaining, 30),
|
||||
check=False,
|
||||
)
|
||||
timeout = min(remaining, 30)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(adapter), operation, peer, str(root)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
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:
|
||||
diagnostic = (
|
||||
result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed"
|
||||
|
|
@ -82,6 +104,70 @@ def run_adapter(
|
|||
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:
|
||||
while not path.is_file():
|
||||
if time.monotonic() >= deadline:
|
||||
|
|
@ -107,6 +193,27 @@ def wait_json(path: Path, deadline: float, predicate, pump=None) -> dict:
|
|||
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:
|
||||
value = json.loads(bounded(root / "android-peer-v1.json"))
|
||||
if (
|
||||
|
|
@ -166,9 +273,29 @@ def main() -> int:
|
|||
}
|
||||
request_path = args.transport_root / "request-v1.json"
|
||||
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":
|
||||
run_adapter(
|
||||
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
|
||||
push_adapter(
|
||||
args.mtp_adapter,
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
["request-v1.json", "desktop-archive-acknowledgements-v1.json"],
|
||||
deadline,
|
||||
)
|
||||
emit(
|
||||
args.run_id,
|
||||
|
|
@ -177,16 +304,25 @@ def main() -> int:
|
|||
consumer_peer_id=desktop_peer,
|
||||
)
|
||||
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_ref, deadline, lambda value: value.get("run_id") == args.run_id, pump
|
||||
inbound_ref, deadline, lambda value: value.get("run_id") == args.run_id, pump_inbound
|
||||
)
|
||||
inbound_dir = args.transport_root / inbound["relative_path"]
|
||||
ack = generation.consume_desktop(args.database, inbound_dir)
|
||||
ack_path = args.transport_root / "desktop-consumption-ack-v1.json"
|
||||
publish_json(ack_path, ack)
|
||||
if args.mode == "mtp":
|
||||
run_adapter(
|
||||
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
|
||||
push_adapter(
|
||||
args.mtp_adapter,
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"],
|
||||
deadline,
|
||||
)
|
||||
if ack["result"] != "consumed":
|
||||
raise RuntimeError("desktop rejected Android generation")
|
||||
|
|
@ -218,8 +354,16 @@ def main() -> int:
|
|||
}
|
||||
publish_json(args.transport_root / "desktop-generation-v1.json", ref)
|
||||
if args.mode == "mtp":
|
||||
run_adapter(
|
||||
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
|
||||
# CONTRACT: the immutable generation must be fully visible before its
|
||||
# mutable run-correlated reference. Publishing both in one MTP walk
|
||||
# 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(
|
||||
args.run_id,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 21
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 22
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
49
tui/include/trainlog/web_prepared_items.h
Normal file
49
tui/include/trainlog/web_prepared_items.h
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/* 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
|
||||
|
|
@ -192,6 +192,8 @@ trainlog_core_sources = files(
|
|||
'src/json_writer.c',
|
||||
'src/web_dashboard.c',
|
||||
'src/web_dashboard_json.c',
|
||||
'src/web_prepared_items.c',
|
||||
'src/web_prepared_items_json.c',
|
||||
'src/duration.c',
|
||||
'src/id.c',
|
||||
'src/timeutil.c',
|
||||
|
|
@ -335,6 +337,15 @@ test_web_dashboard = executable(
|
|||
)
|
||||
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',
|
||||
'tests/test_dashboard_header.c',
|
||||
|
|
@ -759,6 +770,12 @@ test(
|
|||
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
|
||||
timeout: 120,
|
||||
)
|
||||
test(
|
||||
'generation_archival',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_generation_archival.py'],
|
||||
timeout: 120,
|
||||
)
|
||||
test(
|
||||
'sync_orchestrator',
|
||||
find_program('python3'),
|
||||
|
|
@ -769,6 +786,11 @@ test(
|
|||
find_program('python3'),
|
||||
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(
|
||||
'schema_v21_migration',
|
||||
executable('test_schema_v21_migration', 'tests/test_schema_v21_migration.c', dependencies: trainlog_core_dep),
|
||||
|
|
|
|||
|
|
@ -870,6 +870,20 @@ 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));"
|
||||
"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
|
||||
* added. Keep its version number and add only the nullable lifecycle cursor;
|
||||
* NULL deliberately means retryable/not yet published. */
|
||||
|
|
@ -1565,7 +1579,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
status = TRAINLOG_STATUS_OK;
|
||||
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
||||
version == 15 || version == 16 || version == 17 || version == 18 ||
|
||||
version == 19 || version == 20 || version == 21) {
|
||||
version == 19 || version == 20 || version == 21 || version == 22) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -1738,6 +1752,9 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
if (status == TRAINLOG_STATUS_OK && version < 21) {
|
||||
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) {
|
||||
status = ensure_v18_ai_draft_publication_state(database);
|
||||
}
|
||||
|
|
@ -1753,7 +1770,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
set_open_diagnostic(
|
||||
output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v21" : "migrate database to schema v21",
|
||||
version == 0 ? "create schema v22" : "migrate database to schema v22",
|
||||
database->connection,
|
||||
SQLITE_ERROR
|
||||
);
|
||||
|
|
|
|||
|
|
@ -253,6 +253,116 @@ static TrainlogStatus pull_folder(const TrainlogGenerationMtpIo *io,
|
|||
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) {
|
||||
int left_fd = open(left, O_RDONLY | O_CLOEXEC);
|
||||
int right_fd = open(right, O_RDONLY | O_CLOEXEC);
|
||||
|
|
@ -459,9 +569,8 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
|
|||
char *diagnostic,
|
||||
size_t diagnostic_size) {
|
||||
Selection selection;
|
||||
uint64_t total = 0U;
|
||||
size_t objects = 0U;
|
||||
TrainlogStatus status;
|
||||
char generation[256];
|
||||
if (io == NULL || expected == NULL || local == NULL) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
|
|
@ -470,7 +579,32 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
|
|||
status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = pull_folder(io, &selection, selection.root, local, 0U, &total, &objects);
|
||||
/* WHY: Documents/Trainlog retains prior generations and causal evidence.
|
||||
* 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;
|
||||
}
|
||||
|
|
|
|||
174
tui/src/web_prepared_items.c
Normal file
174
tui/src/web_prepared_items.c
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
#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;
|
||||
}
|
||||
140
tui/src/web_prepared_items_json.c
Normal file
140
tui/src/web_prepared_items_json.c
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
#include <yyjson.h>
|
||||
|
||||
#include "trainlog/web_dashboard.h"
|
||||
#include "trainlog/web_prepared_items.h"
|
||||
#include "trainlog/dashboard_layout.h"
|
||||
#include "web_assets.h"
|
||||
|
||||
|
|
@ -707,6 +708,27 @@ static enum MHD_Result handle_request(void *closure,
|
|||
}
|
||||
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) {
|
||||
TrainlogDashboardLayout layout;
|
||||
TrainlogDashboardLayout saved;
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ static TrainlogStatus receive(unsigned int b, unsigned int d, uint32_t id, const
|
|||
if (!f) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
bytes = id == 9U ? 3U : o->size;
|
||||
bytes = id == 12U ? 3U : o->size;
|
||||
if (fwrite(o->data, 1, bytes, f) != bytes) {
|
||||
fclose(f);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
|
|
@ -232,11 +232,20 @@ static int test_pull_and_manifest_last(void) {
|
|||
char *manifest;
|
||||
reset();
|
||||
CHECK(mkdtemp(root) != NULL);
|
||||
add(4, 2, true, "android-objects", NULL);
|
||||
add(5, 4, true, "generations", NULL);
|
||||
add(6, 5, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
|
||||
add(7, 6, false, "payload.json", "payload");
|
||||
add(8, 6, false, "manifest.json", "manifest");
|
||||
add(4,
|
||||
2,
|
||||
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(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(
|
||||
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
|
|
@ -287,12 +296,25 @@ static int test_ambiguity_and_truncation(void) {
|
|||
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
|
||||
TRAINLOG_STATUS_NOT_FOUND);
|
||||
reset();
|
||||
add(9, 2, false, "bad.bin", "abc");
|
||||
find(9)->size = 99U;
|
||||
add(4,
|
||||
2,
|
||||
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(
|
||||
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag));
|
||||
CHECK(status != TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_test_join_path(path, sizeof(path), root, "bad.bin"));
|
||||
CHECK(trainlog_test_join_path(
|
||||
path,
|
||||
sizeof(path),
|
||||
root,
|
||||
"android-objects/generations/gen_11111111-1111-4111-8111-111111111111/bad.bin"));
|
||||
CHECK(access(path, F_OK) != 0);
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,16 +27,17 @@ int main(void)
|
|||
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(database);database=NULL;
|
||||
CHECK(sqlite3_open(path,&raw)==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_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_close(raw)==SQLITE_OK);
|
||||
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
|
||||
trainlog_database_close(database);database=NULL;
|
||||
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
||||
CHECK(scalar(raw,"PRAGMA user_version")==21);
|
||||
CHECK(scalar(raw,"PRAGMA user_version")==22);
|
||||
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_acknowledgements")==0);
|
||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generation_archives")==0);
|
||||
CHECK(sqlite3_close(raw)==SQLITE_OK);CHECK(unlink(path)==0);
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v21: SQLite rc=",
|
||||
strlen("migrate database to schema v21: SQLite rc=")) == 0);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v22: SQLite rc=",
|
||||
strlen("migrate database to schema v22: SQLite rc=")) == 0);
|
||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
|||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=22;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=23;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 22 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 23 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v21") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v22") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
110
tui/tests/test_web_prepared_items.c
Normal file
110
tui/tests/test_web_prepared_items.c
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -255,6 +255,19 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
|||
CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL);
|
||||
CHECK(strstr(response, "\"window_days\":90") != 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;
|
||||
char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
|
||||
|
|
|
|||
21
web/src/api/preparedItems.test.ts
Normal file
21
web/src/api/preparedItems.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
56
web/src/api/preparedItems.ts
Normal file
56
web/src/api/preparedItems.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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())
|
||||
}
|
||||
|
|
@ -1,11 +1,126 @@
|
|||
export type SyncPhase = 'idle' | 'requested' | 'waiting_android_publication' | 'running' | 'local_import_committed' | 'published' | 'waiting_acknowledgement' | '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; 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 }
|
||||
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 parse(value:unknown):SyncStatus { if(typeof value!=='object'||value===null)throw new Error('statut sync invalide');const status=value as Partial<SyncStatus>;if(typeof status.phase!=='string'||!phases.has(status.phase as SyncPhase)||typeof status.result!=='string')throw new Error('statut sync invalide');if(status.drafts!==undefined&&(!Array.isArray(status.drafts)||status.drafts.length>32))throw new Error('résumé des brouillons invalide');return status as SyncStatus }
|
||||
export async function fetchSyncStatus(signal?:AbortSignal):Promise<SyncStatus>{const response=await fetch('/api/v1/sync/status',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`sync status HTTP ${response.status}`);const token=response.headers.get('X-Trainlog-CSRF-Token')??'';if(!/^[0-9a-f]{64}$/.test(token))throw new Error('jeton CSRF absent');csrfToken=token;return parse(await response.json())}
|
||||
export async function startSync(requestId:string):Promise<SyncStatus>{if(!/^sy_[0-9a-f-]{36}$/.test(requestId)||csrfToken.length!==64)throw new Error('requête sync invalide');const response=await fetch('/api/v1/sync',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':csrfToken},body:JSON.stringify({request_id:requestId,trigger:'web'})});const value=await response.json();if(!response.ok)throw new Error(typeof value?.error==='string'?value.error:`sync HTTP ${response.status}`);return parse(value)}
|
||||
export type SyncPhase =
|
||||
| 'idle'
|
||||
| 'requested'
|
||||
| 'waiting_android_publication'
|
||||
| 'running'
|
||||
| 'local_import_committed'
|
||||
| 'published'
|
||||
| 'waiting_acknowledgement'
|
||||
| '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 {
|
||||
/*
|
||||
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
|
||||
|
|
@ -27,4 +142,6 @@ export function newRequestId(): string {
|
|||
].join('-')
|
||||
return `sy_${uuid}`
|
||||
}
|
||||
export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}
|
||||
export function syncIsActive(phase: SyncPhase): boolean {
|
||||
return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchHealth, type Health } from '../api/health'
|
||||
import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard'
|
||||
import { fetchPreparedItems, type PreparedItemsSnapshot } from '../api/preparedItems'
|
||||
import { Footer } from '../components/Footer'
|
||||
import { Header } from '../components/Header'
|
||||
import { DashboardPage } from '../routes/DashboardPage'
|
||||
|
|
@ -23,10 +24,24 @@ export function App() {
|
|||
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null)
|
||||
const [dashboardPending, setDashboardPending] = useState(true)
|
||||
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(() => {
|
||||
fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(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(() => {
|
||||
const controller = new AbortController()
|
||||
|
|
@ -39,10 +54,25 @@ export function App() {
|
|||
}).catch(() => {
|
||||
setDashboard(null); setDashboardFailed(true)
|
||||
}).finally(() => setDashboardPending(false))
|
||||
fetchPreparedItems(controller.signal).then((value) => {
|
||||
setPreparedItems(value)
|
||||
setPreparedItemsFailed(false)
|
||||
}).catch(() => {
|
||||
setPreparedItemsFailed(true)
|
||||
}).finally(() => setPreparedItemsPending(false))
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
const content = route.id === 'dashboard' ? <DashboardPage dashboard={dashboard} pending={dashboardPending} failed={dashboardFailed} /> :
|
||||
const content = route.id === 'dashboard' ? (
|
||||
<DashboardPage
|
||||
dashboard={dashboard}
|
||||
pending={dashboardPending}
|
||||
failed={dashboardFailed}
|
||||
preparedItems={preparedItems}
|
||||
preparedItemsPending={preparedItemsPending}
|
||||
preparedItemsFailed={preparedItemsFailed}
|
||||
/>
|
||||
) :
|
||||
<PlaceholderPage title={placeholderContent[route.id][0]}
|
||||
eyebrow={placeholderContent[route.id][1]}
|
||||
description={placeholderContent[route.id][2]} />
|
||||
|
|
@ -50,7 +80,10 @@ export function App() {
|
|||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">Aller au contenu</a>
|
||||
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={reloadDashboard} />
|
||||
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={() => {
|
||||
reloadDashboard()
|
||||
reloadPreparedItems()
|
||||
}} />
|
||||
<main className="app-main" id="main-content">{content}</main>
|
||||
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,61 @@
|
|||
import { fireEvent,render,screen,waitFor } from '@testing-library/react'
|
||||
import { describe,expect,it,vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SyncControl } from './SyncControl'
|
||||
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',()=>{
|
||||
it('démarre une fois, confirme par polling et expose le brouillon sans le relabeller',async()=>{const fetch=vi.fn()
|
||||
.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)})
|
||||
|
||||
const headers = new Headers({ 'X-Trainlog-CSRF-Token': 'c'.repeat(64) })
|
||||
|
||||
function reply(value: unknown, status = 200) {
|
||||
return { ok: status < 300, status, headers, json: () => Promise.resolve(value) }
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,156 @@
|
|||
import { useEffect,useRef,useState } from 'react'
|
||||
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'}
|
||||
export function SyncControl({onCommitted}:{onCommitted:()=>void}){const [status,setStatus]=useState<SyncStatus|null>(null);const [error,setError]=useState('');const committed=useRef(-1)
|
||||
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()}}
|
||||
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])
|
||||
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')}}
|
||||
const active=status?syncIsActive(status.phase):false
|
||||
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>
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
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 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
|||
import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout'
|
||||
import { Tile } from '../components/Tile'
|
||||
import type { DashboardSnapshot } from '../api/dashboard'
|
||||
import type { PreparedItemsSnapshot } from '../api/preparedItems'
|
||||
import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout'
|
||||
import {
|
||||
cloneLayout,
|
||||
|
|
@ -35,9 +36,23 @@ function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
|
|||
return { columns: 12, rowHeight: 52 }
|
||||
}
|
||||
|
||||
interface DashboardGridProps { dashboard?: DashboardSnapshot | null; pending?: boolean; failed?: boolean }
|
||||
interface DashboardGridProps {
|
||||
dashboard?: DashboardSnapshot | null
|
||||
pending?: boolean
|
||||
failed?: boolean
|
||||
preparedItems?: PreparedItemsSnapshot | null
|
||||
preparedItemsPending?: boolean
|
||||
preparedItemsFailed?: boolean
|
||||
}
|
||||
|
||||
export function DashboardGrid({ dashboard = null, pending = false, failed = false }: DashboardGridProps) {
|
||||
export function DashboardGrid({
|
||||
dashboard = null,
|
||||
pending = false,
|
||||
failed = false,
|
||||
preparedItems = null,
|
||||
preparedItemsPending = false,
|
||||
preparedItemsFailed = false,
|
||||
}: DashboardGridProps) {
|
||||
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
|
@ -169,7 +184,10 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals
|
|||
const content = dashboardTiles[id]
|
||||
const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0]
|
||||
const size = tileSize(dimensions)
|
||||
const available = dashboard === null ? false : tileAvailable(dashboard, id)
|
||||
const available = id === 'next-session'
|
||||
? (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 TileContent = content.component
|
||||
return (
|
||||
|
|
@ -180,10 +198,21 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals
|
|||
size={size}
|
||||
editable={editing && desktop}
|
||||
onKeyDown={(event) => onTileKeyDown(event, id)}
|
||||
stateLabel={pending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
|
||||
stateLabel={tilePending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
|
||||
stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'}
|
||||
>
|
||||
{dashboard === null ? <div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div> : <TileContent snapshot={dashboard} size={size} />}
|
||||
{id === 'next-session' ? (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -195,7 +224,7 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals
|
|||
}
|
||||
|
||||
function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean {
|
||||
if (id === 'next-session') return snapshot.data.next_session.available
|
||||
if (id === 'next-session') return false
|
||||
if (id === 'activity') return snapshot.data.activity.available
|
||||
if (id === 'progression') return snapshot.data.progression.available
|
||||
if (id === 'last-session') return snapshot.data.last_session.available
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistri
|
|||
describe('tuiles Dashboard alimentées par le contrat', () => {
|
||||
it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => {
|
||||
const snapshot = dashboardFixture()
|
||||
const { rerender } = render(<NextSessionTile snapshot={snapshot} size="large" />)
|
||||
expect(screen.getByText('Aucune séance préparée')).toBeInTheDocument()
|
||||
const { rerender } = render(<NextSessionTile preparedItems={{api_version:1,generated_at:'2026-09-17T12:00:00Z',partial:false,items:[]}} pending={false} failed={false} size="large" />)
|
||||
expect(screen.getByText('Aucun élément disponible')).toBeInTheDocument()
|
||||
expect(document.body).not.toHaveTextContent('no_persisted_executable_plan')
|
||||
rerender(<CardioTile snapshot={snapshot} size="large" />)
|
||||
expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument()
|
||||
|
|
@ -15,6 +15,42 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
|
|||
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) => {
|
||||
const { container } = render(<ActivityTile snapshot={dashboardFixture()} size={size} />)
|
||||
const heatmap = screen.getByRole('img')
|
||||
|
|
|
|||
|
|
@ -3,13 +3,68 @@ import { lazy, Suspense, type ComponentType } from 'react'
|
|||
import type { TileSize } from './dashboardLayout'
|
||||
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
|
||||
import { BodyZoneFigure } from './BodyZoneFigure'
|
||||
import type { PreparedItemsSnapshot } from '../api/preparedItems'
|
||||
|
||||
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
|
||||
|
||||
interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize }
|
||||
|
||||
export function NextSessionTile({ size }: TileDataProps) {
|
||||
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>
|
||||
export interface NextSessionTileProps {
|
||||
size: TileSize
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,37 @@
|
|||
import { DashboardGrid } from '../dashboard/DashboardGrid'
|
||||
import type { DashboardSnapshot } from '../api/dashboard'
|
||||
import type { PreparedItemsSnapshot } from '../api/preparedItems'
|
||||
|
||||
interface DashboardPageProps { dashboard: DashboardSnapshot | null; pending: boolean; failed: boolean }
|
||||
interface DashboardPageProps {
|
||||
dashboard: DashboardSnapshot | null
|
||||
pending: boolean
|
||||
failed: boolean
|
||||
preparedItems: PreparedItemsSnapshot | null
|
||||
preparedItemsPending: boolean
|
||||
preparedItemsFailed: boolean
|
||||
}
|
||||
|
||||
export function DashboardPage({ dashboard, pending, failed }: DashboardPageProps) {
|
||||
export function DashboardPage({
|
||||
dashboard,
|
||||
pending,
|
||||
failed,
|
||||
preparedItems,
|
||||
preparedItemsPending,
|
||||
preparedItemsFailed,
|
||||
}: DashboardPageProps) {
|
||||
return (
|
||||
<section className="page" aria-labelledby="page-title">
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
|
||||
</div>
|
||||
<DashboardGrid dashboard={dashboard} pending={pending} failed={failed} />
|
||||
<DashboardGrid
|
||||
dashboard={dashboard}
|
||||
pending={pending}
|
||||
failed={failed}
|
||||
preparedItems={preparedItems}
|
||||
preparedItemsPending={preparedItemsPending}
|
||||
preparedItemsFailed={preparedItemsFailed}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue