Implement durable sync generation archival
This commit is contained in:
parent
6f3ad45cb4
commit
4c330be8c3
56 changed files with 813 additions and 100 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -2,6 +2,17 @@
|
||||||
|
|
||||||
## Unreleased — 0.1.2
|
## Unreleased — 0.1.2
|
||||||
|
|
||||||
|
- Added compatible non-destructive generation archival on desktop schema v22
|
||||||
|
and Android schema v21. Only exactly acknowledged generations older than the
|
||||||
|
two active recovery tips leave admission after a verified durable payload
|
||||||
|
copy and audit record; all identities, manifests, ACKs, lineage and causal
|
||||||
|
evidence remain stored. Older Android stores can recover exact missing
|
||||||
|
producer-side ACK rows from the desktop consumer's durable ledger, with
|
||||||
|
strict run, peer, generation and manifest revalidation. A correlated Android
|
||||||
|
capacity error now reaches the Web without waiting for a transport timeout,
|
||||||
|
and the prepared-items tile can inspect every proposal rather than only its
|
||||||
|
first compact summary.
|
||||||
|
|
||||||
- Fixed a production full-generation timeout caused by recursively uploading
|
- Fixed a production full-generation timeout caused by recursively uploading
|
||||||
retained staging and unrelated transport history on every MTP push. Each
|
retained staging and unrelated transport history on every MTP push. Each
|
||||||
phase now publishes a private bounded outbox containing only its request,
|
phase now publishes a private bounded outbox containing only its request,
|
||||||
|
|
|
||||||
|
|
@ -318,7 +318,7 @@ not contain cloud credentials and does not run `rclone`.
|
||||||
As of 2026-09-17:
|
As of 2026-09-17:
|
||||||
|
|
||||||
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
||||||
- desktop SQLite schema v21 and Android SQLite schema v20;
|
- desktop SQLite schema v22 and Android SQLite schema v21;
|
||||||
- Notcurses is the only active desktop terminal backend;
|
- Notcurses is the only active desktop terminal backend;
|
||||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||||
all-files access setting;
|
all-files access setting;
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) {
|
||||||
manifest.getInt("version") == VERSION &&
|
manifest.getInt("version") == VERSION &&
|
||||||
manifest.getString("package") == context.packageName &&
|
manifest.getString("package") == context.packageName &&
|
||||||
manifest.getBoolean("plaintext") &&
|
manifest.getBoolean("plaintext") &&
|
||||||
manifest.getInt("schema") in 17..20
|
manifest.getInt("schema") in 17..21
|
||||||
) {
|
) {
|
||||||
"Unsupported backup."
|
"Unsupported backup."
|
||||||
}
|
}
|
||||||
|
|
@ -313,7 +313,7 @@ internal class AndroidBackupService(private val context: Context) {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||||
require(db.version in 17..20) { "Unsupported Android schema ${db.version}." }
|
require(db.version in 17..21) { "Unsupported Android schema ${db.version}." }
|
||||||
return db.version
|
return db.version
|
||||||
} finally {
|
} finally {
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,65 @@ internal sealed interface ForegroundGenerationResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Owns the bounded, user-initiated Android side of a generation conversation. */
|
/** Owns the bounded, user-initiated Android side of a generation conversation. */
|
||||||
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
|
internal class SyncGenerationForegroundCoordinator(
|
||||||
private val service = SyncGenerationService(repository)
|
private val repository: TrainlogRepository,
|
||||||
|
archiveDirectorySync: ((File) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
private val service =
|
||||||
|
archiveDirectorySync?.let { SyncGenerationService(repository, it) }
|
||||||
|
?: SyncGenerationService(repository)
|
||||||
|
|
||||||
|
private fun requireExactKeys(value: JSONObject, expected: Set<String>, label: String) {
|
||||||
|
val actual = value.keys().asSequence().toSet()
|
||||||
|
if (actual != expected) throw SyncGenerationException("invalid $label fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconcileArchiveAcknowledgements(
|
||||||
|
raw: ByteArray,
|
||||||
|
runId: String,
|
||||||
|
androidPeerId: String,
|
||||||
|
desktopPeerId: String,
|
||||||
|
) {
|
||||||
|
/* WHY: releases before archive-v1 advanced generation status without
|
||||||
|
* retaining the consumer's ACK locally. The desktop consumer may
|
||||||
|
* return its exact durable ACK so the Android producer can repair
|
||||||
|
* that evidence gap without fabricating or weakening proof.
|
||||||
|
* CONTRACT: the envelope is strictly correlated and contains at most
|
||||||
|
* 32 ACKs; acceptAcknowledgement revalidates every ACK against the
|
||||||
|
* immutable local manifest before any archive can become eligible.
|
||||||
|
* INVARIANT: a missing, rejected, or ambiguous ACK leaves its
|
||||||
|
* generation active and protected. */
|
||||||
|
val envelope = JSONObject(raw.toString(Charsets.UTF_8))
|
||||||
|
requireExactKeys(
|
||||||
|
envelope,
|
||||||
|
setOf(
|
||||||
|
"format",
|
||||||
|
"version",
|
||||||
|
"run_id",
|
||||||
|
"android_peer_id",
|
||||||
|
"desktop_peer_id",
|
||||||
|
"acknowledgements",
|
||||||
|
),
|
||||||
|
"archive acknowledgement envelope",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
envelope.getString("format") != "trainlog-sync-archive-acknowledgements" ||
|
||||||
|
envelope.getInt("version") != 1 ||
|
||||||
|
envelope.getString("run_id") != runId ||
|
||||||
|
envelope.getString("android_peer_id") != androidPeerId ||
|
||||||
|
envelope.getString("desktop_peer_id") != desktopPeerId
|
||||||
|
) {
|
||||||
|
throw SyncGenerationException("archive acknowledgement envelope is not correlated")
|
||||||
|
}
|
||||||
|
val acknowledgements = envelope.getJSONArray("acknowledgements")
|
||||||
|
if (acknowledgements.length() > 32)
|
||||||
|
throw SyncGenerationException("too many archive acknowledgements")
|
||||||
|
for (index in 0 until acknowledgements.length()) {
|
||||||
|
service.acceptAcknowledgement(
|
||||||
|
acknowledgements.getJSONObject(index).toString().toByteArray(Charsets.UTF_8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun publish(path: File, value: String) {
|
private fun publish(path: File, value: String) {
|
||||||
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
|
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
|
||||||
|
|
@ -81,15 +138,41 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
||||||
"causal-delete-v1",
|
"causal-delete-v1",
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
"execution-draft-v1",
|
"execution-draft-v1",
|
||||||
|
"generation-archive-v1",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toString(),
|
.toString(),
|
||||||
)
|
)
|
||||||
val deadline = System.nanoTime() + timeout.toNanos()
|
val deadline = System.nanoTime() + timeout.toNanos()
|
||||||
val 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)
|
if (request.getString("android_peer_id") != peer)
|
||||||
throw SyncGenerationException("request targets another Android peer")
|
throw SyncGenerationException("request targets another Android peer")
|
||||||
|
val archiveAckRaw =
|
||||||
|
awaitCorrelated(
|
||||||
|
File(directory, "desktop-archive-acknowledgements-v1.json"),
|
||||||
|
deadline,
|
||||||
|
) {
|
||||||
|
it.optString("run_id") == request.getString("run_id") &&
|
||||||
|
it.optString("android_peer_id") == peer
|
||||||
|
}
|
||||||
|
reconcileArchiveAcknowledgements(
|
||||||
|
archiveAckRaw,
|
||||||
|
request.getString("run_id"),
|
||||||
|
peer,
|
||||||
|
request.getString("desktop_peer_id"),
|
||||||
|
)
|
||||||
val captured =
|
val captured =
|
||||||
service.capture(
|
service.capture(
|
||||||
directory,
|
directory,
|
||||||
|
|
@ -128,6 +211,25 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
||||||
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
|
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
|
||||||
ForegroundGenerationResult.Completed(captured.runId)
|
ForegroundGenerationResult.Completed(captured.runId)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
|
if (error.message == "generation retention capacity exhausted") {
|
||||||
|
try {
|
||||||
|
val request = JSONObject(File(directory, "request-v1.json").readText())
|
||||||
|
publish(
|
||||||
|
File(directory, "android-generation-error-v1.json"),
|
||||||
|
JSONObject()
|
||||||
|
.put("format", "trainlog-sync-generation-error")
|
||||||
|
.put("version", 1)
|
||||||
|
.put("run_id", request.getString("run_id"))
|
||||||
|
.put("android_peer_id", service.peerId())
|
||||||
|
.put("code", "peer_capacity_exhausted")
|
||||||
|
.put("action", "Archive acknowledged generations, then retry explicitly.")
|
||||||
|
.toString(),
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Preserve the original local failure when error reporting
|
||||||
|
// cannot itself be durably published.
|
||||||
|
}
|
||||||
|
}
|
||||||
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
|
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.system.Os
|
||||||
|
import android.system.OsConstants
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.nio.file.Files
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -23,7 +27,18 @@ internal data class CapturedSyncGeneration(
|
||||||
* CONTRACT: artifacts are immutable, bounded and published before the manifest marker. INVARIANT:
|
* CONTRACT: artifacts are immutable, bounded and published before the manifest marker. INVARIANT:
|
||||||
* causal operation payloads are never rewritten to attach generation identity.
|
* causal operation payloads are never rewritten to attach generation identity.
|
||||||
*/
|
*/
|
||||||
internal class SyncGenerationService(private val repository: TrainlogRepository) {
|
internal class SyncGenerationService(
|
||||||
|
private val repository: TrainlogRepository,
|
||||||
|
private val syncArchiveDirectory: (File) -> Unit = { directory ->
|
||||||
|
val descriptor =
|
||||||
|
Os.open(directory.absolutePath, OsConstants.O_RDONLY or O_DIRECTORY, 0)
|
||||||
|
try {
|
||||||
|
Os.fsync(descriptor)
|
||||||
|
} finally {
|
||||||
|
Os.close(descriptor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val MAX_MANIFEST_BYTES = 64 * 1024
|
const val MAX_MANIFEST_BYTES = 64 * 1024
|
||||||
const val MAX_ARTIFACTS = 32
|
const val MAX_ARTIFACTS = 32
|
||||||
|
|
@ -33,6 +48,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
const val MAX_PATH_BYTES = 240
|
const val MAX_PATH_BYTES = 240
|
||||||
const val MAX_DIAGNOSTIC_BYTES = 1024
|
const val MAX_DIAGNOSTIC_BYTES = 1024
|
||||||
const val MAX_RETAINED_PER_PEER = 8
|
const val MAX_RETAINED_PER_PEER = 8
|
||||||
|
// Android's public OsConstants omits Linux O_DIRECTORY even though
|
||||||
|
// Os.open forwards the platform flag. Keep the fixed Linux ABI value
|
||||||
|
// local to the directory-fsync boundary.
|
||||||
|
private const val O_DIRECTORY = 0x10000
|
||||||
private val ID =
|
private val ID =
|
||||||
Regex(
|
Regex(
|
||||||
"^(gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
"^(gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||||
|
|
@ -160,6 +179,105 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
* callers cannot supply or replace it. */
|
* callers cannot supply or replace it. */
|
||||||
internal fun peerId(): String = stablePeerId("android")
|
internal fun peerId(): String = stablePeerId("android")
|
||||||
|
|
||||||
|
private fun archiveDigest(directory: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
directory.walkTopDown().filter { it.isFile }.sortedBy { it.relativeTo(directory).path }
|
||||||
|
.forEach { file ->
|
||||||
|
if (java.nio.file.Files.isSymbolicLink(file.toPath()))
|
||||||
|
throw SyncGenerationException("archive contains symbolic link")
|
||||||
|
val name = file.relativeTo(directory).invariantSeparatorsPath.toByteArray()
|
||||||
|
digest.update(ByteBuffer.allocate(4).putInt(name.size).array())
|
||||||
|
digest.update(name)
|
||||||
|
digest.update(MessageDigest.getInstance("SHA-256").digest(file.readBytes()))
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun archiveAcknowledged(ownedRoot: File, consumerPeerId: String): Int {
|
||||||
|
/* WHY: acknowledged payloads must not occupy active admission forever.
|
||||||
|
* CONTRACT: an exact consumed ACK is required and the newest two
|
||||||
|
* acknowledged lineage members remain active for recovery.
|
||||||
|
* INVARIANT: generation, artifact, ACK and causal rows remain intact;
|
||||||
|
* the ledger is committed only after a verified atomic archive copy. */
|
||||||
|
val rows = repository.inSyncGenerationTransaction { db ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT g.generation_id,g.run_id,g.producer_peer_id,g.consumer_peer_id," +
|
||||||
|
"g.manifest_sha256,g.staging_path,g.manifest_json FROM sync_generations g " +
|
||||||
|
"WHERE g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(" +
|
||||||
|
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id) " +
|
||||||
|
"ORDER BY g.generated_at DESC,g.generation_id DESC",
|
||||||
|
arrayOf(consumerPeerId),
|
||||||
|
).use { cursor ->
|
||||||
|
buildList {
|
||||||
|
while (cursor.moveToNext()) add(List(7) { cursor.getString(it) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val root = File(File(ownedRoot, "archives"), "generations")
|
||||||
|
if (!root.exists() && !root.mkdirs())
|
||||||
|
throw SyncGenerationException("cannot create generation archive")
|
||||||
|
var count = 0
|
||||||
|
rows.drop(2).forEach { row ->
|
||||||
|
val eligible = repository.inSyncGenerationTransaction { db ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT ack_id FROM sync_acknowledgements WHERE generation_id=? AND run_id=? " +
|
||||||
|
"AND producer_peer_id=? AND consumer_peer_id=? AND manifest_sha256=? " +
|
||||||
|
"AND result='consumed' AND durability='sqlite-commit-full' LIMIT 2",
|
||||||
|
arrayOf(row[0], row[1], row[2], row[3], row[4]),
|
||||||
|
).use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) null
|
||||||
|
else cursor.getString(0).also {
|
||||||
|
if (cursor.moveToNext()) throw SyncGenerationException("ambiguous ACK evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: return@forEach
|
||||||
|
val source = File(row[5])
|
||||||
|
if (Files.isSymbolicLink(source.toPath()))
|
||||||
|
throw SyncGenerationException("generation staging is a symbolic link")
|
||||||
|
validatePublished(source, row[3])
|
||||||
|
val destination = File(root, row[0])
|
||||||
|
val temporary = File(root, ".${row[0]}.tmp")
|
||||||
|
if (Files.isSymbolicLink(destination.toPath()))
|
||||||
|
throw SyncGenerationException("generation archive is a symbolic link")
|
||||||
|
if (!destination.exists()) {
|
||||||
|
if (Files.isSymbolicLink(temporary.toPath()))
|
||||||
|
throw SyncGenerationException("temporary generation archive is a symbolic link")
|
||||||
|
if (temporary.exists() && !temporary.deleteRecursively())
|
||||||
|
throw SyncGenerationException("cannot reset interrupted generation archive")
|
||||||
|
if (!source.copyRecursively(temporary, overwrite = false))
|
||||||
|
throw SyncGenerationException("cannot copy generation archive")
|
||||||
|
validatePublished(temporary, row[3])
|
||||||
|
temporary.walkTopDown().filter { it.isFile }.forEach { file ->
|
||||||
|
java.io.FileOutputStream(file, true).use { it.fd.sync() }
|
||||||
|
}
|
||||||
|
if (!temporary.renameTo(destination))
|
||||||
|
throw SyncGenerationException("cannot commit generation archive")
|
||||||
|
syncArchiveDirectory(root)
|
||||||
|
}
|
||||||
|
validatePublished(destination, row[3])
|
||||||
|
val checksum = archiveDigest(destination)
|
||||||
|
val audit =
|
||||||
|
canonical(
|
||||||
|
JSONObject()
|
||||||
|
.put("format", "trainlog-sync-generation-archive-audit")
|
||||||
|
.put("version", 1)
|
||||||
|
.put("generation_id", row[0])
|
||||||
|
.put("run_id", row[1])
|
||||||
|
.put("manifest_sha256", row[4])
|
||||||
|
.put("ack_id", eligible)
|
||||||
|
.put("archive_sha256", checksum),
|
||||||
|
)
|
||||||
|
repository.inSyncGenerationTransaction { db ->
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT OR IGNORE INTO sync_generation_archives VALUES(?,?,?,?,?,?)",
|
||||||
|
arrayOf(row[0], destination.absolutePath, row[4], checksum, OffsetDateTime.now().toString(), audit),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
fun capture(
|
fun capture(
|
||||||
ownedRoot: File,
|
ownedRoot: File,
|
||||||
consumerPeerId: String,
|
consumerPeerId: String,
|
||||||
|
|
@ -169,10 +287,11 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
requireId(consumerPeerId, "peer")
|
requireId(consumerPeerId, "peer")
|
||||||
requireId(runId, "sy")
|
requireId(runId, "sy")
|
||||||
requireId(generationId, "gen")
|
requireId(generationId, "gen")
|
||||||
|
archiveAcknowledged(ownedRoot, consumerPeerId)
|
||||||
repository.inSyncGenerationTransaction { db ->
|
repository.inSyncGenerationTransaction { db ->
|
||||||
val retained =
|
val retained =
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT COUNT(*) FROM sync_generations 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),
|
arrayOf(consumerPeerId),
|
||||||
)
|
)
|
||||||
.use {
|
.use {
|
||||||
|
|
@ -869,6 +988,32 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
throw SyncGenerationException("ACK does not match pending generation")
|
throw SyncGenerationException("ACK does not match pending generation")
|
||||||
val target =
|
val target =
|
||||||
if (ack.getString("result") == "consumed") "acknowledged" else "rejected"
|
if (ack.getString("result") == "consumed") "acknowledged" else "rejected"
|
||||||
|
/* CONTRACT: the correlated ACK is permanent causal
|
||||||
|
* evidence and is committed atomically with generation
|
||||||
|
* state. Replay is byte-identical or rejected below. */
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT OR IGNORE INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
arrayOf(
|
||||||
|
ack.getString("ack_id"),
|
||||||
|
ack.getString("generation_id"),
|
||||||
|
ack.getString("run_id"),
|
||||||
|
ack.getString("producer_peer_id"),
|
||||||
|
ack.getString("consumer_peer_id"),
|
||||||
|
ack.getString("manifest_sha256"),
|
||||||
|
ack.getString("result"),
|
||||||
|
ack.getString("durability"),
|
||||||
|
ack.getString("consumed_at"),
|
||||||
|
ack.getString("diagnostic"),
|
||||||
|
claimed,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT payload_sha256 FROM sync_acknowledgements WHERE ack_id=?",
|
||||||
|
arrayOf(ack.getString("ack_id")),
|
||||||
|
).use { stored ->
|
||||||
|
if (!stored.moveToFirst() || stored.getString(0) != claimed)
|
||||||
|
throw SyncGenerationException("conflicting ACK replay")
|
||||||
|
}
|
||||||
if (row.getString(4) in setOf("acknowledged", "rejected")) {
|
if (row.getString(4) in setOf("acknowledged", "rejected")) {
|
||||||
if (row.getString(4) != target)
|
if (row.getString(4) != target)
|
||||||
throw SyncGenerationException("conflicting ACK replay")
|
throw SyncGenerationException("conflicting ACK replay")
|
||||||
|
|
|
||||||
|
|
@ -7934,7 +7934,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
20,
|
21,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -7979,6 +7979,7 @@ private class TrainlogDatabaseHelper(
|
||||||
createSyncDataLifecycleTables(db)
|
createSyncDataLifecycleTables(db)
|
||||||
createCausalDeleteTables(db)
|
createCausalDeleteTables(db)
|
||||||
createSyncGenerationTables(db)
|
createSyncGenerationTables(db)
|
||||||
|
createSyncGenerationArchiveTable(db)
|
||||||
seedEquipment(db)
|
seedEquipment(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -8146,6 +8147,14 @@ private class TrainlogDatabaseHelper(
|
||||||
createSyncGenerationTables(db)
|
createSyncGenerationTables(db)
|
||||||
version = 20
|
version = 20
|
||||||
}
|
}
|
||||||
|
if (version < 21 && newVersion >= 21) {
|
||||||
|
/* WHY: acknowledged payloads must leave bounded active admission
|
||||||
|
* without losing immutable identity, lineage or ACK evidence.
|
||||||
|
* CONTRACT: v21 adds only an append-only verified archive ledger.
|
||||||
|
* INVARIANT: migration archives nothing and invents no ACK. */
|
||||||
|
createSyncGenerationArchiveTable(db)
|
||||||
|
version = 21
|
||||||
|
}
|
||||||
|
|
||||||
if (version != newVersion) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
|
|
@ -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));")
|
db.execSQL("CREATE TABLE IF NOT EXISTS sync_causal_publications(operation_id TEXT NOT NULL REFERENCES sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createSyncGenerationArchiveTable(db: SQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS sync_generation_archives(" +
|
||||||
|
"generation_id TEXT PRIMARY KEY REFERENCES sync_generations(generation_id)," +
|
||||||
|
"archive_path TEXT NOT NULL UNIQUE,manifest_sha256 TEXT NOT NULL," +
|
||||||
|
"archive_sha256 TEXT NOT NULL,archived_at TEXT NOT NULL,audit_json TEXT NOT NULL);",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createAiSessionDraftTables(db: SQLiteDatabase) {
|
private fun createAiSessionDraftTables(db: SQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +
|
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(20, scalar(db, "PRAGMA user_version"))
|
assertEquals(21, scalar(db, "PRAGMA user_version"))
|
||||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(20, scalar(db, "PRAGMA user_version"))
|
assertEquals(21, scalar(db, "PRAGMA user_version"))
|
||||||
/* v13 intentionally adds the frozen machine identities and
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(20, scalar(db, "PRAGMA user_version"))
|
assertEquals(21, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(before, listOf(
|
assertEquals(before, listOf(
|
||||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(20, cursor.getInt(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(21, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
||||||
null,
|
null,
|
||||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||||
)
|
)
|
||||||
.use { assertEquals(20, it.version) }
|
.use { assertEquals(21, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
|
|
@ -86,8 +87,25 @@ class SyncDeploymentConversationTest {
|
||||||
ActiveSessionDraft(exercises = listOf(occurrence))
|
ActiveSessionDraft(exercises = listOf(occurrence))
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val coordinator = SyncGenerationForegroundCoordinator(repository)
|
|
||||||
val release = System.getenv("TRAINLOG_SYNC_TEST_RELEASE_FILE")
|
val release = System.getenv("TRAINLOG_SYNC_TEST_RELEASE_FILE")
|
||||||
|
val desktop = File(transport, "desktop.sqlite")
|
||||||
|
if (suppliedTransport == null) {
|
||||||
|
val fixture =
|
||||||
|
ProcessBuilder(
|
||||||
|
File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||||
|
desktop.absolutePath,
|
||||||
|
)
|
||||||
|
.directory(repositoryRoot)
|
||||||
|
.start()
|
||||||
|
assertEquals(0, fixture.waitFor())
|
||||||
|
}
|
||||||
|
val conversations = if (suppliedTransport == null) 24 else 1
|
||||||
|
repeat(conversations) {
|
||||||
|
// Robolectric cannot open a directory file descriptor. The
|
||||||
|
// production default performs the real directory fsync; this
|
||||||
|
// test substitutes only that kernel primitive while retaining
|
||||||
|
// the production copy, validation, rename and ledger path.
|
||||||
|
val coordinator = SyncGenerationForegroundCoordinator(repository) {}
|
||||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||||
val result =
|
val result =
|
||||||
executor.submit<ForegroundGenerationResult> {
|
executor.submit<ForegroundGenerationResult> {
|
||||||
|
|
@ -97,15 +115,6 @@ class SyncDeploymentConversationTest {
|
||||||
}
|
}
|
||||||
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
|
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
|
||||||
if (suppliedTransport == null) {
|
if (suppliedTransport == null) {
|
||||||
val desktop = File(transport, "desktop.sqlite")
|
|
||||||
val fixture =
|
|
||||||
ProcessBuilder(
|
|
||||||
File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
|
||||||
desktop.absolutePath,
|
|
||||||
)
|
|
||||||
.directory(repositoryRoot)
|
|
||||||
.start()
|
|
||||||
assertEquals(0, fixture.waitFor())
|
|
||||||
localWorker =
|
localWorker =
|
||||||
ProcessBuilder(
|
ProcessBuilder(
|
||||||
"python3",
|
"python3",
|
||||||
|
|
@ -127,18 +136,55 @@ class SyncDeploymentConversationTest {
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start()
|
.start()
|
||||||
}
|
}
|
||||||
assertTrue(
|
val completed = result.get(35, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
result.get(35, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
is ForegroundGenerationResult.Completed
|
|
||||||
)
|
|
||||||
executor.shutdownNow()
|
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
|
||||||
|
}
|
||||||
repository.close()
|
repository.close()
|
||||||
repository = TrainlogRepository(context, databaseName)
|
repository = TrainlogRepository(context, databaseName)
|
||||||
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded)
|
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded)
|
||||||
localWorker?.let { worker ->
|
if (suppliedTransport == null) {
|
||||||
val output = worker.inputStream.bufferedReader().readText()
|
SQLiteDatabase.openDatabase(
|
||||||
assertEquals("worker failed:\n$output", 0, worker.waitFor())
|
context.getDatabasePath(databaseName).absolutePath,
|
||||||
assertTrue(output.contains("\"result\":\"completed\""))
|
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 {
|
} finally {
|
||||||
localWorker?.destroyForcibly()
|
localWorker?.destroyForcibly()
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class SyncGenerationServiceTest {
|
||||||
SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READWRITE).use { db
|
SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READWRITE).use { db
|
||||||
->
|
->
|
||||||
listOf(
|
listOf(
|
||||||
|
"sync_generation_archives",
|
||||||
"sync_causal_publications",
|
"sync_causal_publications",
|
||||||
"sync_acknowledgements",
|
"sync_acknowledgements",
|
||||||
"sync_consumed_generations",
|
"sync_consumed_generations",
|
||||||
|
|
@ -62,7 +63,7 @@ class SyncGenerationServiceTest {
|
||||||
.use { db ->
|
.use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use {
|
db.rawQuery("PRAGMA user_version", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
assertEquals(20, it.getInt(0))
|
assertEquals(21, it.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(20, cursor.getInt(0))
|
assertEquals(21, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(20, cursor.getInt(0))
|
assertEquals(21, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -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
|
without clearing package data, verified the resulting plaintext archive, then
|
||||||
installed the current signed release variant through a strictly increasing
|
installed the current signed release variant through a strictly increasing
|
||||||
version-code chain. The final installed private candidate retains product
|
version-code chain. The final installed private candidate retains product
|
||||||
version 0.1.2 and schema v20. Its full-generation foreground coordinator waits
|
version 0.1.2 and schema v21. Schema v21 adds the verified generation archive
|
||||||
for correlated objects, tolerates stale objects left by interrupted runs, and
|
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.
|
shows completion only after both durable peer acknowledgements.
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@ parallel implementation of its rules.
|
||||||
| Boundary | Current state |
|
| Boundary | Current state |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||||
| Desktop SQLite | schema v21 |
|
| Desktop SQLite | schema v22 |
|
||||||
| Android SQLite | schema v20 |
|
| Android SQLite | schema v21 |
|
||||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||||
| Desktop terminal backend | Notcurses only |
|
| Desktop terminal backend | Notcurses only |
|
||||||
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
|
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
|
||||||
|
|
@ -359,7 +359,7 @@ The focused causal closeout additionally proves child-data delete/update
|
||||||
conflicts including change-and-revert, imported built-in and alias protection,
|
conflicts including change-and-revert, imported built-in and alias protection,
|
||||||
active/pending/desktop draft-revision equivalence, protected companion refusal,
|
active/pending/desktop draft-revision equivalence, protected companion refusal,
|
||||||
current-view retirement and bounded pre-effect admission. The later generation
|
current-view retirement and bounded pre-effect admission. The later generation
|
||||||
slice additively advances schemas to desktop v21/Android v20 without changing
|
slice additively advances schemas to desktop v22/Android v21 without changing
|
||||||
active V3 transport selection.
|
active V3 transport selection.
|
||||||
|
|
||||||
The residual causal evidence also proves legitimate Android and desktop
|
The residual causal evidence also proves legitimate Android and desktop
|
||||||
|
|
|
||||||
|
|
@ -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
|
desktop v9 copies before applying migrations to the real stores. SQLite files
|
||||||
are never synchronization artifacts.
|
are never synchronization artifacts.
|
||||||
|
|
||||||
|
Desktop schema v22 adds `sync_generation_archives` as an append-only archive
|
||||||
|
ledger. A row retains the generation identity, verified manifest digest,
|
||||||
|
deterministic archive digest, durable location, timestamp and canonical audit
|
||||||
|
record. Generation, artifact, ACK, consumption and causal-publication rows are
|
||||||
|
not deleted or rewritten when a payload becomes archived.
|
||||||
|
|
||||||
The schemas deliberately differ where ownership differs. Desktop sessions own
|
The schemas deliberately differ where ownership differs. Desktop sessions own
|
||||||
`ended_at`, notes, planned targets and session-specific load semantics; the
|
`ended_at`, notes, planned targets and session-specific load semantics; the
|
||||||
Android capture schema does not. Desktop body observations may link to a
|
Android capture schema does not. Desktop body observations may link to a
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ distinction between measured values and estimates.
|
||||||
|
|
||||||
## Current baseline
|
## 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
|
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
|
||||||
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
|
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
|
||||||
implemented. Session Generator V1 is hidden pending V2. AI session-draft
|
implemented. Session Generator V1 is hidden pending V2. AI session-draft
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ available because availability is independently guarded by causal state.
|
||||||
|
|
||||||
## Machine-exercise Phase 1 compatibility
|
## Machine-exercise Phase 1 compatibility
|
||||||
|
|
||||||
Desktop schema 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
|
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||||
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||||
silently added to a frozen artifact: stable exercise IDs and canonical names
|
silently added to a frozen artifact: stable exercise IDs and canonical names
|
||||||
|
|
@ -756,8 +756,32 @@ transaction while calling the existing domain exporters. Both validate domain
|
||||||
and outer limits before recording a captured generation. Publication copies
|
and outer limits before recording a captured generation. Publication copies
|
||||||
verified immutable bytes into `generations/<generation_id>/`, resumes only
|
verified immutable bytes into `generations/<generation_id>/`, resumes only
|
||||||
byte-identical partial publication, and writes `manifest.json` last. Capacity
|
byte-identical partial publication, and writes `manifest.json` last. Capacity
|
||||||
admits at most eight retained outgoing generations per consumer; exhaustion is
|
admits at most eight active outgoing generations per consumer. Acknowledged
|
||||||
recoverable and never evicts an unacknowledged generation or causal proof.
|
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,
|
The required artifact order is causal deletions, catalog/profile identity,
|
||||||
custom-equipment definitions, V4 history, aliases and occurrence equipment,
|
custom-equipment definitions, V4 history, aliases and occurrence equipment,
|
||||||
|
|
|
||||||
|
|
@ -1323,11 +1323,23 @@ Android generation service, desktop consumers and durable ACK path remain real.
|
||||||
backup including an active draft, raw partial input, stable peer identity and
|
backup including an active draft, raw partial input, stable peer identity and
|
||||||
preferences, and rejects corrupt and traversal entries. The bridge build runs
|
preferences, and rejects corrupt and traversal entries. The bridge build runs
|
||||||
the analogous test on the exact schema-v17 source and feeds its archive to
|
the analogous test on the exact schema-v17 source and feeds its archive to
|
||||||
`BridgeBackupMigrationTest`, which verifies normal migration to schema v20.
|
`BridgeBackupMigrationTest`, which verifies normal migration to schema v21.
|
||||||
These are software proofs. They do not claim physical USB/MTP behavior,
|
These are software proofs. They do not claim physical USB/MTP behavior,
|
||||||
installation over the signed release, a real user backup, or a real-device
|
installation over the signed release, a real user backup, or a real-device
|
||||||
restore.
|
restore.
|
||||||
|
|
||||||
|
The production Android coordinator and desktop peer worker also complete 24
|
||||||
|
successive bidirectional conversations against their real SQLite services with
|
||||||
|
no cleanup between runs. The test crosses multiple archive cycles and checks
|
||||||
|
that the active draft survives. After the eighth exchange it also reproduces
|
||||||
|
the deployed legacy evidence gap by retaining acknowledged generations while
|
||||||
|
removing only their producer-side ACK rows; the next production conversation
|
||||||
|
must restore the desktop consumer's exact correlated ACKs, archive eligible
|
||||||
|
generations, and complete without deleting or duplicating any generation.
|
||||||
|
Focused archive tests cover interrupted-copy resume, invalid archives,
|
||||||
|
simulated insufficient space, missing and late ACKs, and idempotent ACK replay
|
||||||
|
after archival.
|
||||||
|
|
||||||
## Readability validation
|
## Readability validation
|
||||||
|
|
||||||
The normalized C scope is checked non-mutatively with the explicit
|
The normalized C scope is checked non-mutatively with the explicit
|
||||||
|
|
|
||||||
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()
|
||||||
|
|
@ -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_generation_artifacts(generation_id TEXT,logical_name TEXT,format TEXT,version INTEGER,filename TEXT,size_bytes INTEGER,sha256 TEXT,required INTEGER,PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename));
|
||||||
CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,consumed_at TEXT,result TEXT,durability TEXT,diagnostic TEXT,ack_json TEXT,UNIQUE(producer_peer_id,generation_id));
|
CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,consumed_at TEXT,result TEXT,durability TEXT,diagnostic TEXT,ack_json TEXT,UNIQUE(producer_peer_id,generation_id));
|
||||||
CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT);
|
CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT);
|
||||||
|
CREATE TABLE sync_generation_archives(generation_id TEXT PRIMARY KEY,archive_path TEXT UNIQUE,manifest_sha256 TEXT,archive_sha256 TEXT,archived_at TEXT,audit_json TEXT);
|
||||||
CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id));
|
CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id));
|
||||||
PRAGMA user_version=21;
|
PRAGMA user_version=22;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class GenerationTest(unittest.TestCase):
|
class GenerationTest(unittest.TestCase):
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (20, 21):
|
if version not in (20, 21, 22):
|
||||||
raise CausalError(f"desktop schema v20/v21 required, found v{version}")
|
raise CausalError(f"desktop schema v20-v22 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
||||||
|
|
||||||
def require_schema(connection):
|
def require_schema(connection):
|
||||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (19, 20, 21):
|
if version not in (19, 20, 21, 22):
|
||||||
raise LifecycleError(f"desktop schema v19/v20/v21 required, found v{version}")
|
raise LifecycleError(f"desktop schema v19-v22 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def import_document(connection, document, own_transaction=True):
|
def import_document(connection, document, own_transaction=True):
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
||||||
from trainlog_sqlite import configure_connection
|
from trainlog_sqlite import configure_connection
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21)
|
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22)
|
||||||
MEASUREMENT_FIELDS = (
|
MEASUREMENT_FIELDS = (
|
||||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
|
||||||
def ensure_publication_state(connection):
|
def ensure_publication_state(connection):
|
||||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||||
owned_transaction = not connection.in_transaction
|
owned_transaction = not connection.in_transaction
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22):
|
||||||
raise ValueError("schema desktop v18-v21 requis")
|
raise ValueError("schema desktop v18-v22 requis")
|
||||||
columns = {row[1] for row in connection.execute(
|
columns = {row[1] for row in connection.execute(
|
||||||
"PRAGMA table_info(ai_session_drafts)")}
|
"PRAGMA table_info(ai_session_drafts)")}
|
||||||
if "published_at" not in columns:
|
if "published_at" not in columns:
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
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")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
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")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal equipment protection requires the staged artifact")
|
raise ValueError("causal equipment protection requires the staged artifact")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
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")
|
raise ValueError("schema desktop v12-v16 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
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")
|
raise ValueError("schema desktop v11-v16 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (17, 18, 19, 20, 21):
|
if version not in (17, 18, 19, 20, 21, 22):
|
||||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||||
rows = con.execute("""
|
rows = con.execute("""
|
||||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ def main():
|
||||||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if args.version == 4 and schema_version not in (19, 20, 21):
|
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")
|
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)")
|
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||||
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
||||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ def main(complete_causal_envelope=False):
|
||||||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||||
db=connect_database(args.database)
|
db=connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21): 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")
|
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||||
|
|
|
||||||
|
|
@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None):
|
||||||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||||
any failure leaves no draft, child, or import-identity row behind.
|
any failure leaves no draft, child, or import-identity row behind.
|
||||||
"""
|
"""
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22):
|
||||||
raise ImportFailure("schema desktop v18-v21 requis")
|
raise ImportFailure("schema desktop v18-v22 requis")
|
||||||
draft, entries = validate(payload, connection)
|
draft, entries = validate(payload, connection)
|
||||||
digest = canonical_digest(payload)
|
digest = canonical_digest(payload)
|
||||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ def main():
|
||||||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
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")
|
fail("schema desktop v8 à v21 requis")
|
||||||
proof_path = args.mobile_export
|
proof_path = args.mobile_export
|
||||||
if proof_path is None:
|
if proof_path is None:
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ def validate(payload, reserved):
|
||||||
|
|
||||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||||
"""Apply validated definitions without owning the surrounding transaction."""
|
"""Apply validated definitions without owning the surrounding transaction."""
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
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")
|
fail("schema desktop v8 à v21 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||||
fail("causal equipment protection requires the staged artifact")
|
fail("causal equipment protection requires the staged artifact")
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ def load(path):
|
||||||
|
|
||||||
def apply_aliases(con, aliases):
|
def apply_aliases(con, aliases):
|
||||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
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")
|
fail("schema desktop v12-v21 requis")
|
||||||
for source, canonical in aliases:
|
for source, canonical in aliases:
|
||||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
|
||||||
|
|
||||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||||
"""Apply validated zone rows without owning transaction boundaries."""
|
"""Apply validated zone rows without owning transaction boundaries."""
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21): 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")
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ def validate(item):
|
||||||
def apply_profile_state(con, root, allow_pending=False):
|
def apply_profile_state(con, root, allow_pending=False):
|
||||||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21): 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()
|
applied=equal=ancestor=pending=0;seen=set()
|
||||||
for item in root["exercises"]:
|
for item in root["exercises"]:
|
||||||
validate(item);eid=item["exercise_id"]
|
validate(item);eid=item["exercise_id"]
|
||||||
|
|
|
||||||
|
|
@ -846,9 +846,9 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# readable for legacy artifacts and are never made to fake that table.
|
||||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||||
raise ImportFailure(
|
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)
|
require_supported_schema(connection)
|
||||||
|
|
||||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||||
if payload["version"] == 4 and schema_version not in (19, 20, 21):
|
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22):
|
||||||
raise ImportFailure("mobile V4 exige le schéma desktop v19, v20 ou v21")
|
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v22")
|
||||||
if schema_version >= 20:
|
if schema_version >= 20:
|
||||||
protected = {(row[0], row[1]) for row in connection.execute(
|
protected = {(row[0], row[1]) for row in connection.execute(
|
||||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
||||||
def apply_feedback(db,root,complete_causal_envelope=False):
|
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
db.execute("PRAGMA foreign_keys=ON")
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21):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")
|
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
||||||
seen=set()
|
seen=set()
|
||||||
for index,item in enumerate(root["exercise_feedback"]):
|
for index,item in enumerate(root["exercise_feedback"]):
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,8 @@ def main() -> int:
|
||||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||||
).strip(),
|
).strip(),
|
||||||
"product_version": "0.1.2",
|
"product_version": "0.1.2",
|
||||||
"desktop_schema": 21,
|
"desktop_schema": 22,
|
||||||
"android_schema": 20,
|
"android_schema": 21,
|
||||||
"protocols": [
|
"protocols": [
|
||||||
"mobile-export-v3",
|
"mobile-export-v3",
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
|
|
@ -104,6 +104,7 @@ def main() -> int:
|
||||||
"execution-draft-v1",
|
"execution-draft-v1",
|
||||||
"generation-manifest-v1",
|
"generation-manifest-v1",
|
||||||
"generation-ack-v1",
|
"generation-ack-v1",
|
||||||
|
"generation-archive-v1",
|
||||||
],
|
],
|
||||||
"entry_points": [
|
"entry_points": [
|
||||||
"bin/trainlog",
|
"bin/trainlog",
|
||||||
|
|
|
||||||
|
|
@ -199,8 +199,8 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
||||||
|
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] != 21:
|
if db.execute("PRAGMA user_version").fetchone()[0] != 22:
|
||||||
raise GenerationError("desktop schema v21 required")
|
raise GenerationError("desktop schema v22 required")
|
||||||
|
|
||||||
|
|
||||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||||
|
|
@ -215,6 +215,106 @@ def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||||
return identity
|
return identity
|
||||||
|
|
||||||
|
|
||||||
|
def archive_digest(directory: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for path in sorted(value for value in directory.rglob("*") if value.is_file()):
|
||||||
|
if path.is_symlink():
|
||||||
|
raise GenerationError("archive contains symbolic link")
|
||||||
|
relative = path.relative_to(directory).as_posix().encode("utf-8")
|
||||||
|
digest.update(len(relative).to_bytes(4, "big"))
|
||||||
|
digest.update(relative)
|
||||||
|
digest.update(bytes.fromhex(digest_file(path)[1]))
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def archive_acknowledged(database: Path, owned_root: Path, consumer: str) -> int:
|
||||||
|
"""Durably archive eligible acknowledged payloads without deleting evidence.
|
||||||
|
|
||||||
|
WHY: acknowledged generations otherwise consume the bounded active window
|
||||||
|
forever. CONTRACT: exact correlated consumed ACK evidence is required and
|
||||||
|
the newest two acknowledged lineage members remain active for recovery.
|
||||||
|
INVARIANT: generation, artifact, ACK and causal rows are never removed or
|
||||||
|
rewritten; a ledger row is committed only after a verified atomic copy.
|
||||||
|
"""
|
||||||
|
archive_root = owned_root / "archives" / "generations"
|
||||||
|
archive_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
with closing(connect_database(database)) as db:
|
||||||
|
require_schema(db)
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT g.generation_id,g.run_id,g.producer_peer_id,g.consumer_peer_id,"
|
||||||
|
"g.manifest_sha256,g.staging_path,g.manifest_json FROM sync_generations g "
|
||||||
|
"WHERE g.consumer_peer_id=? AND g.status='acknowledged' "
|
||||||
|
"AND NOT EXISTS(SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id) "
|
||||||
|
"ORDER BY g.generated_at DESC,g.generation_id DESC",
|
||||||
|
(consumer,),
|
||||||
|
).fetchall()
|
||||||
|
archived = 0
|
||||||
|
for generation, run, producer, target, manifest_sha, staging, manifest_json in rows[2:]:
|
||||||
|
with closing(connect_database(database)) as db:
|
||||||
|
ack = db.execute(
|
||||||
|
"SELECT ack_id FROM sync_acknowledgements WHERE generation_id=? AND run_id=? "
|
||||||
|
"AND producer_peer_id=? AND consumer_peer_id=? AND manifest_sha256=? "
|
||||||
|
"AND result='consumed' AND durability='sqlite-commit-full' LIMIT 2",
|
||||||
|
(generation, run, producer, target, manifest_sha),
|
||||||
|
).fetchall()
|
||||||
|
if len(ack) != 1:
|
||||||
|
continue
|
||||||
|
source = Path(staging)
|
||||||
|
if source.is_symlink():
|
||||||
|
raise GenerationError("generation staging is a symbolic link")
|
||||||
|
manifest = validate_manifest_bytes(manifest_json.encode(), target)
|
||||||
|
validate_published(source, target)
|
||||||
|
destination = archive_root / generation
|
||||||
|
temporary = archive_root / ("." + generation + ".tmp")
|
||||||
|
if destination.exists():
|
||||||
|
if destination.is_symlink():
|
||||||
|
raise GenerationError("generation archive is a symbolic link")
|
||||||
|
validate_published(destination, target)
|
||||||
|
checksum = archive_digest(destination)
|
||||||
|
else:
|
||||||
|
if temporary.exists():
|
||||||
|
if temporary.is_symlink():
|
||||||
|
raise GenerationError("temporary generation archive is a symbolic link")
|
||||||
|
shutil.rmtree(temporary)
|
||||||
|
shutil.copytree(source, temporary, copy_function=shutil.copy2)
|
||||||
|
validate_published(temporary, target)
|
||||||
|
checksum = archive_digest(temporary)
|
||||||
|
for copied in temporary.rglob("*"):
|
||||||
|
if copied.is_file():
|
||||||
|
with copied.open("rb") as stream:
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
directory_fd = os.open(archive_root, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory_fd)
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
audit = canonical({
|
||||||
|
"format": "trainlog-sync-generation-archive-audit",
|
||||||
|
"version": 1,
|
||||||
|
"generation_id": generation,
|
||||||
|
"run_id": run,
|
||||||
|
"manifest_sha256": manifest_sha,
|
||||||
|
"ack_id": ack[0][0],
|
||||||
|
"archive_sha256": checksum,
|
||||||
|
}).decode()
|
||||||
|
with closing(connect_database(database)) as db:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
current = db.execute(
|
||||||
|
"SELECT status,manifest_sha256 FROM sync_generations WHERE generation_id=?",
|
||||||
|
(generation,),
|
||||||
|
).fetchone()
|
||||||
|
if current != ("acknowledged", manifest_sha):
|
||||||
|
raise GenerationError("generation changed during archive")
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR IGNORE INTO sync_generation_archives VALUES(?,?,?,?,?,?)",
|
||||||
|
(generation, str(destination), manifest_sha, checksum, now(), audit),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
archived += 1
|
||||||
|
return archived
|
||||||
|
|
||||||
|
|
||||||
def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None:
|
def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None:
|
||||||
command = [sys.executable, str(ROOT / "tools" / tool)]
|
command = [sys.executable, str(ROOT / "tools" / tool)]
|
||||||
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"):
|
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"):
|
||||||
|
|
@ -249,9 +349,10 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
||||||
run = run_id or "sy_" + str(uuid.uuid4())
|
run = run_id or "sy_" + str(uuid.uuid4())
|
||||||
if not valid_id(generation, "gen") or not valid_id(run, "sy"):
|
if not valid_id(generation, "gen") or not valid_id(run, "sy"):
|
||||||
raise GenerationError("invalid generation/run")
|
raise GenerationError("invalid generation/run")
|
||||||
|
archive_acknowledged(database, owned_root, consumer)
|
||||||
with closing(connect_database(database)) as admission:
|
with closing(connect_database(database)) as admission:
|
||||||
require_schema(admission)
|
require_schema(admission)
|
||||||
retained = admission.execute("SELECT COUNT(*) FROM sync_generations 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:
|
if retained >= MAX_RETAINED_PER_PEER:
|
||||||
raise GenerationError("generation retention capacity exhausted")
|
raise GenerationError("generation retention capacity exhausted")
|
||||||
stage = owned_root / "staging" / generation
|
stage = owned_root / "staging" / generation
|
||||||
|
|
@ -539,10 +640,23 @@ def accept_ack(database: Path, path: Path) -> str:
|
||||||
raise GenerationError("ACK has no pending generation")
|
raise GenerationError("ACK has no pending generation")
|
||||||
ack = validate_ack(raw, row)
|
ack = validate_ack(raw, row)
|
||||||
status = "acknowledged" if ack["result"] == "consumed" else "rejected"
|
status = "acknowledged" if ack["result"] == "consumed" else "rejected"
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR IGNORE INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(ack["ack_id"], ack["generation_id"], ack["run_id"],
|
||||||
|
ack["producer_peer_id"], ack["consumer_peer_id"],
|
||||||
|
ack["manifest_sha256"], ack["result"], ack["durability"],
|
||||||
|
ack["consumed_at"], ack["diagnostic"], ack["payload_sha256"]),
|
||||||
|
)
|
||||||
|
stored = db.execute(
|
||||||
|
"SELECT payload_sha256 FROM sync_acknowledgements WHERE ack_id=?",
|
||||||
|
(ack["ack_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if stored != (ack["payload_sha256"],):
|
||||||
|
raise GenerationError("conflicting ACK replay")
|
||||||
if row[5] in ("acknowledged", "rejected"):
|
if row[5] in ("acknowledged", "rejected"):
|
||||||
if row[5] != status:
|
if row[5] != status:
|
||||||
raise GenerationError("conflicting ACK replay")
|
raise GenerationError("conflicting ACK replay")
|
||||||
db.rollback(); return "unchanged"
|
db.commit(); return "unchanged"
|
||||||
if row[5] != "waiting_acknowledgement":
|
if row[5] != "waiting_acknowledgement":
|
||||||
raise GenerationError("generation is not awaiting ACK")
|
raise GenerationError("generation is not awaiting ACK")
|
||||||
db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",
|
db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,8 @@ def failure_code(error: BaseException) -> str:
|
||||||
diagnostic = str(error).lower()
|
diagnostic = str(error).lower()
|
||||||
if "transport_timeout:" in diagnostic or "peer worker timed out" in diagnostic:
|
if "transport_timeout:" in diagnostic or "peer worker timed out" in diagnostic:
|
||||||
return "transport_timeout"
|
return "transport_timeout"
|
||||||
|
if "peer_capacity_exhausted:" in diagnostic:
|
||||||
|
return "peer_capacity_exhausted"
|
||||||
if "expected android peer not found" in diagnostic:
|
if "expected android peer not found" in diagnostic:
|
||||||
return "device_unavailable"
|
return "device_unavailable"
|
||||||
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
|
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ CAPS = {
|
||||||
"causal-delete-v1",
|
"causal-delete-v1",
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
"execution-draft-v1",
|
"execution-draft-v1",
|
||||||
|
"generation-archive-v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,9 +54,12 @@ def bounded(path: Path, limit: int = 65536) -> bytes:
|
||||||
|
|
||||||
def publish_json(path: Path, value: dict) -> None:
|
def publish_json(path: Path, value: dict) -> None:
|
||||||
"""Publish a coordination object only after all bytes are durable."""
|
"""Publish a coordination object only after all bytes are durable."""
|
||||||
|
raw = generation.canonical(value)
|
||||||
|
if len(raw) > 65536:
|
||||||
|
raise RuntimeError(f"{path.name} exceeds bound")
|
||||||
temporary = path.with_name("." + path.name + ".tmp-" + str(os.getpid()))
|
temporary = path.with_name("." + path.name + ".tmp-" + str(os.getpid()))
|
||||||
with temporary.open("wb") as stream:
|
with temporary.open("wb") as stream:
|
||||||
stream.write(generation.canonical(value))
|
stream.write(raw)
|
||||||
stream.flush()
|
stream.flush()
|
||||||
os.fsync(stream.fileno())
|
os.fsync(stream.fileno())
|
||||||
os.chmod(temporary, 0o600)
|
os.chmod(temporary, 0o600)
|
||||||
|
|
@ -166,6 +170,27 @@ def wait_json(path: Path, deadline: float, predicate, pump=None) -> dict:
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def correlated_peer_error(root: Path, run_id: str) -> None:
|
||||||
|
path = root / "android-generation-error-v1.json"
|
||||||
|
if not path.is_file():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
value = json.loads(bounded(path))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
isinstance(value, dict)
|
||||||
|
and value.get("format") == "trainlog-sync-generation-error"
|
||||||
|
and value.get("version") == 1
|
||||||
|
and value.get("run_id") == run_id
|
||||||
|
and value.get("code") == "peer_capacity_exhausted"
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"peer_capacity_exhausted: Android active generation capacity is exhausted; "
|
||||||
|
"archive acknowledged generations, then retry explicitly"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_peer(root: Path, expected: str) -> dict:
|
def load_peer(root: Path, expected: str) -> dict:
|
||||||
value = json.loads(bounded(root / "android-peer-v1.json"))
|
value = json.loads(bounded(root / "android-peer-v1.json"))
|
||||||
if (
|
if (
|
||||||
|
|
@ -225,12 +250,28 @@ def main() -> int:
|
||||||
}
|
}
|
||||||
request_path = args.transport_root / "request-v1.json"
|
request_path = args.transport_root / "request-v1.json"
|
||||||
publish_json(request_path, request)
|
publish_json(request_path, request)
|
||||||
|
with closing(generation.connect_database(args.database)) as db:
|
||||||
|
acknowledgement_rows = db.execute(
|
||||||
|
"SELECT ack_json FROM sync_consumed_generations WHERE producer_peer_id=? "
|
||||||
|
"AND consumer_peer_id=? AND result='consumed' ORDER BY consumed_at,generation_id LIMIT 32",
|
||||||
|
(peer["peer_id"], desktop_peer),
|
||||||
|
).fetchall()
|
||||||
|
archive_acknowledgements = {
|
||||||
|
"format": "trainlog-sync-archive-acknowledgements",
|
||||||
|
"version": 1,
|
||||||
|
"run_id": args.run_id,
|
||||||
|
"android_peer_id": peer["peer_id"],
|
||||||
|
"desktop_peer_id": desktop_peer,
|
||||||
|
"acknowledgements": [json.loads(row[0]) for row in acknowledgement_rows],
|
||||||
|
}
|
||||||
|
archive_ack_path = args.transport_root / "desktop-archive-acknowledgements-v1.json"
|
||||||
|
publish_json(archive_ack_path, archive_acknowledgements)
|
||||||
if args.mode == "mtp":
|
if args.mode == "mtp":
|
||||||
push_adapter(
|
push_adapter(
|
||||||
args.mtp_adapter,
|
args.mtp_adapter,
|
||||||
args.expected_peer,
|
args.expected_peer,
|
||||||
args.transport_root,
|
args.transport_root,
|
||||||
["request-v1.json"],
|
["request-v1.json", "desktop-archive-acknowledgements-v1.json"],
|
||||||
deadline,
|
deadline,
|
||||||
)
|
)
|
||||||
emit(
|
emit(
|
||||||
|
|
@ -240,8 +281,13 @@ def main() -> int:
|
||||||
consumer_peer_id=desktop_peer,
|
consumer_peer_id=desktop_peer,
|
||||||
)
|
)
|
||||||
inbound_ref = args.transport_root / "android-generation-v1.json"
|
inbound_ref = args.transport_root / "android-generation-v1.json"
|
||||||
|
def pump_inbound():
|
||||||
|
if pump is not None:
|
||||||
|
pump()
|
||||||
|
correlated_peer_error(args.transport_root, args.run_id)
|
||||||
|
|
||||||
inbound = wait_json(
|
inbound = wait_json(
|
||||||
inbound_ref, deadline, lambda value: value.get("run_id") == args.run_id, pump
|
inbound_ref, deadline, lambda value: value.get("run_id") == args.run_id, pump_inbound
|
||||||
)
|
)
|
||||||
inbound_dir = args.transport_root / inbound["relative_path"]
|
inbound_dir = args.transport_root / inbound["relative_path"]
|
||||||
ack = generation.consume_desktop(args.database, inbound_dir)
|
ack = generation.consume_desktop(args.database, inbound_dir)
|
||||||
|
|
@ -252,7 +298,7 @@ def main() -> int:
|
||||||
args.mtp_adapter,
|
args.mtp_adapter,
|
||||||
args.expected_peer,
|
args.expected_peer,
|
||||||
args.transport_root,
|
args.transport_root,
|
||||||
["request-v1.json", "desktop-consumption-ack-v1.json"],
|
["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"],
|
||||||
deadline,
|
deadline,
|
||||||
)
|
)
|
||||||
if ack["result"] != "consumed":
|
if ack["result"] != "consumed":
|
||||||
|
|
@ -291,6 +337,7 @@ def main() -> int:
|
||||||
args.transport_root,
|
args.transport_root,
|
||||||
[
|
[
|
||||||
"request-v1.json",
|
"request-v1.json",
|
||||||
|
"desktop-archive-acknowledgements-v1.json",
|
||||||
"desktop-consumption-ack-v1.json",
|
"desktop-consumption-ack-v1.json",
|
||||||
"desktop-generation-v1.json",
|
"desktop-generation-v1.json",
|
||||||
ref["relative_path"],
|
ref["relative_path"],
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 21
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 22
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -770,6 +770,12 @@ test(
|
||||||
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
|
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
|
||||||
timeout: 120,
|
timeout: 120,
|
||||||
)
|
)
|
||||||
|
test(
|
||||||
|
'generation_archival',
|
||||||
|
find_program('python3'),
|
||||||
|
args: [meson.project_source_root() / 'tests/test_generation_archival.py'],
|
||||||
|
timeout: 120,
|
||||||
|
)
|
||||||
test(
|
test(
|
||||||
'sync_orchestrator',
|
'sync_orchestrator',
|
||||||
find_program('python3'),
|
find_program('python3'),
|
||||||
|
|
|
||||||
|
|
@ -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));"
|
"CREATE TABLE sync_causal_publications(operation_id TEXT NOT NULL REFERENCES sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));"
|
||||||
"PRAGMA user_version=21;COMMIT;";
|
"PRAGMA user_version=21;COMMIT;";
|
||||||
|
|
||||||
|
/* WHY: acknowledged payload copies cannot consume active admission forever,
|
||||||
|
* but generation identity, lineage and ACK evidence must remain permanent.
|
||||||
|
* CONTRACT: v22 adds an append-only archive ledger; it neither fabricates an
|
||||||
|
* ACK nor changes a generation status. INVARIANT: an archive row is valid only
|
||||||
|
* after production code has durably copied and verified the complete payload. */
|
||||||
|
static const char *const MIGRATE_V21_TO_V22_SQL =
|
||||||
|
"BEGIN IMMEDIATE;"
|
||||||
|
"CREATE TABLE sync_generation_archives("
|
||||||
|
"generation_id TEXT PRIMARY KEY REFERENCES sync_generations(generation_id),"
|
||||||
|
"archive_path TEXT NOT NULL UNIQUE,manifest_sha256 TEXT NOT NULL,"
|
||||||
|
"archive_sha256 TEXT NOT NULL,archived_at TEXT NOT NULL,"
|
||||||
|
"audit_json TEXT NOT NULL);"
|
||||||
|
"PRAGMA user_version=22;COMMIT;";
|
||||||
|
|
||||||
/* CONTRACT: v18 was already deployed before bounded draft publication was
|
/* CONTRACT: v18 was already deployed before bounded draft publication was
|
||||||
* added. Keep its version number and add only the nullable lifecycle cursor;
|
* added. Keep its version number and add only the nullable lifecycle cursor;
|
||||||
* NULL deliberately means retryable/not yet published. */
|
* NULL deliberately means retryable/not yet published. */
|
||||||
|
|
@ -1565,7 +1579,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
||||||
version == 15 || version == 16 || version == 17 || version == 18 ||
|
version == 15 || version == 16 || version == 17 || version == 18 ||
|
||||||
version == 19 || version == 20 || version == 21) {
|
version == 19 || version == 20 || version == 21 || version == 22) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -1738,6 +1752,9 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
if (status == TRAINLOG_STATUS_OK && version < 21) {
|
if (status == TRAINLOG_STATUS_OK && version < 21) {
|
||||||
status = execute_sql(database, MIGRATE_V20_TO_V21_SQL);
|
status = execute_sql(database, MIGRATE_V20_TO_V21_SQL);
|
||||||
}
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && version < 22) {
|
||||||
|
status = execute_sql(database, MIGRATE_V21_TO_V22_SQL);
|
||||||
|
}
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v18_ai_draft_publication_state(database);
|
status = ensure_v18_ai_draft_publication_state(database);
|
||||||
}
|
}
|
||||||
|
|
@ -1753,7 +1770,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
set_open_diagnostic(
|
set_open_diagnostic(
|
||||||
output_diagnostic,
|
output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
output_diagnostic_capacity,
|
||||||
version == 0 ? "create schema v21" : "migrate database to schema v21",
|
version == 0 ? "create schema v22" : "migrate database to schema v22",
|
||||||
database->connection,
|
database->connection,
|
||||||
SQLITE_ERROR
|
SQLITE_ERROR
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,10 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
|
||||||
status = pull_named_file(
|
status = pull_named_file(
|
||||||
io, &selection, selection.root, "android-consumption-ack-v1.json", local, false);
|
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 &&
|
if (status == TRAINLOG_STATUS_OK &&
|
||||||
referenced_android_generation(local, generation, sizeof(generation))) {
|
referenced_android_generation(local, generation, sizeof(generation))) {
|
||||||
status = pull_referenced_generation(io, &selection, local, generation);
|
status = pull_referenced_generation(io, &selection, local, generation);
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,17 @@ int main(void)
|
||||||
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
|
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
|
||||||
trainlog_database_close(database);database=NULL;
|
trainlog_database_close(database);database=NULL;
|
||||||
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE sync_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(sqlite3_close(raw)==SQLITE_OK);
|
||||||
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
|
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
|
||||||
trainlog_database_close(database);database=NULL;
|
trainlog_database_close(database);database=NULL;
|
||||||
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
||||||
CHECK(scalar(raw,"PRAGMA user_version")==21);
|
CHECK(scalar(raw,"PRAGMA user_version")==22);
|
||||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
|
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
|
||||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
|
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
|
||||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);
|
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);
|
||||||
|
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generation_archives")==0);
|
||||||
CHECK(sqlite3_close(raw)==SQLITE_OK);CHECK(unlink(path)==0);
|
CHECK(sqlite3_close(raw)==SQLITE_OK);CHECK(unlink(path)==0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic, "migrate database to schema v21: SQLite rc=",
|
CHECK(strncmp(diagnostic, "migrate database to schema v22: SQLite rc=",
|
||||||
strlen("migrate database to schema v21: SQLite rc=")) == 0);
|
strlen("migrate database to schema v22: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=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(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "schema version 22 is newer") != NULL);
|
CHECK(strstr(diagnostic, "schema version 23 is newer") != NULL);
|
||||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "migrate database to schema v21") != NULL);
|
CHECK(strstr(diagnostic, "migrate database to schema v22") != NULL);
|
||||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ function statusAction(status: SyncStatus | null): string {
|
||||||
if (status?.phase === 'failed' && status.error_code === 'transport_timeout') {
|
if (status?.phase === 'failed' && status.error_code === 'transport_timeout') {
|
||||||
return 'Vérifiez le dialogue USB/MTP sur Android avant de relancer.'
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,17 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
|
||||||
expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument()
|
expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rend chaque proposition consultable sans la transformer en brouillon', () => {
|
||||||
|
render(<NextSessionTile size="large" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [
|
||||||
|
{ identity: 'aid_first', kind: 'ai_proposal', title: 'Première', planned_for: null, state: 'published', occurrence_count: 2, provenance: 'ai_import' },
|
||||||
|
{ identity: 'aid_expected', kind: 'ai_proposal', title: 'Attendue', planned_for: null, state: 'published', occurrence_count: 6, provenance: 'ai_import' },
|
||||||
|
] }} pending={false} failed={false} />)
|
||||||
|
const expected = document.querySelector('[data-prepared-identity="aid_expected"]')
|
||||||
|
expect(expected).toHaveTextContent('Attendue')
|
||||||
|
expect(expected).toHaveAttribute('data-prepared-identity', 'aid_expected')
|
||||||
|
expect(document.body).not.toHaveTextContent('Brouillon d’exécution · à reprendre')
|
||||||
|
})
|
||||||
|
|
||||||
it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => {
|
it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => {
|
||||||
const { container } = render(<ActivityTile snapshot={dashboardFixture()} size={size} />)
|
const { container } = render(<ActivityTile snapshot={dashboardFixture()} size={size} />)
|
||||||
const heatmap = screen.getByRole('img')
|
const heatmap = screen.getByRole('img')
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,25 @@ export function NextSessionTile({
|
||||||
<p className="primary-label">{heading}</p>
|
<p className="primary-label">{heading}</p>
|
||||||
<strong>{title}</strong>
|
<strong>{title}</strong>
|
||||||
{size !== 'compact' && (
|
{size !== 'compact' && (
|
||||||
|
<>
|
||||||
<dl className="fact-list horizontal">
|
<dl className="fact-list horizontal">
|
||||||
<Fact label="Éléments" value={String(item.occurrence_count)} />
|
<Fact label="Éléments" value={String(item.occurrence_count)} />
|
||||||
<Fact label="Prévue" value={item.planned_for ? formatDate(item.planned_for) : 'Non renseignée'} />
|
<Fact label="Prévue" value={item.planned_for ? formatDate(item.planned_for) : 'Non renseignée'} />
|
||||||
</dl>
|
</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>}
|
{failed && <small>Dernière lecture conservée · actualisation indisponible</small>}
|
||||||
{pending && !failed && <small>Actualisation…</small>}
|
{pending && !failed && <small>Actualisation…</small>}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue