Compare commits
6 commits
56ac1cdfd2
...
a5cdbdf2b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5cdbdf2b9 | ||
|
|
d87d7cd302 | ||
|
|
908c38546c | ||
|
|
4c330be8c3 | ||
|
|
6f3ad45cb4 | ||
|
|
f7b556cc37 |
72 changed files with 2413 additions and 169 deletions
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -2,6 +2,42 @@
|
||||||
|
|
||||||
## Unreleased — 0.1.2
|
## Unreleased — 0.1.2
|
||||||
|
|
||||||
|
- Added compatible non-destructive generation archival on desktop schema v22
|
||||||
|
and Android schema v21. Only exactly acknowledged generations older than the
|
||||||
|
two active recovery tips leave admission after a verified durable payload
|
||||||
|
copy and audit record; all identities, manifests, ACKs, lineage and causal
|
||||||
|
evidence remain stored. Older Android stores can recover exact missing
|
||||||
|
producer-side ACK rows from the desktop consumer's durable ledger, with
|
||||||
|
strict run, peer, generation and manifest revalidation. A correlated Android
|
||||||
|
capacity error now reaches the Web without waiting for a transport timeout,
|
||||||
|
and the prepared-items tile can inspect every proposal rather than only its
|
||||||
|
first compact summary.
|
||||||
|
|
||||||
|
- Completed the controlled schema-v22/schema-v21 rollout without uninstalling
|
||||||
|
or clearing Android data. The production Android archive uses a supported
|
||||||
|
post-rename file durability barrier on emulated external storage, and the
|
||||||
|
desktop publishes each complete MTP generation before its correlated
|
||||||
|
reference. Two real post-migration bidirectional exchanges, separated by a
|
||||||
|
Trainlog-service restart, completed with durable ACKs and no session
|
||||||
|
duplication. The expected AI proposal retained its identity, six entries,
|
||||||
|
and `pending` state on Android and remained independently visible in Web.
|
||||||
|
|
||||||
|
- Fixed a production full-generation timeout caused by recursively uploading
|
||||||
|
retained staging and unrelated transport history on every MTP push. Each
|
||||||
|
phase now publishes a private bounded outbox containing only its request,
|
||||||
|
ACK, and current referenced generation, while preserving every retained
|
||||||
|
generation, tombstone, and causal proof. Transport expiry has a stable
|
||||||
|
browser-facing code and action instead of a truncated traceback.
|
||||||
|
The reciprocal pull now reads only current coordination objects and the
|
||||||
|
exactly referenced Android generation instead of recursively mirroring
|
||||||
|
retained Android and desktop history.
|
||||||
|
|
||||||
|
- Replaced the Web Next Session placeholder with a separate read-only Core
|
||||||
|
projection that distinguishes AI proposals from active/pending execution
|
||||||
|
drafts without redefining the frozen Dashboard field or counting targets as
|
||||||
|
performed work. The browser preserves the last successful durable view
|
||||||
|
across refresh failures and uses one abortable, per-run ordered sync poller.
|
||||||
|
|
||||||
- Completed the authorized private full-generation rollout over production
|
- Completed the authorized private full-generation rollout over production
|
||||||
direct MTP: verified Android and desktop backups, signing-preserving Android
|
direct MTP: verified Android and desktop backups, signing-preserving Android
|
||||||
updates, schema migrations, persistent peer pairing, bidirectional durable
|
updates, schema migrations, persistent peer pairing, bidirectional durable
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,58 @@ internal sealed interface ForegroundGenerationResult {
|
||||||
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
|
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
|
||||||
private val service = SyncGenerationService(repository)
|
private val service = SyncGenerationService(repository)
|
||||||
|
|
||||||
|
private fun requireExactKeys(value: JSONObject, expected: Set<String>, label: String) {
|
||||||
|
val actual = value.keys().asSequence().toSet()
|
||||||
|
if (actual != expected) throw SyncGenerationException("invalid $label fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconcileArchiveAcknowledgements(
|
||||||
|
raw: ByteArray,
|
||||||
|
runId: String,
|
||||||
|
androidPeerId: String,
|
||||||
|
desktopPeerId: String,
|
||||||
|
) {
|
||||||
|
/* WHY: releases before archive-v1 advanced generation status without
|
||||||
|
* retaining the consumer's ACK locally. The desktop consumer may
|
||||||
|
* return its exact durable ACK so the Android producer can repair
|
||||||
|
* that evidence gap without fabricating or weakening proof.
|
||||||
|
* CONTRACT: the envelope is strictly correlated and contains at most
|
||||||
|
* 32 ACKs; acceptAcknowledgement revalidates every ACK against the
|
||||||
|
* immutable local manifest before any archive can become eligible.
|
||||||
|
* INVARIANT: a missing, rejected, or ambiguous ACK leaves its
|
||||||
|
* generation active and protected. */
|
||||||
|
val envelope = JSONObject(raw.toString(Charsets.UTF_8))
|
||||||
|
requireExactKeys(
|
||||||
|
envelope,
|
||||||
|
setOf(
|
||||||
|
"format",
|
||||||
|
"version",
|
||||||
|
"run_id",
|
||||||
|
"android_peer_id",
|
||||||
|
"desktop_peer_id",
|
||||||
|
"acknowledgements",
|
||||||
|
),
|
||||||
|
"archive acknowledgement envelope",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
envelope.getString("format") != "trainlog-sync-archive-acknowledgements" ||
|
||||||
|
envelope.getInt("version") != 1 ||
|
||||||
|
envelope.getString("run_id") != runId ||
|
||||||
|
envelope.getString("android_peer_id") != androidPeerId ||
|
||||||
|
envelope.getString("desktop_peer_id") != desktopPeerId
|
||||||
|
) {
|
||||||
|
throw SyncGenerationException("archive acknowledgement envelope is not correlated")
|
||||||
|
}
|
||||||
|
val acknowledgements = envelope.getJSONArray("acknowledgements")
|
||||||
|
if (acknowledgements.length() > 32)
|
||||||
|
throw SyncGenerationException("too many archive acknowledgements")
|
||||||
|
for (index in 0 until acknowledgements.length()) {
|
||||||
|
service.acceptAcknowledgement(
|
||||||
|
acknowledgements.getJSONObject(index).toString().toByteArray(Charsets.UTF_8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun publish(path: File, value: String) {
|
private fun publish(path: File, value: String) {
|
||||||
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
|
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
|
||||||
FileOutputStream(temporary).use { stream ->
|
FileOutputStream(temporary).use { stream ->
|
||||||
|
|
@ -81,15 +133,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 +206,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,9 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.nio.file.Files
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -160,6 +162,111 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
* callers cannot supply or replace it. */
|
* callers cannot supply or replace it. */
|
||||||
internal fun peerId(): String = stablePeerId("android")
|
internal fun peerId(): String = stablePeerId("android")
|
||||||
|
|
||||||
|
private fun archiveDigest(directory: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
directory.walkTopDown().filter { it.isFile }.sortedBy { it.relativeTo(directory).path }
|
||||||
|
.forEach { file ->
|
||||||
|
if (java.nio.file.Files.isSymbolicLink(file.toPath()))
|
||||||
|
throw SyncGenerationException("archive contains symbolic link")
|
||||||
|
val name = file.relativeTo(directory).invariantSeparatorsPath.toByteArray()
|
||||||
|
digest.update(ByteBuffer.allocate(4).putInt(name.size).array())
|
||||||
|
digest.update(name)
|
||||||
|
digest.update(MessageDigest.getInstance("SHA-256").digest(file.readBytes()))
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun archiveAcknowledged(ownedRoot: File, consumerPeerId: String): Int {
|
||||||
|
/* WHY: acknowledged payloads must not occupy active admission forever.
|
||||||
|
* CONTRACT: an exact consumed ACK is required and the newest two
|
||||||
|
* acknowledged lineage members remain active for recovery.
|
||||||
|
* INVARIANT: generation, artifact, ACK and causal rows remain intact;
|
||||||
|
* the ledger is committed only after a verified atomic archive copy. */
|
||||||
|
val rows = repository.inSyncGenerationTransaction { db ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT g.generation_id,g.run_id,g.producer_peer_id,g.consumer_peer_id," +
|
||||||
|
"g.manifest_sha256,g.staging_path,g.manifest_json FROM sync_generations g " +
|
||||||
|
"WHERE g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(" +
|
||||||
|
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id) " +
|
||||||
|
"ORDER BY g.generated_at DESC,g.generation_id DESC",
|
||||||
|
arrayOf(consumerPeerId),
|
||||||
|
).use { cursor ->
|
||||||
|
buildList {
|
||||||
|
while (cursor.moveToNext()) add(List(7) { cursor.getString(it) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val root = File(File(ownedRoot, "archives"), "generations")
|
||||||
|
if (!root.exists() && !root.mkdirs())
|
||||||
|
throw SyncGenerationException("cannot create generation archive")
|
||||||
|
var count = 0
|
||||||
|
rows.drop(2).forEach { row ->
|
||||||
|
val eligible = repository.inSyncGenerationTransaction { db ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT ack_id FROM sync_acknowledgements WHERE generation_id=? AND run_id=? " +
|
||||||
|
"AND producer_peer_id=? AND consumer_peer_id=? AND manifest_sha256=? " +
|
||||||
|
"AND result='consumed' AND durability='sqlite-commit-full' LIMIT 2",
|
||||||
|
arrayOf(row[0], row[1], row[2], row[3], row[4]),
|
||||||
|
).use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) null
|
||||||
|
else cursor.getString(0).also {
|
||||||
|
if (cursor.moveToNext()) throw SyncGenerationException("ambiguous ACK evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: return@forEach
|
||||||
|
val source = File(row[5])
|
||||||
|
if (Files.isSymbolicLink(source.toPath()))
|
||||||
|
throw SyncGenerationException("generation staging is a symbolic link")
|
||||||
|
validatePublished(source, row[3])
|
||||||
|
val destination = File(root, row[0])
|
||||||
|
val temporary = File(root, ".${row[0]}.tmp")
|
||||||
|
if (Files.isSymbolicLink(destination.toPath()))
|
||||||
|
throw SyncGenerationException("generation archive is a symbolic link")
|
||||||
|
if (!destination.exists()) {
|
||||||
|
if (Files.isSymbolicLink(temporary.toPath()))
|
||||||
|
throw SyncGenerationException("temporary generation archive is a symbolic link")
|
||||||
|
if (temporary.exists() && !temporary.deleteRecursively())
|
||||||
|
throw SyncGenerationException("cannot reset interrupted generation archive")
|
||||||
|
if (!source.copyRecursively(temporary, overwrite = false))
|
||||||
|
throw SyncGenerationException("cannot copy generation archive")
|
||||||
|
validatePublished(temporary, row[3])
|
||||||
|
temporary.walkTopDown().filter { it.isFile }.forEach { file ->
|
||||||
|
java.io.FileOutputStream(file, true).use { it.fd.sync() }
|
||||||
|
}
|
||||||
|
if (!temporary.renameTo(destination))
|
||||||
|
throw SyncGenerationException("cannot commit generation archive")
|
||||||
|
/* Android emulated external storage rejects directory fsync.
|
||||||
|
* Reopen and sync the commit marker after the atomic rename so
|
||||||
|
* the supported FUSE durability barrier covers the committed
|
||||||
|
* namespace before the internal SQLite ledger can advance. */
|
||||||
|
java.io.FileOutputStream(File(destination, "manifest.json"), true).use {
|
||||||
|
it.fd.sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validatePublished(destination, row[3])
|
||||||
|
val checksum = archiveDigest(destination)
|
||||||
|
val audit =
|
||||||
|
canonical(
|
||||||
|
JSONObject()
|
||||||
|
.put("format", "trainlog-sync-generation-archive-audit")
|
||||||
|
.put("version", 1)
|
||||||
|
.put("generation_id", row[0])
|
||||||
|
.put("run_id", row[1])
|
||||||
|
.put("manifest_sha256", row[4])
|
||||||
|
.put("ack_id", eligible)
|
||||||
|
.put("archive_sha256", checksum),
|
||||||
|
)
|
||||||
|
repository.inSyncGenerationTransaction { db ->
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT OR IGNORE INTO sync_generation_archives VALUES(?,?,?,?,?,?)",
|
||||||
|
arrayOf(row[0], destination.absolutePath, row[4], checksum, OffsetDateTime.now().toString(), audit),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
fun capture(
|
fun capture(
|
||||||
ownedRoot: File,
|
ownedRoot: File,
|
||||||
consumerPeerId: String,
|
consumerPeerId: String,
|
||||||
|
|
@ -169,10 +276,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 +977,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,18 +87,9 @@ 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 executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
val desktop = File(transport, "desktop.sqlite")
|
||||||
val result =
|
|
||||||
executor.submit<ForegroundGenerationResult> {
|
|
||||||
coordinator.run(transport, Duration.ofSeconds(30)) {
|
|
||||||
release?.let { awaitFile(File(it)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
|
|
||||||
if (suppliedTransport == null) {
|
if (suppliedTransport == null) {
|
||||||
val desktop = File(transport, "desktop.sqlite")
|
|
||||||
val fixture =
|
val fixture =
|
||||||
ProcessBuilder(
|
ProcessBuilder(
|
||||||
File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||||
|
|
@ -106,7 +98,20 @@ class SyncDeploymentConversationTest {
|
||||||
.directory(repositoryRoot)
|
.directory(repositoryRoot)
|
||||||
.start()
|
.start()
|
||||||
assertEquals(0, fixture.waitFor())
|
assertEquals(0, fixture.waitFor())
|
||||||
localWorker =
|
}
|
||||||
|
val conversations = if (suppliedTransport == null) 24 else 1
|
||||||
|
repeat(conversations) {
|
||||||
|
val coordinator = SyncGenerationForegroundCoordinator(repository)
|
||||||
|
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||||
|
val result =
|
||||||
|
executor.submit<ForegroundGenerationResult> {
|
||||||
|
coordinator.run(transport, Duration.ofSeconds(30)) {
|
||||||
|
release?.let { awaitFile(File(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
|
||||||
|
if (suppliedTransport == null) {
|
||||||
|
localWorker =
|
||||||
ProcessBuilder(
|
ProcessBuilder(
|
||||||
"python3",
|
"python3",
|
||||||
File(repositoryRoot, "tools/sync_peer_worker.py").absolutePath,
|
File(repositoryRoot, "tools/sync_peer_worker.py").absolutePath,
|
||||||
|
|
@ -126,19 +131,56 @@ class SyncDeploymentConversationTest {
|
||||||
.directory(repositoryRoot)
|
.directory(repositoryRoot)
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start()
|
.start()
|
||||||
|
}
|
||||||
|
val completed = result.get(35, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
executor.shutdownNow()
|
||||||
|
assertTrue(
|
||||||
|
"conversation ${it + 1}: $completed",
|
||||||
|
completed is ForegroundGenerationResult.Completed,
|
||||||
|
)
|
||||||
|
var workerOutput = ""
|
||||||
|
localWorker?.let { worker ->
|
||||||
|
workerOutput = worker.inputStream.bufferedReader().readText()
|
||||||
|
assertEquals("worker failed:\n$workerOutput", 0, worker.waitFor())
|
||||||
|
assertTrue(workerOutput.contains("\"result\":\"completed\""))
|
||||||
|
}
|
||||||
|
if (suppliedTransport == null && it == 7) {
|
||||||
|
// Reproduce the deployed pre-archive state: those builds
|
||||||
|
// advanced producer status but did not retain received
|
||||||
|
// ACK rows. The next real worker conversation must return
|
||||||
|
// the desktop consumer's exact evidence and unblock the
|
||||||
|
// full active quota without deleting any generation.
|
||||||
|
repository.inSyncGenerationTransaction { database ->
|
||||||
|
database.delete("sync_acknowledgements", null, null)
|
||||||
|
database.rawQuery(
|
||||||
|
"SELECT COUNT(*) FROM sync_generations WHERE status='acknowledged'",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(8, cursor.getInt(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localWorker = null
|
||||||
}
|
}
|
||||||
assertTrue(
|
|
||||||
result.get(35, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
is ForegroundGenerationResult.Completed
|
|
||||||
)
|
|
||||||
executor.shutdownNow()
|
|
||||||
repository.close()
|
repository.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` |
|
||||||
|
|
@ -219,6 +219,26 @@ persistence with optimistic conflict and CSRF/Origin protection. It provides no
|
||||||
business mutation or automatic browser launch. Analyse, Programmes, Sessions,
|
business mutation or automatic browser launch. Analyse, Programmes, Sessions,
|
||||||
and Exercises remain explicit placeholders.
|
and Exercises remain explicit placeholders.
|
||||||
|
|
||||||
|
The Next Session tile no longer treats the frozen Dashboard
|
||||||
|
`next_session.available` placeholder as a durable inventory. A separate bounded
|
||||||
|
`GET /api/v1/prepared-items` Core projection distinguishes imported AI
|
||||||
|
proposals from active or pending execution drafts and exposes stable identity,
|
||||||
|
state, title/date, occurrence count, and provenance. Reload and later sync
|
||||||
|
failure do not erase the last successfully read projection. Proposal targets
|
||||||
|
remain planning data and never alter Dashboard actual-work metrics.
|
||||||
|
|
||||||
|
Full-generation MTP publication now uses a private phase-owned outbox. The
|
||||||
|
request, desktop consumption ACK, and current desktop generation are published
|
||||||
|
only when their phase requires them; retained staging, inbound objects, legacy
|
||||||
|
files, prior ACKs, and causal evidence are not recursively re-uploaded. Adapter
|
||||||
|
timeouts are persisted as the stable `transport_timeout` status instead of a
|
||||||
|
truncated Python traceback. The browser polls through one abortable scheduler,
|
||||||
|
orders revisions per `run_id`, and rejects stale responses from older runs.
|
||||||
|
The reciprocal MTP read is bounded to the Android peer advertisement, current
|
||||||
|
generation reference and consumption ACK, plus the single Android generation
|
||||||
|
named by that reference. Historical generations and other retained evidence
|
||||||
|
are neither traversed nor deleted during polling.
|
||||||
|
|
||||||
## Data semantics
|
## Data semantics
|
||||||
|
|
||||||
- `exercise_id` identifies a catalogue exercise; `entry_id` identifies one
|
- `exercise_id` identifies a catalogue exercise; `entry_id` identifies one
|
||||||
|
|
@ -321,9 +341,11 @@ Android resource-parity, language-owner, typed sync-presentation, and
|
||||||
stable-data presentation tests. A real-device/manual visual language-switch
|
stable-data presentation tests. A real-device/manual visual language-switch
|
||||||
smoke remains a manual validation; it does not change the validated status.
|
smoke remains a manual validation; it does not change the validated status.
|
||||||
|
|
||||||
Hardware-dependent MTP and the final real Drive plus Android-triggered AI-draft
|
Hardware-dependent MTP is not automated. The controlled real-device rollout
|
||||||
smoke test are not automated. The latter is why
|
validated two consecutive full-generation exchanges around a Trainlog-service
|
||||||
`TRAINLOG_AI_SESSION_DRAFT_V1` remains `VALIDATION_PENDING`.
|
restart. The expected AI proposal was verified in the persistent Android store
|
||||||
|
and the read-only Web proposal projection with its original identity and
|
||||||
|
content; no proposal-to-execution-draft conversion was performed.
|
||||||
|
|
||||||
## Active limitations
|
## Active limitations
|
||||||
|
|
||||||
|
|
@ -331,16 +353,18 @@ smoke test are not automated. The latter is why
|
||||||
desktop producers/consumers for `trainlog-causal-deletions` V1. All contracted
|
desktop producers/consumers for `trainlog-causal-deletions` V1. All contracted
|
||||||
domains are durably protected; exact replay is idempotent and unprovable or
|
domains are durably protected; exact replay is idempotent and unprovable or
|
||||||
concurrent ancestry conflicts before mutation. Protected legacy snapshots are
|
concurrent ancestry conflicts before mutation. Protected legacy snapshots are
|
||||||
refused before resurrection. Staged generation acknowledgement is implemented,
|
refused before resurrection. The default automatic daemon path remains V3,
|
||||||
but active synchronization still selects V3: no staged-service activation,
|
while the trusted local Web opt-in now runs the deployed full-generation path.
|
||||||
deployment, or user-data migration occurred.
|
Its acknowledged generations can leave the active admission window only
|
||||||
|
through the verified schema-v22/schema-v21 archive ledger; pending, rejected
|
||||||
|
and ambiguous generations remain protected.
|
||||||
|
|
||||||
The focused causal closeout additionally proves child-data delete/update
|
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.
|
default automatic V3 selection.
|
||||||
|
|
||||||
The residual causal evidence also proves legitimate Android and desktop
|
The residual causal evidence also proves legitimate Android and desktop
|
||||||
producer directions separately for exercise retirement, populated body
|
producer directions separately for exercise retirement, populated body
|
||||||
|
|
@ -358,8 +382,6 @@ pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
||||||
remain shell placeholders until their Core/API ownership and implementation
|
remain shell placeholders until their Core/API ownership and implementation
|
||||||
order are explicitly contracted.
|
order are explicitly contracted.
|
||||||
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
|
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
|
||||||
- AI proposal exchange still awaits one real Drive plus Android-triggered
|
|
||||||
bidirectional smoke test.
|
|
||||||
- Session Generator V1 is hidden while V2 planning semantics are developed.
|
- Session Generator V1 is hidden while V2 planning semantics are developed.
|
||||||
- Hardware MTP validation requires a connected unlocked Android device.
|
- Hardware MTP validation requires a connected unlocked Android device.
|
||||||
- Scientific knowledge is bounded to reviewed catalog entries; unknown custom
|
- Scientific knowledge is bounded to reviewed catalog entries; unknown custom
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -34,6 +34,9 @@ CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
|
||||||
contract, frozen interactive grid, versioned layout persistence and factual
|
contract, frozen interactive grid, versioned layout persistence and factual
|
||||||
tile rendering exist. The Dashboard's bounded progression and BODY ZONES
|
tile rendering exist. The Dashboard's bounded progression and BODY ZONES
|
||||||
visualizations are implemented; the other four Web routes remain placeholders.
|
visualizations are implemented; the other four Web routes remain placeholders.
|
||||||
|
The corrective prepared-item projection and bounded phase-owned MTP outbox are
|
||||||
|
implemented without opening the Sessions route, changing schemas, or changing
|
||||||
|
the frozen Dashboard and exchange contracts.
|
||||||
|
|
||||||
## Current cursor
|
## Current cursor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -798,6 +822,31 @@ truncation, and publishes generation manifests last. Production behavior is
|
||||||
tested by replacing only the typed libmtp/udev I/O callbacks with a filesystem
|
tested by replacing only the typed libmtp/udev I/O callbacks with a filesystem
|
||||||
object double; the same compiled transport algorithm remains under test.
|
object double; the same compiled transport algorithm remains under test.
|
||||||
|
|
||||||
|
Each worker push is additionally scoped by a private disposable outbox. The
|
||||||
|
first push contains only the correlated request; the post-import push adds the
|
||||||
|
desktop consumption ACK. Publication uses two causally ordered bounded pushes:
|
||||||
|
the immutable current desktop generation directory is committed first, with
|
||||||
|
its manifest last, and only then is its run-correlated reference made visible.
|
||||||
|
Android therefore cannot consume a reference to an in-progress MTP directory. The
|
||||||
|
durable transport root remains the owner of retained generations, staging,
|
||||||
|
legacy compatibility artifacts, and evidence, but those unrelated objects are
|
||||||
|
not recursively sent on every phase. Constructing an outbox does not delete or
|
||||||
|
rewrite any source object. A bounded adapter timeout is an ambiguous transport
|
||||||
|
failure with stable code `transport_timeout`; it is not evidence of rollback
|
||||||
|
and does not authorize automatic mutation replay.
|
||||||
|
|
||||||
|
MTP polling follows the same bounded ownership rule in the opposite direction.
|
||||||
|
It reads the peer advertisement, current Android generation reference and
|
||||||
|
current Android consumption ACK, then downloads only the generation directory
|
||||||
|
named by that validated reference. It does not recursively mirror retained
|
||||||
|
Android generations, desktop publications, or unrelated exchange history.
|
||||||
|
|
||||||
|
`GET /api/v1/prepared-items` is a separate read-only Web projection. It does not
|
||||||
|
change the frozen Dashboard `next_session.available` field or any exchange
|
||||||
|
format. It lists bounded durable desktop AI proposals and relevant execution
|
||||||
|
drafts as distinct kinds. Completed sessions remain outside this projection,
|
||||||
|
and finalized or causally deleted execution drafts are excluded.
|
||||||
|
|
||||||
## 19. Legacy active-protocol limitations
|
## 19. Legacy active-protocol limitations
|
||||||
|
|
||||||
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
|
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,21 @@ through the C adapter, observes the desktop commit at an Android test barrier,
|
||||||
then releases the real ACK. This proves the directory transport double, not
|
then releases the real ACK. This proves the directory transport double, not
|
||||||
physical MTP. Orchestrator tests cover bounded streams and descendant cleanup;
|
physical MTP. Orchestrator tests cover bounded streams and descendant cleanup;
|
||||||
deployment-tool tests cover consistent SQLite backup and candidate inventory.
|
deployment-tool tests cover consistent SQLite backup and candidate inventory.
|
||||||
|
The incident regressions also prove that a phase-owned MTP outbox excludes
|
||||||
|
retained staging and unrelated legacy files, adapter expiry produces the stable
|
||||||
|
`transport_timeout` code, and operational failure classes remain distinct.
|
||||||
|
The C MTP regression keeps a malformed retained generation beside the current
|
||||||
|
reference and proves that pull downloads only the referenced generation while
|
||||||
|
still rejecting a truncated object inside that current generation.
|
||||||
|
|
||||||
|
`web_prepared_items` exercises the production SQLite projection and JSON
|
||||||
|
serializer with a proposal and an execution draft, then proves finalized and
|
||||||
|
causally deleted execution drafts are absent. The HTTP test covers the
|
||||||
|
read-only route and method rejection. Vitest distinguishes proposal, active
|
||||||
|
draft, loading, empty and failed-refresh states; it retains an already rendered
|
||||||
|
durable value across a reread failure. Sync-control tests cover a single
|
||||||
|
abortable polling scheduler, per-run revisions, rejection of an older run's
|
||||||
|
late terminal response, stable error/action rendering, and cleanup on unmount.
|
||||||
|
|
||||||
The `ai_history_export` test covers an empty database, a simple session,
|
The `ai_history_export` test covers an empty database, a simple session,
|
||||||
distinct multi-occurrences of one exercise, ordered sets, exact occurrence
|
distinct multi-occurrences of one exercise, ordered sets, exact occurrence
|
||||||
|
|
@ -1308,11 +1323,38 @@ Android generation service, desktop consumers and durable ACK path remain real.
|
||||||
backup including an active draft, raw partial input, stable peer identity and
|
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.
|
||||||
|
|
||||||
|
The controlled Samsung SM-G990B rollout migrated the authentic Android backup
|
||||||
|
from schema v20 to v21 and an authentic desktop copy from v21 to v22 before
|
||||||
|
installation. The installed release retained its existing certificate and
|
||||||
|
advanced from versionCode 6 through 7 to 8 without uninstalling or clearing
|
||||||
|
data. Real MTP validation preserved the failed-run evidence that exposed an
|
||||||
|
Android external-storage directory-fsync incompatibility and an early desktop
|
||||||
|
generation reference. After their bounded repairs, run
|
||||||
|
`sy_06200dd7-0d51-4f33-b80d-9976822ba87d` completed, the two Trainlog services
|
||||||
|
were restarted, and run `sy_783014b0-f3f0-4f1e-8f4f-723acfc02dbc` completed.
|
||||||
|
Both exchanged distinct generations and durable ACKs with zero session
|
||||||
|
reconciliation. The final Android backup passed integrity and foreign-key
|
||||||
|
checks at schema v21 with 13 generations, five verified archives, 13 ACKs,
|
||||||
|
five consumed generations, and six active generations. The expected AI
|
||||||
|
proposal remained `pending` with its original identity and six entries.
|
||||||
|
|
||||||
## Readability validation
|
## Readability validation
|
||||||
|
|
||||||
The normalized C scope is checked non-mutatively with the explicit
|
The normalized C scope is checked non-mutatively with the explicit
|
||||||
|
|
|
||||||
124
tests/test_generation_archival.py
Normal file
124
tests/test_generation_archival.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Failure-boundary tests for the production generation archive ledger."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
import sync_generation_exchange as generation # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURE = ROOT / "build" / "tui" / "sync-generation-fixture"
|
||||||
|
PEER = "peer_11111111-1111-4111-8111-111111111111"
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationArchiveTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory(prefix="trainlog-archive-test-")
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.database = self.root / "desktop.sqlite"
|
||||||
|
subprocess.run([str(FIXTURE), str(self.database)], check=True)
|
||||||
|
self.owned = self.root / "owned"
|
||||||
|
self.objects = self.root / "objects"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def acknowledge(self, generation_id: str, manifest: dict, digest: str) -> Path:
|
||||||
|
generation.publish(self.database, generation_id, self.objects)
|
||||||
|
with sqlite3.connect(self.database) as database:
|
||||||
|
producer = database.execute(
|
||||||
|
"SELECT producer_peer_id FROM sync_generations WHERE generation_id=?",
|
||||||
|
(generation_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
ack = generation.ack_document(
|
||||||
|
manifest["run_id"], generation_id, producer, PEER, digest,
|
||||||
|
"consumed", generation.now(), "",
|
||||||
|
)
|
||||||
|
path = self.root / f"{generation_id}.ack.json"
|
||||||
|
path.write_bytes(generation.canonical(ack))
|
||||||
|
self.assertEqual("acknowledged", generation.accept_ack(self.database, path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
def create_acknowledged(self):
|
||||||
|
values = []
|
||||||
|
for _ in range(3):
|
||||||
|
stage, manifest, digest = generation.capture_desktop(
|
||||||
|
self.database, self.owned, PEER
|
||||||
|
)
|
||||||
|
del stage
|
||||||
|
ack = self.acknowledge(manifest["generation_id"], manifest, digest)
|
||||||
|
values.append((manifest, ack))
|
||||||
|
return values
|
||||||
|
|
||||||
|
def test_interrupted_archive_resumes_and_replay_stays_idempotent(self):
|
||||||
|
values = self.create_acknowledged()
|
||||||
|
oldest = values[0][0]["generation_id"]
|
||||||
|
temporary = self.owned / "archives" / "generations" / f".{oldest}.tmp"
|
||||||
|
temporary.mkdir(parents=True)
|
||||||
|
(temporary / "interrupted").write_text("partial")
|
||||||
|
self.assertEqual(1, generation.archive_acknowledged(self.database, self.owned, PEER))
|
||||||
|
self.assertFalse(temporary.exists())
|
||||||
|
self.assertEqual("unchanged", generation.accept_ack(self.database, values[0][1]))
|
||||||
|
with sqlite3.connect(self.database) as database:
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
database.execute(
|
||||||
|
"SELECT COUNT(*) FROM sync_generation_archives WHERE generation_id=?",
|
||||||
|
(oldest,),
|
||||||
|
).fetchone()[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_archive_and_no_space_never_commit_ledger(self):
|
||||||
|
values = self.create_acknowledged()
|
||||||
|
oldest = values[0][0]["generation_id"]
|
||||||
|
destination = self.owned / "archives" / "generations" / oldest
|
||||||
|
destination.mkdir(parents=True)
|
||||||
|
(destination / "manifest.json").write_text("{}")
|
||||||
|
with self.assertRaises(generation.GenerationError):
|
||||||
|
generation.archive_acknowledged(self.database, self.owned, PEER)
|
||||||
|
with sqlite3.connect(self.database) as database:
|
||||||
|
self.assertEqual(0, database.execute("SELECT COUNT(*) FROM sync_generation_archives").fetchone()[0])
|
||||||
|
destination.unlink(missing_ok=True) if destination.is_file() else None
|
||||||
|
for child in destination.iterdir():
|
||||||
|
child.unlink()
|
||||||
|
destination.rmdir()
|
||||||
|
with mock.patch.object(generation.shutil, "copytree", side_effect=OSError(28, "no space")):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
generation.archive_acknowledged(self.database, self.owned, PEER)
|
||||||
|
with sqlite3.connect(self.database) as database:
|
||||||
|
self.assertEqual(0, database.execute("SELECT COUNT(*) FROM sync_generation_archives").fetchone()[0])
|
||||||
|
|
||||||
|
def test_missing_then_late_ack_is_protected_until_correlated(self):
|
||||||
|
values = self.create_acknowledged()
|
||||||
|
stage, manifest, digest = generation.capture_desktop(self.database, self.owned, PEER)
|
||||||
|
del stage
|
||||||
|
generation.publish(self.database, manifest["generation_id"], self.objects)
|
||||||
|
generation.archive_acknowledged(self.database, self.owned, PEER)
|
||||||
|
with sqlite3.connect(self.database) as database:
|
||||||
|
self.assertEqual(
|
||||||
|
"waiting_acknowledgement",
|
||||||
|
database.execute(
|
||||||
|
"SELECT status FROM sync_generations WHERE generation_id=?",
|
||||||
|
(manifest["generation_id"],),
|
||||||
|
).fetchone()[0],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
database.execute(
|
||||||
|
"SELECT COUNT(*) FROM sync_generation_archives WHERE generation_id=?",
|
||||||
|
(manifest["generation_id"],),
|
||||||
|
).fetchone()[0],
|
||||||
|
)
|
||||||
|
self.acknowledge(manifest["generation_id"], manifest, digest)
|
||||||
|
self.assertGreaterEqual(generation.archive_acknowledged(self.database, self.owned, PEER), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
119
tests/test_sync_error_classification.py
Normal file
119
tests/test_sync_error_classification.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
|
||||||
|
import sync_orchestrator # noqa: E402
|
||||||
|
import sync_peer_worker # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class SyncErrorClassificationTest(unittest.TestCase):
|
||||||
|
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
|
||||||
|
timeout = subprocess.TimeoutExpired(["adapter", "push"], 30)
|
||||||
|
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RuntimeError,
|
||||||
|
r"^transport_timeout: MTP push did not finish within 30 seconds$",
|
||||||
|
) as caught:
|
||||||
|
sync_peer_worker.run_adapter(
|
||||||
|
Path("/adapter"),
|
||||||
|
"push",
|
||||||
|
"peer_00000000-0000-4000-8000-000000000001",
|
||||||
|
Path("/transport"),
|
||||||
|
sync_peer_worker.time.monotonic() + 60,
|
||||||
|
)
|
||||||
|
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")
|
||||||
|
|
||||||
|
def test_known_operational_failures_remain_distinct(self):
|
||||||
|
self.assertEqual(
|
||||||
|
sync_orchestrator.failure_code(RuntimeError("expected Android peer not found")),
|
||||||
|
"device_unavailable",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sync_orchestrator.failure_code(RuntimeError("pending capacity exhausted")),
|
||||||
|
"capacity_exhausted",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sync_orchestrator.failure_code(RuntimeError("revision conflict")),
|
||||||
|
"data_conflict",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory) / "transport"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "request-v1.json").write_text("{}")
|
||||||
|
(root / "staging").mkdir()
|
||||||
|
(root / "staging" / "retained.json").write_text("retained")
|
||||||
|
(root / "legacy.json").write_text("legacy")
|
||||||
|
observed = []
|
||||||
|
|
||||||
|
def inspect(command, **_kwargs):
|
||||||
|
outbox = Path(command[3])
|
||||||
|
observed.extend(
|
||||||
|
str(path.relative_to(outbox))
|
||||||
|
for path in outbox.rglob("*")
|
||||||
|
if path.is_file()
|
||||||
|
)
|
||||||
|
return subprocess.CompletedProcess(command, 0, b"", b"")
|
||||||
|
|
||||||
|
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=inspect):
|
||||||
|
sync_peer_worker.push_adapter(
|
||||||
|
Path("/adapter"),
|
||||||
|
"peer_00000000-0000-4000-8000-000000000001",
|
||||||
|
root,
|
||||||
|
["request-v1.json"],
|
||||||
|
sync_peer_worker.time.monotonic() + 60,
|
||||||
|
)
|
||||||
|
self.assertEqual(observed, ["request-v1.json"])
|
||||||
|
|
||||||
|
def test_desktop_generation_directory_precedes_reference(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
generation = (
|
||||||
|
"desktop-objects/generations/"
|
||||||
|
"gen_11111111-1111-4111-8111-111111111111"
|
||||||
|
)
|
||||||
|
(root / generation).mkdir(parents=True)
|
||||||
|
(root / generation / "manifest.json").write_text("manifest")
|
||||||
|
for name in (
|
||||||
|
"request-v1.json",
|
||||||
|
"desktop-archive-acknowledgements-v1.json",
|
||||||
|
"desktop-consumption-ack-v1.json",
|
||||||
|
"desktop-generation-v1.json",
|
||||||
|
):
|
||||||
|
(root / name).write_text("{}")
|
||||||
|
observed = []
|
||||||
|
|
||||||
|
def inspect(_adapter, _operation, _peer, outbox, _deadline):
|
||||||
|
observed.append(
|
||||||
|
sorted(
|
||||||
|
str(path.relative_to(outbox))
|
||||||
|
for path in outbox.rglob("*")
|
||||||
|
if path.is_file()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
with mock.patch.object(sync_peer_worker, "run_adapter", side_effect=inspect):
|
||||||
|
sync_peer_worker.push_desktop_generation(
|
||||||
|
Path("adapter"),
|
||||||
|
"peer",
|
||||||
|
root,
|
||||||
|
generation,
|
||||||
|
sync_peer_worker.time.monotonic() + 60,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(observed), 2)
|
||||||
|
self.assertEqual(observed[0], [generation + "/manifest.json"])
|
||||||
|
self.assertNotIn("desktop-generation-v1.json", observed[0])
|
||||||
|
self.assertIn("desktop-generation-v1.json", observed[1])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -21,8 +21,9 @@ CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,produce
|
||||||
CREATE TABLE sync_generation_artifacts(generation_id TEXT,logical_name TEXT,format TEXT,version INTEGER,filename TEXT,size_bytes INTEGER,sha256 TEXT,required INTEGER,PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename));
|
CREATE TABLE sync_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):
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,9 @@ class OrchestratorTest(unittest.TestCase):
|
||||||
values = self.fixture(root, **override)
|
values = self.fixture(root, **override)
|
||||||
result = self.invoke(ORCHESTRATOR, values)
|
result = self.invoke(ORCHESTRATOR, values)
|
||||||
self.assertEqual(result.returncode, 2)
|
self.assertEqual(result.returncode, 2)
|
||||||
self.assertEqual(json.loads(values[1].read_text())["phase"], "failed")
|
final = json.loads(values[1].read_text())
|
||||||
|
self.assertEqual(final["phase"], "failed")
|
||||||
|
self.assertEqual(final["error_code"], "internal_error")
|
||||||
|
|
||||||
def test_stdout_bound_is_enforced_before_complete_accumulation(self):
|
def test_stdout_bound_is_enforced_before_complete_accumulation(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|
|
||||||
|
|
@ -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=?",
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,22 @@ def update(state_path: Path, state: dict, phase: str, **fields: object) -> None:
|
||||||
atomic_write(state_path, state)
|
atomic_write(state_path, state)
|
||||||
|
|
||||||
|
|
||||||
|
def failure_code(error: BaseException) -> str:
|
||||||
|
"""Map bounded operational evidence to one stable browser-facing code."""
|
||||||
|
diagnostic = str(error).lower()
|
||||||
|
if "transport_timeout:" in diagnostic or "peer worker timed out" in diagnostic:
|
||||||
|
return "transport_timeout"
|
||||||
|
if "peer_capacity_exhausted:" in diagnostic:
|
||||||
|
return "peer_capacity_exhausted"
|
||||||
|
if "expected android peer not found" in diagnostic:
|
||||||
|
return "device_unavailable"
|
||||||
|
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
|
||||||
|
return "capacity_exhausted"
|
||||||
|
if "conflict" in diagnostic or "wrong consumer" in diagnostic:
|
||||||
|
return "data_conflict"
|
||||||
|
return "internal_error"
|
||||||
|
|
||||||
|
|
||||||
def validate_result(value: dict, run_id: str) -> dict:
|
def validate_result(value: dict, run_id: str) -> dict:
|
||||||
required = {
|
required = {
|
||||||
"format",
|
"format",
|
||||||
|
|
@ -303,6 +319,7 @@ def run(args: argparse.Namespace) -> int:
|
||||||
state,
|
state,
|
||||||
"failed",
|
"failed",
|
||||||
result="conflict",
|
result="conflict",
|
||||||
|
error_code="sync_in_progress",
|
||||||
diagnostic="Another synchronization owns the common lock",
|
diagnostic="Another synchronization owns the common lock",
|
||||||
finished_at=datetime.now(timezone.utc).isoformat(),
|
finished_at=datetime.now(timezone.utc).isoformat(),
|
||||||
)
|
)
|
||||||
|
|
@ -418,6 +435,7 @@ def main() -> int:
|
||||||
state,
|
state,
|
||||||
phase,
|
phase,
|
||||||
result=phase,
|
result=phase,
|
||||||
|
error_code=("interrupted" if phase == "interrupted" else failure_code(error)),
|
||||||
diagnostic=str(error)[:MAX_DIAGNOSTIC],
|
diagnostic=str(error)[:MAX_DIAGNOSTIC],
|
||||||
finished_at=datetime.now(timezone.utc).isoformat(),
|
finished_at=datetime.now(timezone.utc).isoformat(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,17 @@ always produced and consumed by the existing generation services.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import argparse, hashlib, json, os, sys, time, uuid
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from contextlib import closing, redirect_stdout
|
from contextlib import closing, redirect_stdout
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -20,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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,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)
|
||||||
|
|
@ -61,13 +75,21 @@ def run_adapter(
|
||||||
allow_missing_peer: bool = False,
|
allow_missing_peer: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
remaining = max(1, int(deadline - time.monotonic()))
|
remaining = max(1, int(deadline - time.monotonic()))
|
||||||
result = __import__("subprocess").run(
|
timeout = min(remaining, 30)
|
||||||
[str(adapter), operation, peer, str(root)],
|
try:
|
||||||
stdin=__import__("subprocess").DEVNULL,
|
result = subprocess.run(
|
||||||
capture_output=True,
|
[str(adapter), operation, peer, str(root)],
|
||||||
timeout=min(remaining, 30),
|
stdin=subprocess.DEVNULL,
|
||||||
check=False,
|
capture_output=True,
|
||||||
)
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
# CONTRACT: an adapter timeout is an ambiguous transport failure. It
|
||||||
|
# never implies rollback, replay, or permission to retry a mutation.
|
||||||
|
raise RuntimeError(
|
||||||
|
f"transport_timeout: MTP {operation} did not finish within {timeout} seconds"
|
||||||
|
) from error
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
diagnostic = (
|
diagnostic = (
|
||||||
result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed"
|
result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed"
|
||||||
|
|
@ -82,6 +104,70 @@ def run_adapter(
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def push_adapter(
|
||||||
|
adapter: Path,
|
||||||
|
peer: str,
|
||||||
|
root: Path,
|
||||||
|
relative_paths: list[str],
|
||||||
|
deadline: float,
|
||||||
|
) -> None:
|
||||||
|
"""Publish only the phase-owned bounded MTP objects.
|
||||||
|
|
||||||
|
WHY: the durable transport root also retains inbound objects, legacy files,
|
||||||
|
and staging evidence; recursively uploading that root makes each exchange
|
||||||
|
grow with history and can time out before Android sees the request.
|
||||||
|
CONTRACT: callers name fixed relative files/directories already durably
|
||||||
|
published below ``root``. The adapter receives a private disposable view.
|
||||||
|
INVARIANT: no retained generation, ACK, tombstone, or source object is
|
||||||
|
deleted or modified while constructing the view.
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory(prefix="trainlog-mtp-outbox-", dir=root.parent) as directory:
|
||||||
|
outbox = Path(directory)
|
||||||
|
os.chmod(outbox, 0o700)
|
||||||
|
for relative in relative_paths:
|
||||||
|
source = root / relative
|
||||||
|
destination = outbox / relative
|
||||||
|
if (
|
||||||
|
source.is_symlink()
|
||||||
|
or not source.exists()
|
||||||
|
or not source.resolve().is_relative_to(root.resolve())
|
||||||
|
):
|
||||||
|
raise RuntimeError(f"invalid MTP outbox source: {relative}")
|
||||||
|
if source.is_dir() and any(path.is_symlink() for path in source.rglob("*")):
|
||||||
|
raise RuntimeError(f"MTP outbox source contains a symbolic link: {relative}")
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
if source.is_dir():
|
||||||
|
shutil.copytree(source, destination, copy_function=shutil.copy2)
|
||||||
|
elif source.is_file():
|
||||||
|
shutil.copy2(source, destination)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"invalid MTP outbox object: {relative}")
|
||||||
|
run_adapter(adapter, "push", peer, outbox, deadline)
|
||||||
|
|
||||||
|
|
||||||
|
def push_desktop_generation(
|
||||||
|
adapter: Path,
|
||||||
|
peer: str,
|
||||||
|
root: Path,
|
||||||
|
relative_path: str,
|
||||||
|
deadline: float,
|
||||||
|
) -> None:
|
||||||
|
"""Commit immutable bytes before making their correlated reference visible."""
|
||||||
|
push_adapter(adapter, peer, root, [relative_path], deadline)
|
||||||
|
push_adapter(
|
||||||
|
adapter,
|
||||||
|
peer,
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"request-v1.json",
|
||||||
|
"desktop-archive-acknowledgements-v1.json",
|
||||||
|
"desktop-consumption-ack-v1.json",
|
||||||
|
"desktop-generation-v1.json",
|
||||||
|
],
|
||||||
|
deadline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def wait_file(path: Path, deadline: float, pump=None) -> None:
|
def wait_file(path: Path, deadline: float, pump=None) -> None:
|
||||||
while not path.is_file():
|
while not path.is_file():
|
||||||
if time.monotonic() >= deadline:
|
if time.monotonic() >= deadline:
|
||||||
|
|
@ -107,6 +193,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 (
|
||||||
|
|
@ -166,9 +273,29 @@ 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":
|
||||||
run_adapter(
|
push_adapter(
|
||||||
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
|
args.mtp_adapter,
|
||||||
|
args.expected_peer,
|
||||||
|
args.transport_root,
|
||||||
|
["request-v1.json", "desktop-archive-acknowledgements-v1.json"],
|
||||||
|
deadline,
|
||||||
)
|
)
|
||||||
emit(
|
emit(
|
||||||
args.run_id,
|
args.run_id,
|
||||||
|
|
@ -177,16 +304,25 @@ 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)
|
||||||
ack_path = args.transport_root / "desktop-consumption-ack-v1.json"
|
ack_path = args.transport_root / "desktop-consumption-ack-v1.json"
|
||||||
publish_json(ack_path, ack)
|
publish_json(ack_path, ack)
|
||||||
if args.mode == "mtp":
|
if args.mode == "mtp":
|
||||||
run_adapter(
|
push_adapter(
|
||||||
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
|
args.mtp_adapter,
|
||||||
|
args.expected_peer,
|
||||||
|
args.transport_root,
|
||||||
|
["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"],
|
||||||
|
deadline,
|
||||||
)
|
)
|
||||||
if ack["result"] != "consumed":
|
if ack["result"] != "consumed":
|
||||||
raise RuntimeError("desktop rejected Android generation")
|
raise RuntimeError("desktop rejected Android generation")
|
||||||
|
|
@ -218,8 +354,16 @@ def main() -> int:
|
||||||
}
|
}
|
||||||
publish_json(args.transport_root / "desktop-generation-v1.json", ref)
|
publish_json(args.transport_root / "desktop-generation-v1.json", ref)
|
||||||
if args.mode == "mtp":
|
if args.mode == "mtp":
|
||||||
run_adapter(
|
# CONTRACT: the immutable generation must be fully visible before its
|
||||||
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
|
# mutable run-correlated reference. Publishing both in one MTP walk
|
||||||
|
# lets Android observe the reference while artifacts are still being
|
||||||
|
# transferred, turning a complete generation into a false rejection.
|
||||||
|
push_desktop_generation(
|
||||||
|
args.mtp_adapter,
|
||||||
|
args.expected_peer,
|
||||||
|
args.transport_root,
|
||||||
|
ref["relative_path"],
|
||||||
|
deadline,
|
||||||
)
|
)
|
||||||
emit(
|
emit(
|
||||||
args.run_id,
|
args.run_id,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
49
tui/include/trainlog/web_prepared_items.h
Normal file
49
tui/include/trainlog/web_prepared_items.h
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/* Trainlog Web prepared-item V1 read model. */
|
||||||
|
#ifndef TRAINLOG_WEB_PREPARED_ITEMS_H
|
||||||
|
#define TRAINLOG_WEB_PREPARED_ITEMS_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
|
||||||
|
#define TRAINLOG_WEB_PREPARED_ITEM_CAPACITY 32U
|
||||||
|
#define TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY (64U * 1024U)
|
||||||
|
|
||||||
|
typedef enum TrainlogWebPreparedItemKind {
|
||||||
|
TRAINLOG_WEB_PREPARED_AI_PROPOSAL = 0,
|
||||||
|
TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT = 1
|
||||||
|
} TrainlogWebPreparedItemKind;
|
||||||
|
|
||||||
|
typedef struct TrainlogWebPreparedItem {
|
||||||
|
TrainlogWebPreparedItemKind kind;
|
||||||
|
char identity[TRAINLOG_ID_MAX + 1U];
|
||||||
|
char state[32];
|
||||||
|
char title[TRAINLOG_NAME_MAX + 1U];
|
||||||
|
char planned_for[11];
|
||||||
|
size_t occurrence_count;
|
||||||
|
char provenance[32];
|
||||||
|
} TrainlogWebPreparedItem;
|
||||||
|
|
||||||
|
typedef struct TrainlogWebPreparedItems {
|
||||||
|
char generated_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||||
|
TrainlogWebPreparedItem items[TRAINLOG_WEB_PREPARED_ITEM_CAPACITY];
|
||||||
|
size_t item_count;
|
||||||
|
bool partial;
|
||||||
|
} TrainlogWebPreparedItems;
|
||||||
|
|
||||||
|
/* WHY: sync status is one run's evidence, not a durable inventory.
|
||||||
|
* CONTRACT: database/output are required and borrowed for the call. The
|
||||||
|
* projection is bounded, deterministic, read-only, and keeps AI proposals
|
||||||
|
* separate from executable drafts and completed sessions.
|
||||||
|
* INVARIANT: targets never become performed occurrences through this API. */
|
||||||
|
TrainlogStatus trainlog_web_prepared_items_load(
|
||||||
|
TrainlogDatabase *database, TrainlogWebPreparedItems *output);
|
||||||
|
|
||||||
|
/* Caller owns output. Success guarantees valid NUL-terminated JSON. */
|
||||||
|
TrainlogStatus trainlog_web_prepared_items_serialize(
|
||||||
|
const TrainlogWebPreparedItems *items,
|
||||||
|
char *output,
|
||||||
|
size_t capacity,
|
||||||
|
size_t *output_size);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -192,6 +192,8 @@ trainlog_core_sources = files(
|
||||||
'src/json_writer.c',
|
'src/json_writer.c',
|
||||||
'src/web_dashboard.c',
|
'src/web_dashboard.c',
|
||||||
'src/web_dashboard_json.c',
|
'src/web_dashboard_json.c',
|
||||||
|
'src/web_prepared_items.c',
|
||||||
|
'src/web_prepared_items_json.c',
|
||||||
'src/duration.c',
|
'src/duration.c',
|
||||||
'src/id.c',
|
'src/id.c',
|
||||||
'src/timeutil.c',
|
'src/timeutil.c',
|
||||||
|
|
@ -335,6 +337,15 @@ test_web_dashboard = executable(
|
||||||
)
|
)
|
||||||
test('web_dashboard', test_web_dashboard)
|
test('web_dashboard', test_web_dashboard)
|
||||||
|
|
||||||
|
test_web_prepared_items = executable(
|
||||||
|
'test_web_prepared_items',
|
||||||
|
'tests/test_web_prepared_items.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
include_directories: trainlog_private_include,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
test('web_prepared_items', test_web_prepared_items)
|
||||||
|
|
||||||
test_dashboard_header = executable(
|
test_dashboard_header = executable(
|
||||||
'test_dashboard_header',
|
'test_dashboard_header',
|
||||||
'tests/test_dashboard_header.c',
|
'tests/test_dashboard_header.c',
|
||||||
|
|
@ -759,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'),
|
||||||
|
|
@ -769,6 +786,11 @@ test(
|
||||||
find_program('python3'),
|
find_program('python3'),
|
||||||
args: [meson.project_source_root() / 'tests/test_sync_deployment_tools.py'],
|
args: [meson.project_source_root() / 'tests/test_sync_deployment_tools.py'],
|
||||||
)
|
)
|
||||||
|
test(
|
||||||
|
'sync_error_classification',
|
||||||
|
find_program('python3'),
|
||||||
|
args: [meson.project_source_root() / 'tests/test_sync_error_classification.py'],
|
||||||
|
)
|
||||||
test(
|
test(
|
||||||
'schema_v21_migration',
|
'schema_v21_migration',
|
||||||
executable('test_schema_v21_migration', 'tests/test_schema_v21_migration.c', dependencies: trainlog_core_dep),
|
executable('test_schema_v21_migration', 'tests/test_schema_v21_migration.c', dependencies: trainlog_core_dep),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,116 @@ static TrainlogStatus pull_folder(const TrainlogGenerationMtpIo *io,
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus pull_named_file(const TrainlogGenerationMtpIo *io,
|
||||||
|
const Selection *selection,
|
||||||
|
uint32_t parent,
|
||||||
|
const char *name,
|
||||||
|
const char *local,
|
||||||
|
bool required) {
|
||||||
|
TrainlogMtpEntry entry;
|
||||||
|
TrainlogStatus status = one_named(io, selection, parent, name, false, &entry);
|
||||||
|
char path[1024], temporary[1060];
|
||||||
|
struct stat value;
|
||||||
|
int written;
|
||||||
|
if (status == TRAINLOG_STATUS_NOT_FOUND && !required) {
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
if (status != TRAINLOG_STATUS_OK || entry.size_bytes > 65536U) {
|
||||||
|
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status;
|
||||||
|
}
|
||||||
|
written = snprintf(path, sizeof(path), "%s/%s", local, name);
|
||||||
|
if (written < 0 || (size_t)written >= sizeof(path)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
written = snprintf(temporary, sizeof(temporary), "%s.part.%ld", path, (long)getpid());
|
||||||
|
if (written < 0 || (size_t)written >= sizeof(temporary)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
(void)unlink(temporary);
|
||||||
|
status = io->receive(selection->bus, selection->device, entry.item_id, temporary);
|
||||||
|
if (status == TRAINLOG_STATUS_OK &&
|
||||||
|
(stat(temporary, &value) != 0 || value.st_size < 0 ||
|
||||||
|
(uint64_t)value.st_size != entry.size_bytes || rename(temporary, path) != 0)) {
|
||||||
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)unlink(temporary);
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool referenced_android_generation(const char *local, char *generation, size_t size) {
|
||||||
|
char path[1024], buffer[65537];
|
||||||
|
int fd, written;
|
||||||
|
ssize_t count;
|
||||||
|
yyjson_doc *document;
|
||||||
|
yyjson_val *root, *relative;
|
||||||
|
const char *value, *prefix = "android-objects/generations/";
|
||||||
|
bool valid = false;
|
||||||
|
written = snprintf(path, sizeof(path), "%s/android-generation-v1.json", local);
|
||||||
|
if (written < 0 || (size_t)written >= sizeof(path) ||
|
||||||
|
(fd = open(path, O_RDONLY | O_CLOEXEC)) < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
count = read(fd, buffer, sizeof(buffer));
|
||||||
|
(void)close(fd);
|
||||||
|
if (count <= 0 || (size_t)count >= sizeof(buffer)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
document = yyjson_read(buffer, (size_t)count, YYJSON_READ_NOFLAG);
|
||||||
|
if (document == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root = yyjson_doc_get_root(document);
|
||||||
|
relative = yyjson_obj_get(root, "relative_path");
|
||||||
|
value = yyjson_get_str(relative);
|
||||||
|
if (yyjson_is_obj(root) && value != NULL && strncmp(value, prefix, strlen(prefix)) == 0) {
|
||||||
|
const char *leaf = value + strlen(prefix);
|
||||||
|
if (safe_leaf(leaf) && strlen(leaf) < size) {
|
||||||
|
(void)memcpy(generation, leaf, strlen(leaf) + 1U);
|
||||||
|
valid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yyjson_doc_free(document);
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus pull_referenced_generation(const TrainlogGenerationMtpIo *io,
|
||||||
|
const Selection *selection,
|
||||||
|
const char *local,
|
||||||
|
const char *generation) {
|
||||||
|
TrainlogMtpEntry android_objects, generations, selected;
|
||||||
|
char path[1024];
|
||||||
|
uint64_t total = 0U;
|
||||||
|
size_t objects = 0U;
|
||||||
|
TrainlogStatus status;
|
||||||
|
status = one_named(io, selection, selection->root, "android-objects", true, &android_objects);
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = one_named(io, selection, android_objects.item_id, "generations", true, &generations);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = one_named(io, selection, generations.item_id, generation, true, &selected);
|
||||||
|
}
|
||||||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
snprintf(path, sizeof(path), "%s/android-objects/generations/%s", local, generation) < 0 ||
|
||||||
|
strlen(local) + strlen("/android-objects/generations/") + strlen(generation) >= sizeof(path)) {
|
||||||
|
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status;
|
||||||
|
}
|
||||||
|
char parent[1024];
|
||||||
|
if (snprintf(parent, sizeof(parent), "%s/android-objects/generations", local) < 0 ||
|
||||||
|
strlen(local) + strlen("/android-objects/generations") >= sizeof(parent) ||
|
||||||
|
mkdir_private(local) != TRAINLOG_STATUS_OK) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
char objects_path[1024];
|
||||||
|
if (snprintf(objects_path, sizeof(objects_path), "%s/android-objects", local) < 0 ||
|
||||||
|
strlen(local) + strlen("/android-objects") >= sizeof(objects_path) ||
|
||||||
|
mkdir_private(objects_path) != TRAINLOG_STATUS_OK || mkdir_private(parent) != TRAINLOG_STATUS_OK) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
return pull_folder(io, selection, selected.item_id, path, 0U, &total, &objects);
|
||||||
|
}
|
||||||
|
|
||||||
static bool same_files(const char *left, const char *right) {
|
static bool same_files(const char *left, const char *right) {
|
||||||
int left_fd = open(left, O_RDONLY | O_CLOEXEC);
|
int left_fd = open(left, O_RDONLY | O_CLOEXEC);
|
||||||
int right_fd = open(right, O_RDONLY | O_CLOEXEC);
|
int right_fd = open(right, O_RDONLY | O_CLOEXEC);
|
||||||
|
|
@ -459,9 +569,8 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
|
||||||
char *diagnostic,
|
char *diagnostic,
|
||||||
size_t diagnostic_size) {
|
size_t diagnostic_size) {
|
||||||
Selection selection;
|
Selection selection;
|
||||||
uint64_t total = 0U;
|
|
||||||
size_t objects = 0U;
|
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
|
char generation[256];
|
||||||
if (io == NULL || expected == NULL || local == NULL) {
|
if (io == NULL || expected == NULL || local == NULL) {
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
@ -470,7 +579,32 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
|
||||||
status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size);
|
status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size);
|
||||||
}
|
}
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = pull_folder(io, &selection, selection.root, local, 0U, &total, &objects);
|
/* WHY: Documents/Trainlog retains prior generations and causal evidence.
|
||||||
|
* Recursively mirroring that durable history makes foreground sync cost
|
||||||
|
* grow with retention and can exhaust the run deadline before admission.
|
||||||
|
* CONTRACT: pull only current coordination objects and the one Android
|
||||||
|
* generation named by the current reference. Missing optional objects
|
||||||
|
* remain a normal polling state for the Python conversation.
|
||||||
|
* INVARIANT: no remote object, ACK, tombstone, or retained generation is
|
||||||
|
* removed or rewritten by this bounded read. */
|
||||||
|
status = pull_named_file(
|
||||||
|
io, &selection, selection.root, "android-peer-v1.json", local, true);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = pull_named_file(
|
||||||
|
io, &selection, selection.root, "android-generation-v1.json", local, false);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = pull_named_file(
|
||||||
|
io, &selection, selection.root, "android-consumption-ack-v1.json", local, false);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = pull_named_file(
|
||||||
|
io, &selection, selection.root, "android-generation-error-v1.json", local, false);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK &&
|
||||||
|
referenced_android_generation(local, generation, sizeof(generation))) {
|
||||||
|
status = pull_referenced_generation(io, &selection, local, generation);
|
||||||
}
|
}
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
174
tui/src/web_prepared_items.c
Normal file
174
tui/src/web_prepared_items.c
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
#include "trainlog/web_prepared_items.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include "database_internal.h"
|
||||||
|
|
||||||
|
static bool copy_column(sqlite3_stmt *statement, int column, char *output, size_t capacity) {
|
||||||
|
const unsigned char *text;
|
||||||
|
int bytes;
|
||||||
|
|
||||||
|
if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
|
||||||
|
output[0] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
text = sqlite3_column_text(statement, column);
|
||||||
|
bytes = sqlite3_column_bytes(statement, column);
|
||||||
|
if (text == NULL || bytes < 0 || (size_t)bytes >= capacity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
(void)memcpy(output, text, (size_t)bytes);
|
||||||
|
output[bytes] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool read_count(sqlite3_stmt *statement, int column, size_t *output) {
|
||||||
|
sqlite3_int64 value;
|
||||||
|
|
||||||
|
if (sqlite3_column_type(statement, column) != SQLITE_INTEGER) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
value = sqlite3_column_int64(statement, column);
|
||||||
|
if (value < 0 || (uint64_t)value > (uint64_t)SIZE_MAX) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*output = (size_t)value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool format_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
|
||||||
|
time_t now = time(NULL);
|
||||||
|
struct tm utc;
|
||||||
|
|
||||||
|
return now != (time_t)-1 && gmtime_r(&now, &utc) != NULL &&
|
||||||
|
strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus load_ai_proposals(
|
||||||
|
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
|
||||||
|
static const char SQL[] =
|
||||||
|
"SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,''),"
|
||||||
|
"COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' "
|
||||||
|
"END FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON "
|
||||||
|
"e.draft_row_id=d.id GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),"
|
||||||
|
"d.created_at,d.draft_id LIMIT ?1;";
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int step;
|
||||||
|
|
||||||
|
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_int64(
|
||||||
|
statement, 1, (sqlite3_int64)(TRAINLOG_WEB_PREPARED_ITEM_CAPACITY + 1U)) != SQLITE_OK) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||||
|
TrainlogWebPreparedItem *item;
|
||||||
|
|
||||||
|
if (output->item_count == TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
|
||||||
|
output->partial = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
item = &output->items[output->item_count];
|
||||||
|
item->kind = TRAINLOG_WEB_PREPARED_AI_PROPOSAL;
|
||||||
|
if (!copy_column(statement, 0, item->identity, sizeof(item->identity)) ||
|
||||||
|
!copy_column(statement, 1, item->title, sizeof(item->title)) ||
|
||||||
|
!copy_column(statement, 2, item->planned_for, sizeof(item->planned_for)) ||
|
||||||
|
!read_count(statement, 3, &item->occurrence_count) ||
|
||||||
|
!copy_column(statement, 4, item->state, sizeof(item->state))) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "ai_import");
|
||||||
|
++output->item_count;
|
||||||
|
}
|
||||||
|
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
|
||||||
|
fail:
|
||||||
|
if (statement != NULL) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus load_execution_drafts(
|
||||||
|
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
|
||||||
|
static const char SQL[] =
|
||||||
|
"SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),''),"
|
||||||
|
"COALESCE(json_array_length(d.payload_json,'$.exercises'),0) FROM execution_drafts d "
|
||||||
|
"WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
|
||||||
|
"f.session_id=d.session_id) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
|
||||||
|
"c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) "
|
||||||
|
"ORDER BY CASE d.state WHEN 'active' THEN 0 ELSE 1 END,d.session_id LIMIT ?1;";
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int step;
|
||||||
|
size_t remaining = TRAINLOG_WEB_PREPARED_ITEM_CAPACITY - output->item_count;
|
||||||
|
|
||||||
|
if (remaining == 0U) {
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_int64(statement, 1, (sqlite3_int64)(remaining + 1U)) != SQLITE_OK) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||||
|
TrainlogWebPreparedItem *item;
|
||||||
|
|
||||||
|
if (output->item_count == TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
|
||||||
|
output->partial = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
item = &output->items[output->item_count];
|
||||||
|
item->kind = TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT;
|
||||||
|
if (!copy_column(statement, 0, item->identity, sizeof(item->identity)) ||
|
||||||
|
!copy_column(statement, 1, item->state, sizeof(item->state)) ||
|
||||||
|
!copy_column(statement, 2, item->title, sizeof(item->title)) ||
|
||||||
|
!copy_column(statement, 3, item->planned_for, sizeof(item->planned_for)) ||
|
||||||
|
!read_count(statement, 4, &item->occurrence_count)) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "execution_store");
|
||||||
|
++output->item_count;
|
||||||
|
}
|
||||||
|
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
|
||||||
|
fail:
|
||||||
|
if (statement != NULL) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_web_prepared_items_load(
|
||||||
|
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
|
||||||
|
TrainlogStatus status;
|
||||||
|
TrainlogStatus end_status;
|
||||||
|
|
||||||
|
if (database == NULL || output == NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
(void)memset(output, 0, sizeof(*output));
|
||||||
|
if (!format_now(output->generated_at)) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
status = trainlog_database_read_snapshot_begin(database);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
status = load_ai_proposals(database, output);
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = load_execution_drafts(database, output);
|
||||||
|
}
|
||||||
|
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
|
||||||
|
return status == TRAINLOG_STATUS_OK ? end_status : status;
|
||||||
|
}
|
||||||
140
tui/src/web_prepared_items_json.c
Normal file
140
tui/src/web_prepared_items_json.c
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
#include "trainlog/web_prepared_items.h"
|
||||||
|
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <utf8proc.h>
|
||||||
|
|
||||||
|
typedef struct JsonWriter {
|
||||||
|
char *output;
|
||||||
|
size_t capacity;
|
||||||
|
size_t length;
|
||||||
|
bool failed;
|
||||||
|
} JsonWriter;
|
||||||
|
|
||||||
|
static void append(JsonWriter *writer, const char *format, ...)
|
||||||
|
__attribute__((format(printf, 2, 3)));
|
||||||
|
|
||||||
|
static void append(JsonWriter *writer, const char *format, ...) {
|
||||||
|
va_list arguments;
|
||||||
|
int written;
|
||||||
|
|
||||||
|
if (writer->failed || writer->length >= writer->capacity) {
|
||||||
|
writer->failed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
va_start(arguments, format);
|
||||||
|
written = vsnprintf(
|
||||||
|
writer->output + writer->length, writer->capacity - writer->length, format, arguments);
|
||||||
|
va_end(arguments);
|
||||||
|
if (written < 0 || (size_t)written >= writer->capacity - writer->length) {
|
||||||
|
writer->failed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writer->length += (size_t)written;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool valid_utf8(const char *value) {
|
||||||
|
const utf8proc_uint8_t *cursor = (const utf8proc_uint8_t *)value;
|
||||||
|
|
||||||
|
while (*cursor != '\0') {
|
||||||
|
utf8proc_int32_t codepoint;
|
||||||
|
utf8proc_ssize_t sequence = utf8proc_iterate(cursor, -1, &codepoint);
|
||||||
|
(void)codepoint;
|
||||||
|
if (sequence <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
cursor += (size_t)sequence;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void string(JsonWriter *writer, const char *value) {
|
||||||
|
const unsigned char *cursor = (const unsigned char *)value;
|
||||||
|
|
||||||
|
if (!valid_utf8(value)) {
|
||||||
|
writer->failed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
append(writer, "\"");
|
||||||
|
while (!writer->failed && *cursor != '\0') {
|
||||||
|
unsigned char byte = *cursor++;
|
||||||
|
|
||||||
|
if (byte == '"' || byte == '\\') {
|
||||||
|
append(writer, "\\%c", (int)byte);
|
||||||
|
} else if (byte == '\b') {
|
||||||
|
append(writer, "\\b");
|
||||||
|
} else if (byte == '\f') {
|
||||||
|
append(writer, "\\f");
|
||||||
|
} else if (byte == '\n') {
|
||||||
|
append(writer, "\\n");
|
||||||
|
} else if (byte == '\r') {
|
||||||
|
append(writer, "\\r");
|
||||||
|
} else if (byte == '\t') {
|
||||||
|
append(writer, "\\t");
|
||||||
|
} else if (byte < 0x20U) {
|
||||||
|
append(writer, "\\u%04x", (unsigned int)byte);
|
||||||
|
} else {
|
||||||
|
append(writer, "%c", (int)byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append(writer, "\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_web_prepared_items_serialize(
|
||||||
|
const TrainlogWebPreparedItems *items,
|
||||||
|
char *output,
|
||||||
|
size_t capacity,
|
||||||
|
size_t *output_size) {
|
||||||
|
JsonWriter writer = {output, capacity, 0U, false};
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
if (items == NULL || output == NULL || output_size == NULL || capacity == 0U ||
|
||||||
|
items->item_count > TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
*output_size = 0U;
|
||||||
|
append(&writer, "{\"api_version\":1,\"generated_at\":");
|
||||||
|
string(&writer, items->generated_at);
|
||||||
|
append(&writer, ",\"partial\":%s,\"items\":[", items->partial ? "true" : "false");
|
||||||
|
for (index = 0U; index < items->item_count; ++index) {
|
||||||
|
const TrainlogWebPreparedItem *item = &items->items[index];
|
||||||
|
const char *kind;
|
||||||
|
|
||||||
|
if (item->kind == TRAINLOG_WEB_PREPARED_AI_PROPOSAL) {
|
||||||
|
kind = "ai_proposal";
|
||||||
|
} else if (item->kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT) {
|
||||||
|
kind = "execution_draft";
|
||||||
|
} else {
|
||||||
|
output[0] = '\0';
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
append(&writer, "%s{\"identity\":", index == 0U ? "" : ",");
|
||||||
|
string(&writer, item->identity);
|
||||||
|
append(&writer, ",\"kind\":");
|
||||||
|
string(&writer, kind);
|
||||||
|
append(&writer, ",\"title\":");
|
||||||
|
string(&writer, item->title);
|
||||||
|
append(&writer, ",\"planned_for\":");
|
||||||
|
if (item->planned_for[0] == '\0') {
|
||||||
|
append(&writer, "null");
|
||||||
|
} else {
|
||||||
|
string(&writer, item->planned_for);
|
||||||
|
}
|
||||||
|
append(&writer, ",\"state\":");
|
||||||
|
string(&writer, item->state);
|
||||||
|
append(&writer, ",\"occurrence_count\":%zu,\"provenance\":", item->occurrence_count);
|
||||||
|
string(&writer, item->provenance);
|
||||||
|
append(&writer, "}");
|
||||||
|
}
|
||||||
|
append(&writer, "]}\n");
|
||||||
|
if (writer.failed) {
|
||||||
|
if (capacity > 0U) {
|
||||||
|
output[0] = '\0';
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
*output_size = writer.length;
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
#include <yyjson.h>
|
#include <yyjson.h>
|
||||||
|
|
||||||
#include "trainlog/web_dashboard.h"
|
#include "trainlog/web_dashboard.h"
|
||||||
|
#include "trainlog/web_prepared_items.h"
|
||||||
#include "trainlog/dashboard_layout.h"
|
#include "trainlog/dashboard_layout.h"
|
||||||
#include "web_assets.h"
|
#include "web_assets.h"
|
||||||
|
|
||||||
|
|
@ -707,6 +708,27 @@ static enum MHD_Result handle_request(void *closure,
|
||||||
}
|
}
|
||||||
return queue_json(connection, MHD_HTTP_OK, json, NULL);
|
return queue_json(connection, MHD_HTTP_OK, json, NULL);
|
||||||
}
|
}
|
||||||
|
if (strcmp(url, "/api/v1/prepared-items") == 0) {
|
||||||
|
char json[TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY];
|
||||||
|
size_t json_size = 0U;
|
||||||
|
TrainlogWebPreparedItems items;
|
||||||
|
|
||||||
|
if (!is_get) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||||
|
"{\"error\":\"method_not_allowed\"}\n",
|
||||||
|
"GET");
|
||||||
|
}
|
||||||
|
if (trainlog_web_prepared_items_load(context->database, &items) != TRAINLOG_STATUS_OK ||
|
||||||
|
trainlog_web_prepared_items_serialize(&items, json, sizeof(json), &json_size) !=
|
||||||
|
TRAINLOG_STATUS_OK) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||||
|
"{\"error\":\"prepared_items_unavailable\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
return queue_json(connection, MHD_HTTP_OK, json, NULL);
|
||||||
|
}
|
||||||
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
|
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
|
||||||
TrainlogDashboardLayout layout;
|
TrainlogDashboardLayout layout;
|
||||||
TrainlogDashboardLayout saved;
|
TrainlogDashboardLayout saved;
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ static TrainlogStatus receive(unsigned int b, unsigned int d, uint32_t id, const
|
||||||
if (!f) {
|
if (!f) {
|
||||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
}
|
}
|
||||||
bytes = id == 9U ? 3U : o->size;
|
bytes = id == 12U ? 3U : o->size;
|
||||||
if (fwrite(o->data, 1, bytes, f) != bytes) {
|
if (fwrite(o->data, 1, bytes, f) != bytes) {
|
||||||
fclose(f);
|
fclose(f);
|
||||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
|
@ -232,11 +232,20 @@ static int test_pull_and_manifest_last(void) {
|
||||||
char *manifest;
|
char *manifest;
|
||||||
reset();
|
reset();
|
||||||
CHECK(mkdtemp(root) != NULL);
|
CHECK(mkdtemp(root) != NULL);
|
||||||
add(4, 2, true, "android-objects", NULL);
|
add(4,
|
||||||
add(5, 4, true, "generations", NULL);
|
2,
|
||||||
add(6, 5, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
|
false,
|
||||||
add(7, 6, false, "payload.json", "payload");
|
"android-generation-v1.json",
|
||||||
add(8, 6, false, "manifest.json", "manifest");
|
"{\"relative_path\":\"android-objects/generations/gen_11111111-1111-4111-8111-"
|
||||||
|
"111111111111\"}");
|
||||||
|
add(5, 2, true, "android-objects", NULL);
|
||||||
|
add(6, 5, true, "generations", NULL);
|
||||||
|
add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
|
||||||
|
add(8, 7, false, "payload.json", "payload");
|
||||||
|
add(9, 7, false, "manifest.json", "manifest");
|
||||||
|
add(10, 6, true, "gen_22222222-2222-4222-8222-222222222222", NULL);
|
||||||
|
add(11, 10, false, "retained.bin", "old");
|
||||||
|
find(11)->size = 99U;
|
||||||
CHECK(trainlog_generation_mtp_pull(
|
CHECK(trainlog_generation_mtp_pull(
|
||||||
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
|
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
|
||||||
TRAINLOG_STATUS_OK);
|
TRAINLOG_STATUS_OK);
|
||||||
|
|
@ -287,12 +296,25 @@ static int test_ambiguity_and_truncation(void) {
|
||||||
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
|
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
|
||||||
TRAINLOG_STATUS_NOT_FOUND);
|
TRAINLOG_STATUS_NOT_FOUND);
|
||||||
reset();
|
reset();
|
||||||
add(9, 2, false, "bad.bin", "abc");
|
add(4,
|
||||||
find(9)->size = 99U;
|
2,
|
||||||
|
false,
|
||||||
|
"android-generation-v1.json",
|
||||||
|
"{\"relative_path\":\"android-objects/generations/gen_11111111-1111-4111-8111-"
|
||||||
|
"111111111111\"}");
|
||||||
|
add(5, 2, true, "android-objects", NULL);
|
||||||
|
add(6, 5, true, "generations", NULL);
|
||||||
|
add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
|
||||||
|
add(12, 7, false, "bad.bin", "abc");
|
||||||
|
find(12)->size = 99U;
|
||||||
status = trainlog_generation_mtp_pull(
|
status = trainlog_generation_mtp_pull(
|
||||||
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag));
|
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag));
|
||||||
CHECK(status != TRAINLOG_STATUS_OK);
|
CHECK(status != TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_test_join_path(path, sizeof(path), root, "bad.bin"));
|
CHECK(trainlog_test_join_path(
|
||||||
|
path,
|
||||||
|
sizeof(path),
|
||||||
|
root,
|
||||||
|
"android-objects/generations/gen_11111111-1111-4111-8111-111111111111/bad.bin"));
|
||||||
CHECK(access(path, F_OK) != 0);
|
CHECK(access(path, F_OK) != 0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
110
tui/tests/test_web_prepared_items.c
Normal file
110
tui/tests/test_web_prepared_items.c
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
#include "trainlog/web_prepared_items.h"
|
||||||
|
#include "database_internal.h"
|
||||||
|
|
||||||
|
#define CHECK(condition) \
|
||||||
|
do { \
|
||||||
|
if (!(condition)) { \
|
||||||
|
(void)fprintf(stderr, "check failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
|
||||||
|
return false; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static bool execute(TrainlogDatabase *database, const char *sql) {
|
||||||
|
char *error = NULL;
|
||||||
|
int status = sqlite3_exec(database->connection, sql, NULL, NULL, &error);
|
||||||
|
if (status != SQLITE_OK) {
|
||||||
|
(void)fprintf(stderr, "SQLite fixture failed: %s\n", error == NULL ? "unknown" : error);
|
||||||
|
}
|
||||||
|
sqlite3_free(error);
|
||||||
|
return status == SQLITE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_distinct_durable_items(void) {
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogWebPreparedItems items;
|
||||||
|
char json[TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY];
|
||||||
|
size_t json_size = 0U;
|
||||||
|
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(execute(database,
|
||||||
|
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,"
|
||||||
|
"data_fields) VALUES('ex_00000000-0000-4000-8000-000000000001','Test','test','reps',"
|
||||||
|
"'sets',0);"
|
||||||
|
"INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes,"
|
||||||
|
"published_at) VALUES('aid_00000000-0000-4000-8000-000000000001',"
|
||||||
|
"'2026-09-16T10:00:00Z','2026-09-17','training','Prepared title',NULL,"
|
||||||
|
"'2026-09-16T11:00:00Z');"
|
||||||
|
"INSERT INTO ai_session_draft_entries(draft_row_id,entry_id,position,exercise_row_id,"
|
||||||
|
"source_exercise_id,recording_mode,tracking_mode,data_fields,equipment_id,load_mode,"
|
||||||
|
"target_sets,target_reps,target_duration_seconds,target_weight_kg,rest_seconds) "
|
||||||
|
"SELECT d.id,'sxe_00000000-0000-4000-8000-000000000001',0,e.id,e.exercise_id,'sets',"
|
||||||
|
"'reps',0,NULL,'none',2,8,NULL,NULL,60 FROM ai_session_drafts d,exercises e WHERE "
|
||||||
|
"e.exercise_id='ex_00000000-0000-4000-8000-000000000001';"
|
||||||
|
"INSERT INTO execution_drafts(session_id,session_type,source_session_id,started_at,"
|
||||||
|
"revision_id,parent_revision_id,state,payload_json) VALUES("
|
||||||
|
"'se_00000000-0000-4000-8000-000000000001','training',NULL,'2026-09-17T12:00:00Z',"
|
||||||
|
"'dr_one',NULL,'active','{\"exercises\":[{},{}]}');"));
|
||||||
|
{
|
||||||
|
TrainlogStatus status = trainlog_web_prepared_items_load(database, &items);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)fprintf(stderr, "prepared load failed: %d sqlite=%s\n", (int)status,
|
||||||
|
sqlite3_errmsg(database->connection));
|
||||||
|
}
|
||||||
|
CHECK(status == TRAINLOG_STATUS_OK);
|
||||||
|
}
|
||||||
|
CHECK(items.item_count == 2U);
|
||||||
|
CHECK(items.items[0].kind == TRAINLOG_WEB_PREPARED_AI_PROPOSAL);
|
||||||
|
CHECK(strcmp(items.items[0].state, "published") == 0);
|
||||||
|
CHECK(strcmp(items.items[0].title, "Prepared title") == 0);
|
||||||
|
CHECK(items.items[0].occurrence_count == 1U);
|
||||||
|
CHECK(items.items[1].kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT);
|
||||||
|
CHECK(strcmp(items.items[1].state, "active") == 0);
|
||||||
|
CHECK(items.items[1].occurrence_count == 2U);
|
||||||
|
CHECK(trainlog_web_prepared_items_serialize(
|
||||||
|
&items, json, sizeof(json), &json_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(json_size > 0U);
|
||||||
|
CHECK(strstr(json, "\"kind\":\"ai_proposal\"") != NULL);
|
||||||
|
CHECK(strstr(json, "\"kind\":\"execution_draft\"") != NULL);
|
||||||
|
items.items[0].title[0] = (char)0xc3;
|
||||||
|
items.items[0].title[1] = '\0';
|
||||||
|
CHECK(trainlog_web_prepared_items_serialize(
|
||||||
|
&items, json, sizeof(json), &json_size) == TRAINLOG_STATUS_SYSTEM_ERROR);
|
||||||
|
CHECK(json_size == 0U && json[0] == '\0');
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_finalized_and_deleted_drafts_are_hidden(void) {
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogWebPreparedItems items;
|
||||||
|
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(execute(database,
|
||||||
|
"INSERT INTO execution_drafts(session_id,session_type,revision_id,state,payload_json) "
|
||||||
|
"VALUES('se_00000000-0000-4000-8000-000000000002','training','dr_two','pending',"
|
||||||
|
"'{\"exercises\":[]}'),('se_00000000-0000-4000-8000-000000000003','training',"
|
||||||
|
"'dr_three','pending','{\"exercises\":[]}');"
|
||||||
|
"INSERT INTO execution_draft_finalizations(session_id,final_revision_id,finalized_at) "
|
||||||
|
"VALUES('se_00000000-0000-4000-8000-000000000002','dr_final',"
|
||||||
|
"'2026-09-17T12:00:00Z');"
|
||||||
|
"INSERT INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted) "
|
||||||
|
"VALUES('execution_draft','se_00000000-0000-4000-8000-000000000003','dr_delete',1);"));
|
||||||
|
CHECK(trainlog_web_prepared_items_load(database, &items) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(items.item_count == 0U);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
if (!test_distinct_durable_items() || !test_finalized_and_deleted_drafts_are_hidden()) {
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
@ -255,6 +255,19 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
||||||
CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL);
|
CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL);
|
||||||
CHECK(strstr(response, "\"window_days\":90") != NULL);
|
CHECK(strstr(response, "\"window_days\":90") != NULL);
|
||||||
CHECK(strstr(response, "\"partial\":false") != NULL);
|
CHECK(strstr(response, "\"partial\":false") != NULL);
|
||||||
|
CHECK(exchange(port,
|
||||||
|
"GET /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
||||||
|
response,
|
||||||
|
sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 200") != NULL);
|
||||||
|
CHECK(strstr(response, "\"api_version\":1") != NULL);
|
||||||
|
CHECK(strstr(response, "\"items\":[]") != NULL);
|
||||||
|
CHECK(exchange(port,
|
||||||
|
"POST /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
||||||
|
"Content-Length: 0\r\n\r\n",
|
||||||
|
response,
|
||||||
|
sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 405") != NULL);
|
||||||
{
|
{
|
||||||
TrainlogDashboardLayout layout;
|
TrainlogDashboardLayout layout;
|
||||||
char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
|
char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
|
||||||
|
|
|
||||||
21
web/src/api/preparedItems.test.ts
Normal file
21
web/src/api/preparedItems.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { parsePreparedItems } from './preparedItems'
|
||||||
|
|
||||||
|
describe('projection des préparations', () => {
|
||||||
|
it('distingue une proposition IA d’un brouillon d’exécution', () => {
|
||||||
|
const value = parsePreparedItems({
|
||||||
|
api_version: 1,
|
||||||
|
generated_at: '2026-09-17T12:00:00Z',
|
||||||
|
partial: false,
|
||||||
|
items: [
|
||||||
|
{ identity: 'aid_one', kind: 'ai_proposal', title: 'Plan', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' },
|
||||||
|
{ identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(value.items.map((item) => item.kind)).toEqual(['ai_proposal', 'execution_draft'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuse les compteurs et types non bornés', () => {
|
||||||
|
expect(() => parsePreparedItems({ api_version: 1, generated_at: 'x', partial: false, items: [{ identity: 'x', kind: 'session', title: '', planned_for: null, state: '', occurrence_count: -1, provenance: '' }] })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
56
web/src/api/preparedItems.ts
Normal file
56
web/src/api/preparedItems.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
export type PreparedItemKind = 'ai_proposal' | 'execution_draft'
|
||||||
|
|
||||||
|
export interface PreparedItem {
|
||||||
|
identity: string
|
||||||
|
kind: PreparedItemKind
|
||||||
|
title: string
|
||||||
|
planned_for: string | null
|
||||||
|
state: string
|
||||||
|
occurrence_count: number
|
||||||
|
provenance: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedItemsSnapshot {
|
||||||
|
api_version: 1
|
||||||
|
generated_at: string
|
||||||
|
partial: boolean
|
||||||
|
items: PreparedItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validItem(value: unknown): value is PreparedItem {
|
||||||
|
if (!record(value)) return false
|
||||||
|
const kind = value.kind
|
||||||
|
return (kind === 'ai_proposal' || kind === 'execution_draft') &&
|
||||||
|
typeof value.identity === 'string' &&
|
||||||
|
typeof value.title === 'string' &&
|
||||||
|
(value.planned_for === null || typeof value.planned_for === 'string') &&
|
||||||
|
typeof value.state === 'string' &&
|
||||||
|
Number.isSafeInteger(value.occurrence_count) &&
|
||||||
|
Number(value.occurrence_count) >= 0 &&
|
||||||
|
typeof value.provenance === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePreparedItems(value: unknown): PreparedItemsSnapshot {
|
||||||
|
if (!record(value) || value.api_version !== 1 ||
|
||||||
|
typeof value.generated_at !== 'string' ||
|
||||||
|
typeof value.partial !== 'boolean' ||
|
||||||
|
!Array.isArray(value.items) ||
|
||||||
|
value.items.length > 32 ||
|
||||||
|
!value.items.every(validItem)) {
|
||||||
|
throw new TypeError('projection des préparations invalide')
|
||||||
|
}
|
||||||
|
return value as unknown as PreparedItemsSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPreparedItems(signal?: AbortSignal): Promise<PreparedItemsSnapshot> {
|
||||||
|
const response = await fetch('/api/v1/prepared-items', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`prepared-items HTTP ${response.status}`)
|
||||||
|
return parsePreparedItems(await response.json())
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,126 @@
|
||||||
export type SyncPhase = 'idle' | 'requested' | 'waiting_android_publication' | 'running' | 'local_import_committed' | 'published' | 'waiting_acknowledgement' | 'peer_consumed' | 'completed' | 'failed' | 'interrupted' | 'explicitly_degraded'
|
export type SyncPhase =
|
||||||
export interface SyncDraftSummary { draft_id: string; state: 'active'|'pending'|'finalized'|'stale'; session_type: string; occurrence_count: number }
|
| 'idle'
|
||||||
export interface SyncStatus { api_version?: number; enabled?: boolean; phase: SyncPhase; result: string; run_id?: string; request_id?: string; progress_revision?: number; diagnostic?: string; sessions_reconciled?: number|null; inbound_generation_id?: string|null; outbound_generation_id?: string|null; missing_capabilities?: string[]; domains?: Record<string,boolean>; drafts?: SyncDraftSummary[]; finished_at?: string|null }
|
| 'requested'
|
||||||
let csrfToken=''
|
| 'waiting_android_publication'
|
||||||
const phases=new Set<SyncPhase>(['idle','requested','waiting_android_publication','running','local_import_committed','published','waiting_acknowledgement','peer_consumed','completed','failed','interrupted','explicitly_degraded'])
|
| 'running'
|
||||||
function parse(value:unknown):SyncStatus { if(typeof value!=='object'||value===null)throw new Error('statut sync invalide');const status=value as Partial<SyncStatus>;if(typeof status.phase!=='string'||!phases.has(status.phase as SyncPhase)||typeof status.result!=='string')throw new Error('statut sync invalide');if(status.drafts!==undefined&&(!Array.isArray(status.drafts)||status.drafts.length>32))throw new Error('résumé des brouillons invalide');return status as SyncStatus }
|
| 'local_import_committed'
|
||||||
export async function fetchSyncStatus(signal?:AbortSignal):Promise<SyncStatus>{const response=await fetch('/api/v1/sync/status',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`sync status HTTP ${response.status}`);const token=response.headers.get('X-Trainlog-CSRF-Token')??'';if(!/^[0-9a-f]{64}$/.test(token))throw new Error('jeton CSRF absent');csrfToken=token;return parse(await response.json())}
|
| 'published'
|
||||||
export async function startSync(requestId:string):Promise<SyncStatus>{if(!/^sy_[0-9a-f-]{36}$/.test(requestId)||csrfToken.length!==64)throw new Error('requête sync invalide');const response=await fetch('/api/v1/sync',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':csrfToken},body:JSON.stringify({request_id:requestId,trigger:'web'})});const value=await response.json();if(!response.ok)throw new Error(typeof value?.error==='string'?value.error:`sync HTTP ${response.status}`);return parse(value)}
|
| 'waiting_acknowledgement'
|
||||||
|
| 'peer_consumed'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'interrupted'
|
||||||
|
| 'explicitly_degraded'
|
||||||
|
|
||||||
|
export interface SyncDraftSummary {
|
||||||
|
draft_id: string
|
||||||
|
state: 'active' | 'pending' | 'finalized' | 'stale'
|
||||||
|
session_type: string
|
||||||
|
occurrence_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStatus {
|
||||||
|
api_version?: number
|
||||||
|
enabled?: boolean
|
||||||
|
phase: SyncPhase
|
||||||
|
result: string
|
||||||
|
run_id?: string
|
||||||
|
request_id?: string
|
||||||
|
progress_revision?: number
|
||||||
|
diagnostic?: string
|
||||||
|
error_code?: string
|
||||||
|
sessions_reconciled?: number | null
|
||||||
|
inbound_generation_id?: string | null
|
||||||
|
outbound_generation_id?: string | null
|
||||||
|
missing_capabilities?: string[]
|
||||||
|
domains?: Record<string, boolean>
|
||||||
|
drafts?: SyncDraftSummary[]
|
||||||
|
started_at?: string | null
|
||||||
|
finished_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
let csrfToken = ''
|
||||||
|
const phases = new Set<SyncPhase>([
|
||||||
|
'idle',
|
||||||
|
'requested',
|
||||||
|
'waiting_android_publication',
|
||||||
|
'running',
|
||||||
|
'local_import_committed',
|
||||||
|
'published',
|
||||||
|
'waiting_acknowledgement',
|
||||||
|
'peer_consumed',
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
'interrupted',
|
||||||
|
'explicitly_degraded',
|
||||||
|
])
|
||||||
|
function optionalString(value: unknown): boolean {
|
||||||
|
return value === undefined || value === null || typeof value === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function validDraft(value: unknown): value is SyncDraftSummary {
|
||||||
|
if (typeof value !== 'object' || value === null) return false
|
||||||
|
const draft = value as Partial<SyncDraftSummary>
|
||||||
|
return typeof draft.draft_id === 'string' &&
|
||||||
|
['active', 'pending', 'finalized', 'stale'].includes(draft.state ?? '') &&
|
||||||
|
typeof draft.session_type === 'string' &&
|
||||||
|
Number.isSafeInteger(draft.occurrence_count) &&
|
||||||
|
Number(draft.occurrence_count) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(value: unknown): SyncStatus {
|
||||||
|
if (typeof value !== 'object' || value === null) throw new Error('statut sync invalide')
|
||||||
|
const status = value as Partial<SyncStatus>
|
||||||
|
if (typeof status.phase !== 'string' || !phases.has(status.phase as SyncPhase) ||
|
||||||
|
typeof status.result !== 'string' ||
|
||||||
|
!optionalString(status.run_id) ||
|
||||||
|
!optionalString(status.request_id) ||
|
||||||
|
!optionalString(status.diagnostic) ||
|
||||||
|
!optionalString(status.error_code) ||
|
||||||
|
!optionalString(status.started_at) ||
|
||||||
|
!optionalString(status.finished_at) ||
|
||||||
|
(status.progress_revision !== undefined &&
|
||||||
|
(!Number.isSafeInteger(status.progress_revision) || status.progress_revision < 0))) {
|
||||||
|
throw new Error('statut sync invalide')
|
||||||
|
}
|
||||||
|
if (status.drafts !== undefined &&
|
||||||
|
(!Array.isArray(status.drafts) || status.drafts.length > 32 || !status.drafts.every(validDraft))) {
|
||||||
|
throw new Error('résumé des brouillons invalide')
|
||||||
|
}
|
||||||
|
return status as SyncStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSyncStatus(signal?: AbortSignal): Promise<SyncStatus> {
|
||||||
|
const response = await fetch('/api/v1/sync/status', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`sync status HTTP ${response.status}`)
|
||||||
|
const token = response.headers.get('X-Trainlog-CSRF-Token') ?? ''
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(token)) throw new Error('jeton CSRF absent')
|
||||||
|
csrfToken = token
|
||||||
|
return parse(await response.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSync(requestId: string): Promise<SyncStatus> {
|
||||||
|
if (!/^sy_[0-9a-f-]{36}$/.test(requestId) || csrfToken.length !== 64) {
|
||||||
|
throw new Error('requête sync invalide')
|
||||||
|
}
|
||||||
|
const response = await fetch('/api/v1/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Trainlog-CSRF-Token': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ request_id: requestId, trigger: 'web' }),
|
||||||
|
})
|
||||||
|
const value = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(typeof value?.error === 'string' ? value.error : `sync HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
return parse(value)
|
||||||
|
}
|
||||||
export function newRequestId(): string {
|
export function newRequestId(): string {
|
||||||
/*
|
/*
|
||||||
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
|
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
|
||||||
|
|
@ -27,4 +142,6 @@ export function newRequestId(): string {
|
||||||
].join('-')
|
].join('-')
|
||||||
return `sy_${uuid}`
|
return `sy_${uuid}`
|
||||||
}
|
}
|
||||||
export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}
|
export function syncIsActive(phase: SyncPhase): boolean {
|
||||||
|
return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { fetchHealth, type Health } from '../api/health'
|
import { fetchHealth, type Health } from '../api/health'
|
||||||
import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard'
|
import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard'
|
||||||
|
import { fetchPreparedItems, type PreparedItemsSnapshot } from '../api/preparedItems'
|
||||||
import { Footer } from '../components/Footer'
|
import { Footer } from '../components/Footer'
|
||||||
import { Header } from '../components/Header'
|
import { Header } from '../components/Header'
|
||||||
import { DashboardPage } from '../routes/DashboardPage'
|
import { DashboardPage } from '../routes/DashboardPage'
|
||||||
|
|
@ -23,10 +24,24 @@ export function App() {
|
||||||
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null)
|
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null)
|
||||||
const [dashboardPending, setDashboardPending] = useState(true)
|
const [dashboardPending, setDashboardPending] = useState(true)
|
||||||
const [dashboardFailed, setDashboardFailed] = useState(false)
|
const [dashboardFailed, setDashboardFailed] = useState(false)
|
||||||
|
const [preparedItems, setPreparedItems] = useState<PreparedItemsSnapshot | null>(null)
|
||||||
|
const [preparedItemsPending, setPreparedItemsPending] = useState(true)
|
||||||
|
const [preparedItemsFailed, setPreparedItemsFailed] = useState(false)
|
||||||
const reloadDashboard = useCallback(() => {
|
const reloadDashboard = useCallback(() => {
|
||||||
fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) })
|
fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) })
|
||||||
.catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false))
|
.catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
const reloadPreparedItems = useCallback(() => {
|
||||||
|
setPreparedItemsPending(true)
|
||||||
|
fetchPreparedItems().then((value) => {
|
||||||
|
setPreparedItems(value)
|
||||||
|
setPreparedItemsFailed(false)
|
||||||
|
}).catch(() => {
|
||||||
|
// INVARIANT: a transient reread failure does not erase the last durable
|
||||||
|
// projection already shown to the user.
|
||||||
|
setPreparedItemsFailed(true)
|
||||||
|
}).finally(() => setPreparedItemsPending(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
@ -39,10 +54,25 @@ export function App() {
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setDashboard(null); setDashboardFailed(true)
|
setDashboard(null); setDashboardFailed(true)
|
||||||
}).finally(() => setDashboardPending(false))
|
}).finally(() => setDashboardPending(false))
|
||||||
|
fetchPreparedItems(controller.signal).then((value) => {
|
||||||
|
setPreparedItems(value)
|
||||||
|
setPreparedItemsFailed(false)
|
||||||
|
}).catch(() => {
|
||||||
|
setPreparedItemsFailed(true)
|
||||||
|
}).finally(() => setPreparedItemsPending(false))
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const content = route.id === 'dashboard' ? <DashboardPage dashboard={dashboard} pending={dashboardPending} failed={dashboardFailed} /> :
|
const content = route.id === 'dashboard' ? (
|
||||||
|
<DashboardPage
|
||||||
|
dashboard={dashboard}
|
||||||
|
pending={dashboardPending}
|
||||||
|
failed={dashboardFailed}
|
||||||
|
preparedItems={preparedItems}
|
||||||
|
preparedItemsPending={preparedItemsPending}
|
||||||
|
preparedItemsFailed={preparedItemsFailed}
|
||||||
|
/>
|
||||||
|
) :
|
||||||
<PlaceholderPage title={placeholderContent[route.id][0]}
|
<PlaceholderPage title={placeholderContent[route.id][0]}
|
||||||
eyebrow={placeholderContent[route.id][1]}
|
eyebrow={placeholderContent[route.id][1]}
|
||||||
description={placeholderContent[route.id][2]} />
|
description={placeholderContent[route.id][2]} />
|
||||||
|
|
@ -50,7 +80,10 @@ export function App() {
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<a className="skip-link" href="#main-content">Aller au contenu</a>
|
<a className="skip-link" href="#main-content">Aller au contenu</a>
|
||||||
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={reloadDashboard} />
|
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={() => {
|
||||||
|
reloadDashboard()
|
||||||
|
reloadPreparedItems()
|
||||||
|
}} />
|
||||||
<main className="app-main" id="main-content">{content}</main>
|
<main className="app-main" id="main-content">{content}</main>
|
||||||
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
|
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,61 @@
|
||||||
import { fireEvent,render,screen,waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { describe,expect,it,vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { SyncControl } from './SyncControl'
|
import { SyncControl } from './SyncControl'
|
||||||
const headers=new Headers({'X-Trainlog-CSRF-Token':'c'.repeat(64)})
|
|
||||||
const reply=(value:unknown,status=200)=>({ok:status<300,status,headers,json:()=>Promise.resolve(value)})
|
const headers = new Headers({ 'X-Trainlog-CSRF-Token': 'c'.repeat(64) })
|
||||||
describe('contrôle global de synchronisation',()=>{
|
|
||||||
it('démarre une fois, confirme par polling et expose le brouillon sans le relabeller',async()=>{const fetch=vi.fn()
|
function reply(value: unknown, status = 200) {
|
||||||
.mockResolvedValueOnce(reply({phase:'idle',result:'idle'}))
|
return { ok: status < 300, status, headers, json: () => Promise.resolve(value) }
|
||||||
.mockResolvedValueOnce(reply({phase:'requested',result:'running'},202))
|
}
|
||||||
.mockResolvedValueOnce(reply({phase:'completed',result:'completed',progress_revision:7,sessions_reconciled:1,finished_at:'2026-09-17T12:00:00Z',drafts:[{draft_id:'draft_1',state:'pending',session_type:'strength',occurrence_count:2}]}));vi.stubGlobal('fetch',fetch);const committed=vi.fn();render(<SyncControl onCommitted={committed}/>);await waitFor(()=>expect(screen.getByRole('button',{name:'Synchroniser'})).toBeEnabled());fireEvent.click(screen.getByRole('button',{name:'Synchroniser'}));await waitFor(()=>expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(),{timeout:2500});fireEvent.click(screen.getByText('Synchronisation confirmée'));expect(screen.getByRole('region',{name:'Brouillons synchronisés'})).toHaveTextContent('pending');expect(committed).toHaveBeenCalledTimes(1);expect(fetch.mock.calls.filter(call=>String(call[0]).endsWith('/api/v1/sync'))).toHaveLength(1)})
|
|
||||||
|
describe('contrôle global de synchronisation', () => {
|
||||||
|
it('démarre une fois, confirme par polling et expose le brouillon sans le relabeller', async () => {
|
||||||
|
const runId = 'sy_00000000-0000-4000-8000-000000000001'
|
||||||
|
const fetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce(reply({ phase: 'idle', result: 'idle' }))
|
||||||
|
.mockResolvedValueOnce(reply({ phase: 'requested', result: 'running', run_id: runId, progress_revision: 1 }, 202))
|
||||||
|
.mockResolvedValueOnce(reply({
|
||||||
|
phase: 'completed', result: 'completed', run_id: runId, progress_revision: 7,
|
||||||
|
sessions_reconciled: 1, finished_at: '2026-09-17T12:00:00Z',
|
||||||
|
drafts: [{ draft_id: 'draft_1', state: 'pending', session_type: 'strength', occurrence_count: 2 }],
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('fetch', fetch)
|
||||||
|
const committed = vi.fn()
|
||||||
|
render(<SyncControl onCommitted={committed} />)
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Synchroniser' })).toBeEnabled())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Synchroniser' }))
|
||||||
|
await waitFor(() => expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(), { timeout: 2500 })
|
||||||
|
fireEvent.click(screen.getByText('Synchronisation confirmée'))
|
||||||
|
expect(screen.getByRole('region', { name: 'Brouillons synchronisés' })).toHaveTextContent('pending')
|
||||||
|
expect(committed).toHaveBeenCalledTimes(1)
|
||||||
|
expect(fetch.mock.calls.filter((call) => String(call[0]).endsWith('/api/v1/sync'))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuse une réponse terminale d’un ancien run et poursuit le run admis', async () => {
|
||||||
|
const current = 'sy_00000000-0000-4000-8000-000000000002'
|
||||||
|
const stale = 'sy_00000000-0000-4000-8000-000000000001'
|
||||||
|
const fetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce(reply({ phase: 'idle', result: 'idle' }))
|
||||||
|
.mockResolvedValueOnce(reply({ phase: 'running', result: 'running', run_id: current, progress_revision: 2, started_at: '2026-09-17T13:00:00Z' }, 202))
|
||||||
|
.mockResolvedValueOnce(reply({ phase: 'failed', result: 'failed', run_id: stale, progress_revision: 9, started_at: '2026-09-17T12:00:00Z' }))
|
||||||
|
.mockResolvedValueOnce(reply({ phase: 'completed', result: 'completed', run_id: current, progress_revision: 8, started_at: '2026-09-17T13:00:00Z' }))
|
||||||
|
vi.stubGlobal('fetch', fetch)
|
||||||
|
render(<SyncControl onCommitted={vi.fn()} />)
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Synchroniser' })).toBeEnabled())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Synchroniser' }))
|
||||||
|
await waitFor(() => expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(), { timeout: 3500 })
|
||||||
|
expect(screen.queryByText('Échec de synchronisation')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche le code stable et l’action pour un timeout MTP', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(reply({
|
||||||
|
phase: 'failed', result: 'failed', error_code: 'transport_timeout',
|
||||||
|
diagnostic: 'transport_timeout: MTP push did not finish within 30 seconds',
|
||||||
|
})))
|
||||||
|
render(<SyncControl onCommitted={vi.fn()} />)
|
||||||
|
const summary = await screen.findByText('Échec de synchronisation')
|
||||||
|
fireEvent.click(summary)
|
||||||
|
expect(screen.getByText('Code : transport_timeout')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Vérifiez le dialogue USB\/MTP/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,156 @@
|
||||||
import { useEffect,useRef,useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { fetchSyncStatus,newRequestId,startSync,syncIsActive,type SyncStatus } from '../api/sync'
|
import { fetchSyncStatus, newRequestId, startSync, syncIsActive, type SyncStatus } from '../api/sync'
|
||||||
const labels:Record<string,string>={idle:'Synchronisation inactive',requested:'Synchronisation demandée',waiting_android_publication:'Publication Android requise',running:'Synchronisation en cours',local_import_committed:'Données desktop validées',published:'Génération retour publiée',waiting_acknowledgement:'Accusé Android attendu',peer_consumed:'Pair confirmé',completed:'Synchronisation confirmée',failed:'Échec de synchronisation',interrupted:'Synchronisation interrompue',explicitly_degraded:'Pair incompatible'}
|
|
||||||
export function SyncControl({onCommitted}:{onCommitted:()=>void}){const [status,setStatus]=useState<SyncStatus|null>(null);const [error,setError]=useState('');const committed=useRef(-1)
|
const labels: Record<string, string> = {
|
||||||
const accept=(next:SyncStatus)=>{setStatus(next);setError('');if(['local_import_committed','published','waiting_acknowledgement','peer_consumed','completed'].includes(next.phase)&&(next.progress_revision??0)>committed.current){committed.current=next.progress_revision??0;onCommitted()}}
|
idle: 'Synchronisation inactive',
|
||||||
useEffect(()=>{let stopped=false;let timer:number|undefined;const poll=async()=>{try{const next=await fetchSyncStatus();if(stopped)return;accept(next);if(syncIsActive(next.phase))timer=window.setTimeout(poll,750)}catch{if(!stopped)setError('Statut de synchronisation indisponible')}};void poll();return()=>{stopped=true;if(timer!==undefined)window.clearTimeout(timer)}},[onCommitted])
|
requested: 'Synchronisation demandée',
|
||||||
const start=async()=>{setError('');try{accept(await startSync(newRequestId()));const poll=async()=>{try{const next=await fetchSyncStatus();accept(next);if(syncIsActive(next.phase))window.setTimeout(poll,750)}catch{setError('Résultat inconnu : vérifiez le statut avant de réessayer')}};window.setTimeout(poll,250)}catch(reason){setError(reason instanceof Error?reason.message:'Démarrage impossible')}}
|
waiting_android_publication: 'Publication Android requise',
|
||||||
const active=status?syncIsActive(status.phase):false
|
running: 'Synchronisation en cours',
|
||||||
return <div className="sync-control"><button type="button" onClick={start} disabled={active||status?.enabled===false}>Synchroniser</button><details className="sync-details"><summary aria-live="polite">{error||labels[status?.phase??'idle']}</summary>{status?.diagnostic&&<p>{status.diagnostic}</p>}{status?.sessions_reconciled!==null&&status?.sessions_reconciled!==undefined&&<p>Séances rapprochées : {status.sessions_reconciled}</p>}{status?.phase==='waiting_android_publication'&&<p>Ouvrez l’écran de synchronisation Android pour publier une génération fraîche.</p>}{(status?.drafts?.length??0)>0&&<section aria-label="Brouillons synchronisés"><h2>Brouillons reçus</h2><ul>{status!.drafts!.map(d=><li key={d.draft_id}><code>{d.draft_id}</code> · {d.state} · {d.session_type} · {d.occurrence_count} occurrence(s)</li>)}</ul></section>}{status?.finished_at&&<p>Dernier résultat : {status.finished_at}</p>}</details></div>
|
local_import_committed: 'Données desktop validées',
|
||||||
|
published: 'Génération retour publiée',
|
||||||
|
waiting_acknowledgement: 'Accusé Android attendu',
|
||||||
|
peer_consumed: 'Pair confirmé',
|
||||||
|
completed: 'Synchronisation confirmée',
|
||||||
|
failed: 'Échec de synchronisation',
|
||||||
|
interrupted: 'Synchronisation interrompue',
|
||||||
|
explicitly_degraded: 'Pair incompatible',
|
||||||
|
}
|
||||||
|
|
||||||
|
const committedPhases = new Set([
|
||||||
|
'local_import_committed',
|
||||||
|
'published',
|
||||||
|
'waiting_acknowledgement',
|
||||||
|
'peer_consumed',
|
||||||
|
'completed',
|
||||||
|
])
|
||||||
|
|
||||||
|
function statusAction(status: SyncStatus | null): string {
|
||||||
|
if (status?.phase === 'waiting_android_publication') {
|
||||||
|
return 'Ouvrez l’écran de synchronisation Android et laissez le téléphone déverrouillé.'
|
||||||
|
}
|
||||||
|
if (status?.phase === 'failed' && status.error_code === 'device_unavailable') {
|
||||||
|
return 'Connectez et déverrouillez le téléphone, puis réessayez.'
|
||||||
|
}
|
||||||
|
if (status?.phase === 'failed' && status.error_code === 'transport_timeout') {
|
||||||
|
return 'Vérifiez le dialogue USB/MTP sur Android avant de relancer.'
|
||||||
|
}
|
||||||
|
if (status?.phase === 'failed' && status.error_code === 'peer_capacity_exhausted') {
|
||||||
|
return 'Conservez l’application Android ouverte : les générations acquittées doivent être archivées avant une relance explicite.'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
|
||||||
|
const [status, setStatus] = useState<SyncStatus | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const statusRef = useRef<SyncStatus | null>(null)
|
||||||
|
const committedByRun = useRef(new Map<string, number>())
|
||||||
|
const timer = useRef<number | undefined>(undefined)
|
||||||
|
const controller = useRef<AbortController | null>(null)
|
||||||
|
const mounted = useRef(false)
|
||||||
|
|
||||||
|
const accept = useCallback((next: SyncStatus): boolean => {
|
||||||
|
const current = statusRef.current
|
||||||
|
if (current?.run_id && next.run_id === current.run_id &&
|
||||||
|
(next.progress_revision ?? 0) < (current.progress_revision ?? 0)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (current?.run_id && next.run_id && next.run_id !== current.run_id &&
|
||||||
|
syncIsActive(current.phase)) {
|
||||||
|
if (!next.started_at || !current.started_at || next.started_at <= current.started_at) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statusRef.current = next
|
||||||
|
setStatus(next)
|
||||||
|
setError('')
|
||||||
|
if (next.run_id && committedPhases.has(next.phase)) {
|
||||||
|
const revision = next.progress_revision ?? 0
|
||||||
|
const prior = committedByRun.current.get(next.run_id) ?? -1
|
||||||
|
if (revision > prior) {
|
||||||
|
committedByRun.current.set(next.run_id, revision)
|
||||||
|
onCommitted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, [onCommitted])
|
||||||
|
|
||||||
|
const schedulePoll = useCallback((delay: number) => {
|
||||||
|
if (timer.current !== undefined) window.clearTimeout(timer.current)
|
||||||
|
timer.current = window.setTimeout(async () => {
|
||||||
|
controller.current?.abort()
|
||||||
|
const nextController = new AbortController()
|
||||||
|
controller.current = nextController
|
||||||
|
try {
|
||||||
|
const next = await fetchSyncStatus(nextController.signal)
|
||||||
|
if (!mounted.current) return
|
||||||
|
const accepted = accept(next)
|
||||||
|
const current = accepted ? next : statusRef.current
|
||||||
|
if (current && syncIsActive(current.phase)) schedulePoll(750)
|
||||||
|
} catch (reason) {
|
||||||
|
if (!mounted.current || nextController.signal.aborted) return
|
||||||
|
setError('Statut indisponible : le dernier état confirmé reste affiché.')
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
}, [accept])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true
|
||||||
|
schedulePoll(0)
|
||||||
|
return () => {
|
||||||
|
mounted.current = false
|
||||||
|
controller.current?.abort()
|
||||||
|
if (timer.current !== undefined) window.clearTimeout(timer.current)
|
||||||
|
}
|
||||||
|
}, [schedulePoll])
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const admitted = await startSync(newRequestId())
|
||||||
|
accept(admitted)
|
||||||
|
schedulePoll(250)
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Démarrage impossible')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = status ? syncIsActive(status.phase) : false
|
||||||
|
const action = statusAction(status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sync-control">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void start()}
|
||||||
|
disabled={active || status?.enabled === false}
|
||||||
|
>
|
||||||
|
Synchroniser
|
||||||
|
</button>
|
||||||
|
<details className="sync-details">
|
||||||
|
<summary aria-live="polite">{error || labels[status?.phase ?? 'idle']}</summary>
|
||||||
|
{action && <p>{action}</p>}
|
||||||
|
{status?.error_code && <p>Code : {status.error_code}</p>}
|
||||||
|
{status?.diagnostic && <p>{status.diagnostic}</p>}
|
||||||
|
{status?.sessions_reconciled !== null && status?.sessions_reconciled !== undefined && (
|
||||||
|
<p>Séances rapprochées : {status.sessions_reconciled}</p>
|
||||||
|
)}
|
||||||
|
{(status?.drafts?.length ?? 0) > 0 && (
|
||||||
|
<section aria-label="Brouillons synchronisés">
|
||||||
|
<h2>Brouillons reçus</h2>
|
||||||
|
<ul>
|
||||||
|
{status?.drafts?.map((draft) => (
|
||||||
|
<li key={draft.draft_id}>
|
||||||
|
<code>{draft.draft_id}</code> · {draft.state} · {draft.session_type} ·{' '}
|
||||||
|
{draft.occurrence_count} occurrence(s)
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{status?.started_at && <p>Démarrée : {status.started_at}</p>}
|
||||||
|
{status?.finished_at && <p>Dernier résultat : {status.finished_at}</p>}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||||
import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout'
|
import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout'
|
||||||
import { Tile } from '../components/Tile'
|
import { Tile } from '../components/Tile'
|
||||||
import type { DashboardSnapshot } from '../api/dashboard'
|
import type { DashboardSnapshot } from '../api/dashboard'
|
||||||
|
import type { PreparedItemsSnapshot } from '../api/preparedItems'
|
||||||
import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout'
|
import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout'
|
||||||
import {
|
import {
|
||||||
cloneLayout,
|
cloneLayout,
|
||||||
|
|
@ -35,9 +36,23 @@ function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
|
||||||
return { columns: 12, rowHeight: 52 }
|
return { columns: 12, rowHeight: 52 }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardGridProps { dashboard?: DashboardSnapshot | null; pending?: boolean; failed?: boolean }
|
interface DashboardGridProps {
|
||||||
|
dashboard?: DashboardSnapshot | null
|
||||||
|
pending?: boolean
|
||||||
|
failed?: boolean
|
||||||
|
preparedItems?: PreparedItemsSnapshot | null
|
||||||
|
preparedItemsPending?: boolean
|
||||||
|
preparedItemsFailed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardGrid({ dashboard = null, pending = false, failed = false }: DashboardGridProps) {
|
export function DashboardGrid({
|
||||||
|
dashboard = null,
|
||||||
|
pending = false,
|
||||||
|
failed = false,
|
||||||
|
preparedItems = null,
|
||||||
|
preparedItemsPending = false,
|
||||||
|
preparedItemsFailed = false,
|
||||||
|
}: DashboardGridProps) {
|
||||||
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [announcement, setAnnouncement] = useState('')
|
const [announcement, setAnnouncement] = useState('')
|
||||||
|
|
@ -169,7 +184,10 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals
|
||||||
const content = dashboardTiles[id]
|
const content = dashboardTiles[id]
|
||||||
const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0]
|
const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0]
|
||||||
const size = tileSize(dimensions)
|
const size = tileSize(dimensions)
|
||||||
const available = dashboard === null ? false : tileAvailable(dashboard, id)
|
const available = id === 'next-session'
|
||||||
|
? (preparedItems?.items.length ?? 0) > 0
|
||||||
|
: dashboard !== null && tileAvailable(dashboard, id)
|
||||||
|
const tilePending = id === 'next-session' ? preparedItemsPending : pending
|
||||||
const partial = dashboard?.meta.partial === true && id === 'max-records'
|
const partial = dashboard?.meta.partial === true && id === 'max-records'
|
||||||
const TileContent = content.component
|
const TileContent = content.component
|
||||||
return (
|
return (
|
||||||
|
|
@ -180,10 +198,21 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals
|
||||||
size={size}
|
size={size}
|
||||||
editable={editing && desktop}
|
editable={editing && desktop}
|
||||||
onKeyDown={(event) => onTileKeyDown(event, id)}
|
onKeyDown={(event) => onTileKeyDown(event, id)}
|
||||||
stateLabel={pending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
|
stateLabel={tilePending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
|
||||||
stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'}
|
stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'}
|
||||||
>
|
>
|
||||||
{dashboard === null ? <div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div> : <TileContent snapshot={dashboard} size={size} />}
|
{id === 'next-session' ? (
|
||||||
|
<NextSessionTile
|
||||||
|
size={size}
|
||||||
|
preparedItems={preparedItems}
|
||||||
|
pending={preparedItemsPending}
|
||||||
|
failed={preparedItemsFailed}
|
||||||
|
/>
|
||||||
|
) : dashboard === null ? (
|
||||||
|
<div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div>
|
||||||
|
) : (
|
||||||
|
<TileContent snapshot={dashboard} size={size} />
|
||||||
|
)}
|
||||||
</Tile>
|
</Tile>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -195,7 +224,7 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals
|
||||||
}
|
}
|
||||||
|
|
||||||
function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean {
|
function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean {
|
||||||
if (id === 'next-session') return snapshot.data.next_session.available
|
if (id === 'next-session') return false
|
||||||
if (id === 'activity') return snapshot.data.activity.available
|
if (id === 'activity') return snapshot.data.activity.available
|
||||||
if (id === 'progression') return snapshot.data.progression.available
|
if (id === 'progression') return snapshot.data.progression.available
|
||||||
if (id === 'last-session') return snapshot.data.last_session.available
|
if (id === 'last-session') return snapshot.data.last_session.available
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistri
|
||||||
describe('tuiles Dashboard alimentées par le contrat', () => {
|
describe('tuiles Dashboard alimentées par le contrat', () => {
|
||||||
it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => {
|
it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => {
|
||||||
const snapshot = dashboardFixture()
|
const snapshot = dashboardFixture()
|
||||||
const { rerender } = render(<NextSessionTile snapshot={snapshot} size="large" />)
|
const { rerender } = render(<NextSessionTile preparedItems={{api_version:1,generated_at:'2026-09-17T12:00:00Z',partial:false,items:[]}} pending={false} failed={false} size="large" />)
|
||||||
expect(screen.getByText('Aucune séance préparée')).toBeInTheDocument()
|
expect(screen.getByText('Aucun élément disponible')).toBeInTheDocument()
|
||||||
expect(document.body).not.toHaveTextContent('no_persisted_executable_plan')
|
expect(document.body).not.toHaveTextContent('no_persisted_executable_plan')
|
||||||
rerender(<CardioTile snapshot={snapshot} size="large" />)
|
rerender(<CardioTile snapshot={snapshot} size="large" />)
|
||||||
expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument()
|
expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument()
|
||||||
|
|
@ -15,6 +15,42 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
|
||||||
expect(document.body).not.toHaveTextContent(/bpm|%/i)
|
expect(document.body).not.toHaveTextContent(/bpm|%/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sépare proposition à valider et brouillon actif sans fabriquer de réalisé', () => {
|
||||||
|
const proposal = {
|
||||||
|
api_version: 1 as const,
|
||||||
|
generated_at: '2026-09-17T12:00:00Z',
|
||||||
|
partial: false,
|
||||||
|
items: [{ identity: 'aid_one', kind: 'ai_proposal' as const, title: 'Préparation', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' }],
|
||||||
|
}
|
||||||
|
const { rerender } = render(
|
||||||
|
<NextSessionTile size="large" preparedItems={proposal} pending={false} failed={false} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Proposition préparée · à valider')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Préparation')).toBeInTheDocument()
|
||||||
|
expect(document.body).not.toHaveTextContent(/réalisée|terminée/i)
|
||||||
|
|
||||||
|
rerender(<NextSessionTile size="large" preparedItems={{ ...proposal, items: [{ ...proposal.items[0], identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' }] }} pending={false} failed={false} />)
|
||||||
|
expect(screen.getByText('Brouillon d’exécution · à reprendre')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conserve une lecture durable lorsque son actualisation échoue', () => {
|
||||||
|
render(<NextSessionTile size="medium" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [{ identity: 'aid_one', kind: 'ai_proposal', title: 'Conservée', planned_for: null, state: 'published', occurrence_count: 1, provenance: 'ai_import' }] }} pending={false} failed />)
|
||||||
|
expect(screen.getByText('Conservée')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rend chaque proposition consultable sans la transformer en brouillon', () => {
|
||||||
|
render(<NextSessionTile size="large" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [
|
||||||
|
{ identity: 'aid_first', kind: 'ai_proposal', title: 'Première', planned_for: null, state: 'published', occurrence_count: 2, provenance: 'ai_import' },
|
||||||
|
{ identity: 'aid_expected', kind: 'ai_proposal', title: 'Attendue', planned_for: null, state: 'published', occurrence_count: 6, provenance: 'ai_import' },
|
||||||
|
] }} pending={false} failed={false} />)
|
||||||
|
const expected = document.querySelector('[data-prepared-identity="aid_expected"]')
|
||||||
|
expect(expected).toHaveTextContent('Attendue')
|
||||||
|
expect(expected).toHaveAttribute('data-prepared-identity', 'aid_expected')
|
||||||
|
expect(document.body).not.toHaveTextContent('Brouillon d’exécution · à reprendre')
|
||||||
|
})
|
||||||
|
|
||||||
it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => {
|
it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => {
|
||||||
const { container } = render(<ActivityTile snapshot={dashboardFixture()} size={size} />)
|
const { container } = render(<ActivityTile snapshot={dashboardFixture()} size={size} />)
|
||||||
const heatmap = screen.getByRole('img')
|
const heatmap = screen.getByRole('img')
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,68 @@ import { lazy, Suspense, type ComponentType } from 'react'
|
||||||
import type { TileSize } from './dashboardLayout'
|
import type { TileSize } from './dashboardLayout'
|
||||||
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
|
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
|
||||||
import { BodyZoneFigure } from './BodyZoneFigure'
|
import { BodyZoneFigure } from './BodyZoneFigure'
|
||||||
|
import type { PreparedItemsSnapshot } from '../api/preparedItems'
|
||||||
|
|
||||||
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
|
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
|
||||||
|
|
||||||
interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize }
|
interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize }
|
||||||
|
|
||||||
export function NextSessionTile({ size }: TileDataProps) {
|
export interface NextSessionTileProps {
|
||||||
return <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>Aucune séance préparée</p>{size !== 'compact' && <small>Aucune séance préparée n’est actuellement disponible.</small>}{size === 'large' && <span className="future-action" aria-hidden="true">Préparation à venir</span>}</div>
|
size: TileSize
|
||||||
|
preparedItems?: PreparedItemsSnapshot | null
|
||||||
|
pending?: boolean
|
||||||
|
failed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NextSessionTile({
|
||||||
|
size,
|
||||||
|
preparedItems = null,
|
||||||
|
pending = false,
|
||||||
|
failed = false,
|
||||||
|
}: NextSessionTileProps) {
|
||||||
|
if (preparedItems === null) {
|
||||||
|
return <Unavailable text={failed ? 'Données indisponibles' : 'Chargement des préparations…'} />
|
||||||
|
}
|
||||||
|
const item = preparedItems.items[0]
|
||||||
|
if (item === undefined) {
|
||||||
|
return <Unavailable text={failed ? 'Données indisponibles' : 'Aucun élément disponible'} />
|
||||||
|
}
|
||||||
|
const proposal = item.kind === 'ai_proposal'
|
||||||
|
const heading = proposal
|
||||||
|
? 'Proposition préparée · à valider'
|
||||||
|
: item.state === 'active'
|
||||||
|
? 'Brouillon d’exécution · à reprendre'
|
||||||
|
: 'Brouillon d’exécution · en attente'
|
||||||
|
const title = item.title || (proposal ? 'Proposition sans titre' : 'Séance préparée')
|
||||||
|
return (
|
||||||
|
<div className="session-content">
|
||||||
|
<p className="primary-label">{heading}</p>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
{size !== 'compact' && (
|
||||||
|
<>
|
||||||
|
<dl className="fact-list horizontal">
|
||||||
|
<Fact label="Éléments" value={String(item.occurrence_count)} />
|
||||||
|
<Fact label="Prévue" value={item.planned_for ? formatDate(item.planned_for) : 'Non renseignée'} />
|
||||||
|
</dl>
|
||||||
|
{preparedItems.items.length > 1 && (
|
||||||
|
<details className="prepared-items-list">
|
||||||
|
<summary>{preparedItems.items.length} éléments préparés</summary>
|
||||||
|
<ul>
|
||||||
|
{preparedItems.items.map((prepared) => (
|
||||||
|
<li key={`${prepared.kind}:${prepared.identity}`} data-prepared-identity={prepared.identity}>
|
||||||
|
{prepared.title || (prepared.kind === 'ai_proposal' ? 'Proposition sans titre' : 'Séance préparée')}
|
||||||
|
{' · '}{prepared.kind === 'ai_proposal' ? 'proposition' : 'brouillon d’exécution'}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{failed && <small>Dernière lecture conservée · actualisation indisponible</small>}
|
||||||
|
{pending && !failed && <small>Actualisation…</small>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityTile({ snapshot, size }: TileDataProps) {
|
export function ActivityTile({ snapshot, size }: TileDataProps) {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,37 @@
|
||||||
import { DashboardGrid } from '../dashboard/DashboardGrid'
|
import { DashboardGrid } from '../dashboard/DashboardGrid'
|
||||||
import type { DashboardSnapshot } from '../api/dashboard'
|
import type { DashboardSnapshot } from '../api/dashboard'
|
||||||
|
import type { PreparedItemsSnapshot } from '../api/preparedItems'
|
||||||
|
|
||||||
interface DashboardPageProps { dashboard: DashboardSnapshot | null; pending: boolean; failed: boolean }
|
interface DashboardPageProps {
|
||||||
|
dashboard: DashboardSnapshot | null
|
||||||
|
pending: boolean
|
||||||
|
failed: boolean
|
||||||
|
preparedItems: PreparedItemsSnapshot | null
|
||||||
|
preparedItemsPending: boolean
|
||||||
|
preparedItemsFailed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage({ dashboard, pending, failed }: DashboardPageProps) {
|
export function DashboardPage({
|
||||||
|
dashboard,
|
||||||
|
pending,
|
||||||
|
failed,
|
||||||
|
preparedItems,
|
||||||
|
preparedItemsPending,
|
||||||
|
preparedItemsFailed,
|
||||||
|
}: DashboardPageProps) {
|
||||||
return (
|
return (
|
||||||
<section className="page" aria-labelledby="page-title">
|
<section className="page" aria-labelledby="page-title">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
|
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
|
||||||
</div>
|
</div>
|
||||||
<DashboardGrid dashboard={dashboard} pending={pending} failed={failed} />
|
<DashboardGrid
|
||||||
|
dashboard={dashboard}
|
||||||
|
pending={pending}
|
||||||
|
failed={failed}
|
||||||
|
preparedItems={preparedItems}
|
||||||
|
preparedItemsPending={preparedItemsPending}
|
||||||
|
preparedItemsFailed={preparedItemsFailed}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue