Compare commits

..

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

111 changed files with 1110 additions and 13065 deletions

View file

@ -1,19 +0,0 @@
---
BasedOnStyle: LLVM
Language: Cpp
Standard: c++17
ColumnLimit: 100
IndentWidth: 4
InsertBraces: true
ContinuationIndentWidth: 4
UseTab: Never
BreakBeforeBraces: Attach
AllowShortBlocksOnASingleLine: Never
AllowShortCaseLabelsOnASingleLine: false
AllowShortEnumsOnASingleLine: false
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
BinPackArguments: false
BinPackParameters: false
SortIncludes: Never

View file

@ -17,10 +17,6 @@ Trainlog consists of:
The desktop SQLite database is the canonical long-term history.
The full-generation orchestrator and Web sync surface are implemented behind a
trusted local opt-in. Default deployed automatic exchange remains V3 until the
separate controlled-rollout decision.
Android has an independent local SQLite store for offline capture. SQLite
database files are never synchronized directly.
@ -170,7 +166,7 @@ It is not the canonical analytics store.
The Android UI is driven by exercise metadata, never by exercise-name
heuristics.
Android local SQLite schema v20 retains the exactly-one durable active-session
Android local SQLite schema v17 retains the exactly-one durable active-session
draft introduced in v4.
Every meaningful draft/form mutation is persisted by the repository. Back,
backgrounding and process death never delete the draft. Home offers explicit
@ -184,7 +180,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
durable flattened exercise-alias mapping used to resolve retired creator IDs.
Desktop schema v12 owns the same alias compatibility boundary. Later additive
desktop migrations reach v21 and Android migrations reach v20 without changing
desktop migrations reach v18 and Android migrations reach v17 without changing
that identity contract. The active
completed-session exchange is the separate strict
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
@ -241,12 +237,11 @@ Desktop SQLite schema is versioned with:
PRAGMA user_version;
```
The current desktop schema is v21. Schema v9 added an occurrence-owned explicit
The current desktop schema is v18. Schema v9 added an occurrence-owned explicit
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
flattened exercise aliases; v13-v18 add machine metadata, feedback and
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
generation/ACK ledgers. None changes `TRAINLOG_FORMAT_V1`.
storage/publication state. None changes `TRAINLOG_FORMAT_V1`.
Every incompatible schema evolution requires an explicit migration and
regression coverage.
@ -287,12 +282,6 @@ Exact UI strings, user data, identifiers, commands, paths, protocol fields,
version/status markers, citations, and historical evidence are not translated
merely to satisfy the prose-language rule.
Production code, tests, fixtures expressed as source, and repository tooling
must remain readable by a human reviewer. Do not compress functions, control
flow, declarations, assertions, or cleanup onto one line to reduce line or
token count. Grouped missions may and should use small comprehensible commits.
Perform an explicit readability review before the final commit.
Canonical documents:
- `README.md`;

View file

@ -2,119 +2,6 @@
## Unreleased — 0.1.2
- Completed the authorized private full-generation rollout over production
direct MTP: verified Android and desktop backups, signing-preserving Android
updates, schema migrations, persistent peer pairing, bidirectional durable
ACKs, restart and idempotent replay. This did not publish a release or modify
v0.1.1.
- Fixed rollout-discovered feedback idempotence across SQLite row modes,
packaged the complete generation exporter closure, ignored stale Android
coordination objects until their run/generation correlation matches, and
made the Android screen report completed generation conversations instead of
waiting for a legacy receipt.
- Packaged `trainlog-sync-once` and `trainlog-syncd` with the relocatable sync
candidate so user services cannot mix a versioned install with live-checkout
helpers during rollout.
- Added a bounded Android package-version override and made the schema-v17
backup bridge produce a signed release APK, allowing a strictly increasing
private update chain without changing Trainlog schemas or protocols.
- Generalized bridge restore/migration validation to preserve every historical
SQLite value whether a real backup contains an active draft or legitimately
contains none.
- Made relocatable launchers resolve stable user-bin symlinks to their physical
versioned bundle before loading matching binaries and helpers.
- Made the Web sync button generate cryptographic UUIDv4 request IDs on the
loopback HTTP hostname where `crypto.randomUUID()` is unavailable.
- Included the exercise-name and training-knowledge modules required by the
packaged generation worker, with an isolated runtime-import regression.
- Closed the native candidate warning gate with checked path and HTTP response
bounds, strict GCC and Clang builds, full Clang ASan/UBSan validation, and a
stable concurrent object-I/O MTP double. Rebuilt the desktop, current Android
and non-installed schema-v17 bridge candidates with recorded digests; no
service, device, user database, signing secret or deployment was touched.
- Normalized the recent synchronization, generation-MTP, Android backup,
Web Dashboard serialization, tests, I/O doubles and packaging code for human
readability. Added scoped C formatting rules and review guidance without
changing synchronization protocols, assertions, deployment state or runtime
behavior.
- Added the production libudev/libmtp generation adapter with bounded peer
selection, object transfer and manifest-last publication; wired the trusted
orchestrator mode and proved the full Firefox/Android/desktop conversation
with an I/O-boundary object double. Added complete versioned Android
backup/verify/restore and a reproducible, non-installed schema-v17 bridge
candidate with v17 round-trip and v17-to-v20 migration tests. Physical MTP,
signing, device installation, user-data operations and rollout remain
pending.
- Added opt-in foreground generation coordination, peer-evidenced capabilities,
truthful bounded Web progress, descendant cleanup, an integrated Android ↔
desktop Firefox proof, and synthetic candidate/SQLite recovery tooling on the
development branch. Physical MTP and deployment remain pending.
- Completed the grouped `TRAINLOG_SYNC_WEB_END_TO_END_V1` mission: the opt-in
full-generation orchestrator, protected asynchronous Web API, global sync
control, durable reconnect reporting, Dashboard refresh and read-only draft
summary pass isolated C, Python, Android, frontend and Firefox validation.
Default V3, physical MTP, Drive and deployment remain unchanged.
- Completed `TRAINLOG_SYNC_GENERATION_ACK_V1` with desktop schema v21 and
Android schema v20. Added coherent per-platform capture, strict bounded
manifest V1, immutable manifest-last publication, whole-generation SQLite
consumption, pair-scoped lineage, durable consumed/rejected ACK V1 replay,
and a separate causal-operation publication ledger. Real isolated Android →
desktop and desktop → Android chains use production entry points and retain
operation identity across generations. Automatic V3/MTP selection and all
operational services remain unchanged.
- Completed the residual causal-deletion evidence with real Android and
desktop producers for exercise, body-observation, custom-equipment, feedback
and BODY ZONE relation targets, including production replica exchange,
retained-history/current-reader checks, reopen/replay and stale-companion
refusal.
- Closed the causal-deletion correctness review: deletion predecessors now
cover affected session children, measurements, links, notes and immutable
feedback revisions; supported corrections preserve an intervening mutation
marker even after a content revert. Imported operations share built-in
authorization with local deletion, execution drafts use their stable
cross-platform revision identity, protected legacy companions refuse unsafe
replay, and causal artifact reads/exports enforce count and byte bounds
before destructive effects.
- Completed `TRAINLOG_SYNC_CAUSAL_DELETE_V1` with desktop schema v20 and
Android schema v19. Added a strict bounded staged causal-deletion artifact,
durable operation/state records, domain-specific deletion effects, causal
conflict and replay handling, and legacy snapshot non-resurrection gates.
Android and desktop both produce and consume the artifact in isolated tests;
automatic transport, generation acknowledgement and deployment remain
inactive.
- Completed `TRAINLOG_SYNC_DATA_LIFECYCLE_V1` with desktop schema v19 and
Android schema v18. Added explicit staged V4 enriched-history codecs,
causal notes, body-observation session links, stable execution-draft
identities, bounded pending drafts, explicit activation and atomic,
idempotent finalization with stale-replay protection. Active synchronization
remains V3; no service, user database, phone, tag or release artifact was
changed or deployed.
- Closed the lifecycle validation evidence with real Android/desktop V4
history and execution-draft round trips in both producer directions. Fixed
Android V4 import so validated target, load, and rest fields are persisted,
reconciled the 73/75 native count to the two `web=auto` frontend tests, and
documented identical pre-existing GCC 16 `-Wmisleading-indentation`
failures on baseline and branch while retaining Clang 22 strict green
builds. V4 remains staged and inactive.
- Completed `TRAINLOG_SYNC_CHARACTERIZATION_V1` without changing production
synchronization, schemas, or formats. Added a real Android V3 exporter →
desktop importer/exporter → fresh Android importer round trip, and completed

View file

@ -91,16 +91,6 @@ Android and the TUI in each stable release share the same Trainlog product
version on both mirrors. Database schemas and JSON protocol versions are
independent.
### Daily synchronization on an opted-in installation
Connect and unlock the paired phone, keep Trainlog's Synchronization screen in
the foreground, then select **Synchronize** in the local Web Dashboard and
**Synchronize now** on Android. Wait until Web reports completion and Android
shows that synchronization completed on both Android and PC. A failure remains
durable and should be diagnosed before retrying; do not delete generation,
ACK, database, or backup files. Full-generation mode is a trusted local opt-in;
other installations continue to use the established V3 request/receipt path.
### Build from source
Desktop:
@ -318,12 +308,11 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-17:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v21 and Android SQLite schema v20;
- desktop SQLite schema v18 and Android SQLite schema v17;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;
- mobile export V3 is active; V1/V2 remain readable legacy inputs; explicit V4
history and execution-draft codecs are staged but not selected by transport;
- mobile export V3 is active; V1/V2 remain readable legacy inputs;
- `STATS_V1=IMPLEMENTED`, `TRAINING_KNOWLEDGE_V1=PASS`, and
`SESSION_GENERATOR_V1=PASS` (the generator is hidden pending V2);
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
@ -332,10 +321,9 @@ As of 2026-09-17:
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
`WEB_DASHBOARD_V1=PASS/FROZEN`; the other four Web routes remain placeholders;
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
services; automatic synchronization still selects V3. The opt-in grouped Web
path is validated in isolation; the next boundary is
`TRAINLOG_SYNC_DEPLOYMENT_READINESS_V1`, not an authorized deployment.
- `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN /
IMPLEMENTATION_NOT_STARTED` and the next cursor remains
`TRAINLOG_SYNC_CHARACTERIZATION_V1`.
The latest executable result belongs in
[current state](docs/current_state.md), not in multiple README narratives.

View file

@ -35,16 +35,6 @@ val releaseSigningComplete = listOf(
releaseKeyPassword,
).all { !it.isNullOrBlank() }
/* CONTRACT: a private rollout may need an update-only version code above the
* repository default while preserving versionName, schemas and protocols.
* INVARIANT: the override remains a positive Android package version code and
* never changes the source default used by ordinary builds. */
val trainlogVersionCode =
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
} ?: 3
android {
namespace = "com.labfytools.trainlog"
compileSdk = 37
@ -54,7 +44,7 @@ android {
minSdk = 26
targetSdk = 36
versionCode = trainlogVersionCode
versionCode = 3
versionName = "0.1.2"
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"

View file

@ -1,345 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest
import java.time.OffsetDateTime
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import org.json.JSONArray
import org.json.JSONObject
internal sealed interface AndroidBackupResult {
data class Success(val bytes: Long) : AndroidBackupResult
data class Error(val message: String) : AndroidBackupResult
}
internal data class VerifiedAndroidBackup(
val directory: File,
val database: File,
val preferences: File,
)
/** Complete same-installation backup container; plaintext is explicit by design. */
internal class AndroidBackupService(private val context: Context) {
companion object {
const val FORMAT = "trainlog-android-backup"
const val VERSION = 1
const val MAX_ENTRY_BYTES = 768L * 1024L * 1024L
const val MAX_TOTAL_BYTES = 1024L * 1024L * 1024L
private val PREFERENCES = listOf("trainlog-sync", "trainlog_presentation_settings")
}
private fun digest(file: File): String {
val value = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
while (true) {
val count = input.read(buffer)
if (count < 0) break
value.update(buffer, 0, count)
}
}
return value.digest().joinToString("") { "%02x".format(it) }
}
private fun preferencesJson(): String =
JSONObject()
.apply {
put("format", "trainlog-android-preferences")
put("version", 1)
put(
"stores",
JSONObject().also { stores ->
PREFERENCES.forEach { name ->
stores.put(
name,
JSONObject().also { values ->
context
.getSharedPreferences(name, Context.MODE_PRIVATE)
.all
.toSortedMap()
.forEach { (key, value) ->
when (value) {
null -> values.put(key, JSONObject.NULL)
is String,
is Boolean,
is Int,
is Long,
is Float -> values.put(key, value)
is Set<*> ->
values.put(
key,
JSONArray(
value
.filterIsInstance<String>()
.sorted()
),
)
else -> error("Unsupported preference type")
}
}
},
)
}
},
)
}
.toString()
fun createPrivate(repository: TrainlogRepository): Result<File> = runCatching {
val directory = File(context.filesDir, "backups").apply { mkdirs() }
val archive = File(directory, "trainlog-backup-${java.util.UUID.randomUUID()}.tlbackup")
archive.outputStream().use { destination ->
val result = writeArchive(repository, destination)
if (result is AndroidBackupResult.Error) error(result.message)
}
archive
}
fun create(repository: TrainlogRepository, destination: OutputStream): AndroidBackupResult {
val archive =
createPrivate(repository).getOrElse {
return AndroidBackupResult.Error(it.message ?: "Backup failed.")
}
return try {
archive.inputStream().use { it.copyTo(destination) }
destination.flush()
val bytes = archive.length()
archive.delete()
AndroidBackupResult.Success(bytes)
} catch (error: Exception) {
AndroidBackupResult.Error(
"Export failed; private backup retained at ${archive.name}: ${error.message}"
)
}
}
private fun writeArchive(
repository: TrainlogRepository,
destination: OutputStream,
): AndroidBackupResult {
val stage = File(context.cacheDir, "backup-${java.util.UUID.randomUUID()}")
if (!stage.mkdir())
return AndroidBackupResult.Error("Cannot create private backup staging.")
val database = File(stage, "database.sqlite")
val preferences = File(stage, "preferences.json")
return try {
repository.backupDatabaseTo(database)
preferences.writeText(preferencesJson())
val schema = validateDatabase(database)
val files = listOf("database.sqlite" to database, "preferences.json" to preferences)
val manifest =
JSONObject()
.put("format", FORMAT)
.put("version", VERSION)
.put("package", context.packageName)
.put("schema", schema)
.put("product_version", "0.1.2")
.put("plaintext", true)
.put("created_at", OffsetDateTime.now().toString())
.put(
"entries",
JSONArray(
files.map { (name, file) ->
JSONObject()
.put("path", name)
.put("size", file.length())
.put("sha256", digest(file))
}
),
)
var written = 0L
ZipOutputStream(destination).use { zip ->
files.forEach { (name, file) ->
zip.putNextEntry(ZipEntry(name))
file.inputStream().use { input -> written += input.copyTo(zip) }
zip.closeEntry()
}
zip.putNextEntry(ZipEntry("manifest.json"))
val raw = manifest.toString().toByteArray()
zip.write(raw)
written += raw.size
zip.closeEntry()
}
AndroidBackupResult.Success(written)
} catch (error: Exception) {
AndroidBackupResult.Error(error.message ?: "Backup failed.")
} finally {
stage.deleteRecursively()
}
}
fun verify(input: InputStream): Result<VerifiedAndroidBackup> {
val stage = File(context.cacheDir, "restore-${java.util.UUID.randomUUID()}")
if (!stage.mkdir())
return Result.failure(IllegalStateException("Cannot create restore staging."))
return runCatching {
var total = 0L
val names = mutableSetOf<String>()
ZipInputStream(input).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
require(
!entry.isDirectory &&
entry.name in
setOf("database.sqlite", "preferences.json", "manifest.json") &&
names.add(entry.name)
) {
"Invalid backup entry."
}
val target = File(stage, entry.name)
target.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
var entryBytes = 0L
while (true) {
val count = zip.read(buffer)
if (count < 0) break
entryBytes += count
total += count
require(entryBytes <= MAX_ENTRY_BYTES && total <= MAX_TOTAL_BYTES) {
"Backup exceeds bounds."
}
output.write(buffer, 0, count)
}
}
}
}
require(names == setOf("database.sqlite", "preferences.json", "manifest.json")) {
"Backup entries are incomplete."
}
val manifest = JSONObject(File(stage, "manifest.json").readText())
require(
manifest.getString("format") == FORMAT &&
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..20
) {
"Unsupported backup."
}
val expected = manifest.getJSONArray("entries")
require(expected.length() == 2)
val manifestPaths = mutableSetOf<String>()
for (index in 0 until expected.length()) {
val item = expected.getJSONObject(index)
val path = item.getString("path")
require(
path in setOf("database.sqlite", "preferences.json") &&
manifestPaths.add(path)
) {
"Invalid manifest entry."
}
val file = File(stage, path)
require(
file.canonicalFile.parentFile == stage.canonicalFile &&
file.length() == item.getLong("size") &&
digest(file) == item.getString("sha256")
) {
"Backup digest mismatch."
}
}
require(manifestPaths == setOf("database.sqlite", "preferences.json")) {
"Backup manifest is incomplete."
}
require(
validateDatabase(File(stage, "database.sqlite")) == manifest.getInt("schema")
)
val preferences = JSONObject(File(stage, "preferences.json").readText())
require(
preferences.getString("format") == "trainlog-android-preferences" &&
preferences.getInt("version") == 1
)
VerifiedAndroidBackup(
stage,
File(stage, "database.sqlite"),
File(stage, "preferences.json"),
)
}
.onFailure { stage.deleteRecursively() }
}
fun restore(
repository: TrainlogRepository,
verified: VerifiedAndroidBackup,
databaseName: String = "trainlog-android.db",
): AndroidBackupResult {
val destination = context.getDatabasePath(databaseName)
val previous =
File(
destination.parentFile,
"${destination.name}.pre-restore-${java.util.UUID.randomUUID()}",
)
val previousPreferences =
File(context.cacheDir, "preferences-pre-restore-${java.util.UUID.randomUUID()}.json")
var moved = false
return try {
previousPreferences.writeText(preferencesJson())
repository.close()
destination.parentFile?.mkdirs()
if (destination.exists()) {
check(destination.renameTo(previous))
moved = true
}
File("${destination.path}-wal").delete()
File("${destination.path}-shm").delete()
verified.database.copyTo(destination, overwrite = false)
validateDatabase(destination)
restorePreferences(verified.preferences)
previous.delete()
previousPreferences.delete()
verified.directory.deleteRecursively()
AndroidBackupResult.Success(destination.length())
} catch (error: Exception) {
destination.delete()
if (moved) previous.renameTo(destination)
if (previousPreferences.isFile) runCatching { restorePreferences(previousPreferences) }
previousPreferences.delete()
AndroidBackupResult.Error(error.message ?: "Restore failed.")
}
}
private fun validateDatabase(file: File): Int {
val db = SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY)
try {
require(
db.rawQuery("PRAGMA integrity_check", null).use {
it.moveToFirst() && it.getString(0) == "ok"
}
)
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..20) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()
}
}
private fun restorePreferences(file: File) {
val stores = JSONObject(file.readText()).getJSONObject("stores")
PREFERENCES.forEach { name ->
val editor = context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear()
val values = stores.getJSONObject(name)
values.keys().forEach { key ->
when (val value = values.get(key)) {
is String -> editor.putString(key, value)
is Boolean -> editor.putBoolean(key, value)
is Int -> editor.putInt(key, value)
is Long -> editor.putLong(key, value)
is Double -> editor.putFloat(key, value.toFloat())
is JSONArray ->
editor.putStringSet(
key,
(0 until value.length()).map { value.getString(it) }.toSet(),
)
}
}
check(editor.commit())
}
}
}

View file

@ -1,133 +0,0 @@
package com.labfytools.trainlog.data
import java.io.File
import java.io.FileOutputStream
import java.time.Duration
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
internal sealed interface ForegroundGenerationResult {
data class Completed(val runId: String) : ForegroundGenerationResult
data class Error(val message: String) : ForegroundGenerationResult
}
/** Owns the bounded, user-initiated Android side of a generation conversation. */
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
private val service = SyncGenerationService(repository)
private fun publish(path: File, value: String) {
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
FileOutputStream(temporary).use { stream ->
stream.write(value.toByteArray())
stream.flush()
stream.fd.sync()
}
if (!temporary.renameTo(path)) throw SyncGenerationException("cannot publish ${path.name}")
}
private fun await(path: File, deadline: Long): File {
while (!path.isFile) {
if (System.nanoTime() >= deadline)
throw SyncGenerationException("timeout waiting for ${path.name}")
Thread.sleep(50)
}
return path
}
private fun awaitCorrelated(
path: File,
deadline: Long,
predicate: (JSONObject) -> Boolean,
): ByteArray {
while (true) {
if (path.isFile && path.length() <= 64 * 1024) {
try {
val raw = path.readBytes()
if (predicate(JSONObject(raw.toString(Charsets.UTF_8)))) return raw
} catch (_: Exception) {
// Publication replaces the file atomically, but an old or
// concurrently observed object is not evidence of failure.
}
}
if (System.nanoTime() >= deadline)
throw SyncGenerationException("timeout waiting for correlated ${path.name}")
Thread.sleep(50)
}
}
fun run(
directory: File,
timeout: Duration = Duration.ofMinutes(5),
afterPublication: (() -> Unit)? = null,
): ForegroundGenerationResult =
try {
if (!directory.isDirectory)
throw SyncGenerationException("exchange directory is unavailable")
val peer = service.peerId()
publish(
File(directory, "android-peer-v1.json"),
JSONObject()
.put("format", "trainlog-sync-peer")
.put("version", 1)
.put("peer_id", peer)
.put(
"capabilities",
JSONArray(
listOf(
"generation-manifest-v1",
"generation-ack-v1",
"causal-delete-v1",
"mobile-history-v4",
"execution-draft-v1",
)
),
)
.toString(),
)
val deadline = System.nanoTime() + timeout.toNanos()
val request = JSONObject(await(File(directory, "request-v1.json"), deadline).readText())
if (request.getString("android_peer_id") != peer)
throw SyncGenerationException("request targets another Android peer")
val captured =
service.capture(
directory,
request.getString("desktop_peer_id"),
request.getString("run_id"),
)
val published = service.publish(captured, File(directory, "android-objects"))
publish(
File(directory, "android-generation-v1.json"),
JSONObject()
.put("format", "trainlog-sync-generation-reference")
.put("version", 1)
.put("run_id", captured.runId)
.put("generation_id", captured.generationId)
.put("manifest_sha256", captured.manifestSha256)
.put("relative_path", published.relativeTo(directory).path)
.toString(),
)
afterPublication?.invoke()
// CONTRACT: durable objects from earlier conversations remain in
// the exchange directory. Only this run's exact generation ACK
// can advance its lineage; stale bytes are retained and ignored.
val desktopAck =
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline) {
it.optString("run_id") == captured.runId &&
it.optString("generation_id") == captured.generationId
}
service.acceptAcknowledgement(desktopAck)
val referenceRaw =
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline) {
it.optString("run_id") == captured.runId
}
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
val acknowledgement =
service.consume(File(directory, reference.getString("relative_path")))
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
ForegroundGenerationResult.Completed(captured.runId)
} catch (error: Exception) {
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
}
}

View file

@ -1,891 +0,0 @@
package com.labfytools.trainlog.data
import java.io.File
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
internal class SyncGenerationException(message: String) : IllegalArgumentException(message)
internal data class CapturedSyncGeneration(
val generationId: String,
val runId: String,
val manifestSha256: String,
val stagingDirectory: File,
)
/**
* Staged generation service. It is deliberately not wired into the active V3 transport. WHY:
* manifest publication and peer consumption need durable, independently retryable identities.
* CONTRACT: artifacts are immutable, bounded and published before the manifest marker. INVARIANT:
* causal operation payloads are never rewritten to attach generation identity.
*/
internal class SyncGenerationService(private val repository: TrainlogRepository) {
companion object {
const val MAX_MANIFEST_BYTES = 64 * 1024
const val MAX_ARTIFACTS = 32
const val MAX_ARTIFACT_BYTES = 64L * 1024L * 1024L
const val MAX_GENERATION_BYTES = 256L * 1024L * 1024L
const val MAX_LOGICAL_NAME_BYTES = 64
const val MAX_PATH_BYTES = 240
const val MAX_DIAGNOSTIC_BYTES = 1024
const val MAX_RETAINED_PER_PEER = 8
private val ID =
Regex(
"^(gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
private val DIGEST = Regex("^[0-9a-f]{64}$")
private data class Kind(
val format: String,
val version: Int,
val filename: String,
val required: Boolean = true,
)
private val ANDROID_KINDS =
linkedMapOf(
"catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.json"),
"history" to Kind("trainlog-mobile-export", 4, "history-v4.json"),
"execution-drafts" to
Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"),
"exercise-aliases" to
Kind("trainlog-exercise-aliases", 1, "exercise-aliases-v1.json"),
"exercise-profile-state" to
Kind("trainlog-exercise-profile-state", 1, "exercise-profile-state-v1.json"),
"equipment-definitions" to
Kind("trainlog-equipment-definitions", 1, "equipment-definitions-v1.json"),
"equipment-associations" to
Kind("trainlog-equipment-associations", 2, "equipment-associations-v2.json"),
"body-zones" to Kind("trainlog-exercise-body-zones", 1, "body-zones-v1.json"),
"feedback" to Kind("trainlog-training-feedback", 2, "feedback-v2.json"),
"causal-deletions" to
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
)
private val SUPPORTED =
(ANDROID_KINDS.values +
Kind("trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", false))
.map { it.format to it.version }
.toSet()
private fun sha256(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") {
"%02x".format(it)
}
private fun canonical(value: Any?): String =
when (value) {
null,
JSONObject.NULL -> "null"
is JSONObject ->
value.keys().asSequence().toList().sorted().joinToString(",", "{", "}") {
JSONObject.quote(it) + ":" + canonical(value.get(it))
}
is JSONArray ->
(0 until value.length()).joinToString(",", "[", "]") {
canonical(value.get(it))
}
is String -> JSONObject.quote(value)
is Boolean,
is Int,
is Long -> value.toString()
else -> throw SyncGenerationException("unsupported manifest JSON value")
}
private fun requireId(value: String, prefix: String) {
if (!value.startsWith("${prefix}_") || !ID.matches(value))
throw SyncGenerationException("invalid $prefix identity")
}
private fun safePath(value: String, generationId: String) {
if (
value.toByteArray(StandardCharsets.UTF_8).size > MAX_PATH_BYTES ||
'\u0000' in value ||
'\\' in value ||
value.startsWith('/')
)
throw SyncGenerationException("unsafe artifact path")
val parts = value.split('/')
if (
parts.size != 3 ||
parts.any { it.isEmpty() || it == "." || it == ".." } ||
parts[0] != "generations" ||
parts[1] != generationId
)
throw SyncGenerationException("artifact outside generation namespace")
}
private fun boundedJson(root: Any) {
val stack = java.util.ArrayDeque<Pair<Any, Int>>()
stack.add(root to 1)
var nodes = 0
while (stack.isNotEmpty()) {
val (value, depth) = stack.removeLast()
nodes++
if (depth > 8 || nodes > 8192)
throw SyncGenerationException("JSON nesting/node bound exceeded")
when (value) {
is JSONObject -> value.keys().forEach { stack.add(value.get(it) to depth + 1) }
is JSONArray ->
for (index in 0 until value.length()) stack.add(
value.get(index) to depth + 1
)
}
}
}
}
private fun stablePeerId(kind: String): String =
repository.inSyncGenerationTransaction { db ->
db.rawQuery("SELECT peer_id,kind FROM sync_peer_identity WHERE singleton=1", null)
.use { cursor ->
if (cursor.moveToFirst()) {
if (cursor.getString(1) != kind)
throw SyncGenerationException("peer kind conflict")
return@inSyncGenerationTransaction cursor.getString(0)
}
}
val value = "peer_${UUID.randomUUID()}"
db.execSQL(
"INSERT INTO sync_peer_identity(singleton,peer_id,kind) VALUES(1,?,?)",
arrayOf(value, kind),
)
value
}
/* CONTRACT: capability advertisements bind to this installation identity;
* callers cannot supply or replace it. */
internal fun peerId(): String = stablePeerId("android")
fun capture(
ownedRoot: File,
consumerPeerId: String,
runId: String = "sy_${UUID.randomUUID()}",
generationId: String = "gen_${UUID.randomUUID()}",
): CapturedSyncGeneration {
requireId(consumerPeerId, "peer")
requireId(runId, "sy")
requireId(generationId, "gen")
repository.inSyncGenerationTransaction { db ->
val retained =
db.rawQuery(
"SELECT COUNT(*) FROM sync_generations WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged')",
arrayOf(consumerPeerId),
)
.use {
it.moveToFirst()
it.getInt(0)
}
if (retained >= MAX_RETAINED_PER_PEER)
throw SyncGenerationException("generation retention capacity exhausted")
}
val producer = stablePeerId("android")
val stage = File(File(ownedRoot, "staging"), generationId)
if (stage.exists() || !stage.mkdirs())
throw SyncGenerationException("generation staging already exists")
try {
val artifacts = repository.captureSyncGenerationArtifacts()
if (artifacts.keys != ANDROID_KINDS.keys)
throw SyncGenerationException("incomplete Android domain capture")
val descriptors = JSONArray()
var total = 0L
artifacts.forEach { (logical, json) ->
val kind = checkNotNull(ANDROID_KINDS[logical])
val bytes = json.toByteArray(StandardCharsets.UTF_8)
if (bytes.size.toLong() > MAX_ARTIFACT_BYTES)
throw SyncGenerationException("artifact exceeds 64 MiB")
total = Math.addExact(total, bytes.size.toLong())
if (total > MAX_GENERATION_BYTES)
throw SyncGenerationException("generation exceeds 256 MiB")
val output = File(stage, kind.filename)
output.outputStream().use { it.write(bytes) }
output.setReadable(false, false)
output.setReadable(true, true)
output.setWritable(false, false)
output.setWritable(true, true)
descriptors.put(
JSONObject()
.put("logical_name", logical)
.put("format", kind.format)
.put("version", kind.version)
.put("filename", "generations/$generationId/${kind.filename}")
.put("size", bytes.size)
.put("sha256", sha256(bytes))
.put("required", kind.required)
)
}
val generatedAt = OffsetDateTime.now().toString()
val parent =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2",
arrayOf(producer, consumerPeerId),
)
.use {
if (!it.moveToFirst()) null
else {
val value = it.getString(0)
if (it.moveToNext())
throw SyncGenerationException(
"outgoing generation lineage has multiple tips"
)
value
}
}
}
val manifest =
JSONObject()
.put("format", "trainlog-sync-manifest")
.put("version", 1)
.put("generation_id", generationId)
.put("run_id", runId)
.put("producer", JSONObject().put("peer_id", producer).put("kind", "android"))
.put("consumer_peer_id", consumerPeerId)
.put("generated_at", generatedAt)
.put("parent_generation_id", parent ?: JSONObject.NULL)
.put("artifacts", descriptors)
val raw = canonical(manifest).toByteArray(StandardCharsets.UTF_8)
validateManifest(raw, consumerPeerId)
val digest = sha256(raw)
File(stage, "manifest.json").outputStream().use { it.write(raw) }
repository.inSyncGenerationTransaction { db ->
db.execSQL(
"INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,NULL)",
arrayOf(
generationId,
runId,
producer,
consumerPeerId,
"android",
generatedAt,
parent,
digest,
"captured",
raw.toString(StandardCharsets.UTF_8),
stage.absolutePath,
),
)
for (index in 0 until descriptors.length()) {
val item = descriptors.getJSONObject(index)
db.execSQL(
"INSERT INTO sync_generation_artifacts VALUES(?,?,?,?,?,?,?,?)",
arrayOf(
generationId,
item.getString("logical_name"),
item.getString("format"),
item.getInt("version"),
item.getString("filename"),
item.getLong("size"),
item.getString("sha256"),
if (item.getBoolean("required")) 1 else 0,
),
)
}
val causal = JSONObject(checkNotNull(artifacts["causal-deletions"]))
val operations = causal.getJSONArray("operations")
for (index in 0 until operations.length()) {
val operationId = operations.getJSONObject(index).getString("operation_id")
val existed =
db.rawQuery(
"SELECT 1 FROM sync_causal_publications WHERE operation_id=? LIMIT 1",
arrayOf(operationId),
)
.use { it.moveToFirst() }
db.execSQL(
"INSERT INTO sync_causal_publications VALUES(?,?,?)",
arrayOf(operationId, generationId, if (existed) 0 else 1),
)
}
}
return CapturedSyncGeneration(generationId, runId, digest, stage)
} catch (error: Exception) {
stage.deleteRecursively()
throw error
}
}
fun publish(captured: CapturedSyncGeneration, objectRoot: File): File {
val destination = File(File(objectRoot, "generations"), captured.generationId)
val sourceManifest = File(captured.stagingDirectory, "manifest.json").readBytes()
val manifest = validateManifest(sourceManifest, null)
val marker = File(destination, "manifest.json")
if (marker.isFile) {
if (sha256(marker.readBytes()) != captured.manifestSha256)
throw SyncGenerationException("same generation has different published bytes")
validatePublished(destination, manifest.getString("consumer_peer_id"))
} else {
if (
destination.exists() &&
(!destination.isDirectory ||
java.nio.file.Files.isSymbolicLink(destination.toPath()))
)
throw SyncGenerationException("unsafe generation namespace")
if (!destination.exists() && !destination.mkdirs())
throw SyncGenerationException("cannot create generation namespace")
val values = manifest.getJSONArray("artifacts")
for (index in 0 until values.length()) {
val name = values.getJSONObject(index).getString("filename").substringAfterLast('/')
val item = values.getJSONObject(index)
val source = File(captured.stagingDirectory, name)
if (
!source.isFile ||
java.nio.file.Files.isSymbolicLink(source.toPath()) ||
source.canonicalFile.parentFile !=
captured.stagingDirectory.canonicalFile ||
source.length() != item.getLong("size") ||
sha256(source.readBytes()) != item.getString("sha256")
)
throw SyncGenerationException("staging artifact changed after capture")
val target = File(destination, name)
if (target.exists()) {
if (
java.nio.file.Files.isSymbolicLink(target.toPath()) ||
target.length() != item.getLong("size") ||
sha256(target.readBytes()) != item.getString("sha256")
)
throw SyncGenerationException("conflicting partial publication")
} else
source.inputStream().use { input ->
target.outputStream().use { output -> input.copyTo(output, 1024 * 1024) }
}
}
// The manifest is the visibility/commit marker and is always last.
marker.outputStream().use { it.write(sourceManifest) }
}
repository.inSyncGenerationTransaction { db ->
db.execSQL(
"UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=? AND status IN('captured','published','waiting_acknowledgement')",
arrayOf(captured.generationId),
)
}
return destination
}
fun validateManifest(raw: ByteArray, expectedConsumer: String?): JSONObject {
if (
raw.size > MAX_MANIFEST_BYTES ||
!repository.syncGenerationJsonHasUniqueKeys(raw.toString(StandardCharsets.UTF_8))
)
throw SyncGenerationException("invalid or oversized manifest JSON")
val root =
try {
JSONObject(raw.toString(StandardCharsets.UTF_8))
} catch (_: Exception) {
throw SyncGenerationException("invalid manifest JSON")
}
boundedJson(root)
val keys =
setOf(
"format",
"version",
"generation_id",
"run_id",
"producer",
"consumer_peer_id",
"generated_at",
"parent_generation_id",
"artifacts",
)
if (
root.keys().asSequence().toSet() != keys ||
root.optString("format") != "trainlog-sync-manifest" ||
root.opt("version") !is Int ||
root.getInt("version") != 1
)
throw SyncGenerationException("unsupported manifest shape/version")
val generation = root.getString("generation_id")
requireId(generation, "gen")
requireId(root.getString("run_id"), "sy")
val producer = root.getJSONObject("producer")
if (
producer.keys().asSequence().toSet() != setOf("peer_id", "kind") ||
producer.getString("kind") !in setOf("android", "desktop")
)
throw SyncGenerationException("invalid producer")
requireId(producer.getString("peer_id"), "peer")
val consumer = root.getString("consumer_peer_id")
requireId(consumer, "peer")
if (expectedConsumer != null && consumer != expectedConsumer)
throw SyncGenerationException("wrong consumer")
if (consumer == producer.getString("peer_id"))
throw SyncGenerationException("producer and consumer must differ")
if (!root.isNull("parent_generation_id")) {
requireId(root.getString("parent_generation_id"), "gen")
if (root.getString("parent_generation_id") == generation)
throw SyncGenerationException("generation cannot parent itself")
}
try {
OffsetDateTime.parse(root.getString("generated_at"))
} catch (_: Exception) {
throw SyncGenerationException("invalid generated_at")
}
val values = root.getJSONArray("artifacts")
if (values.length() !in 1..MAX_ARTIFACTS)
throw SyncGenerationException("artifact descriptor bound")
val names = mutableSetOf<String>()
val paths = mutableSetOf<String>()
var total = 0L
for (index in 0 until values.length()) {
val item = values.getJSONObject(index)
if (
item.keys().asSequence().toSet() !=
setOf(
"logical_name",
"format",
"version",
"filename",
"size",
"sha256",
"required",
)
)
throw SyncGenerationException("invalid artifact descriptor")
val name = item.getString("logical_name")
if (
!name.all { it.code in 0..127 } ||
name.toByteArray().size !in 1..MAX_LOGICAL_NAME_BYTES ||
!names.add(name)
)
throw SyncGenerationException("invalid logical name")
val path = item.getString("filename")
safePath(path, generation)
if (!paths.add(path)) throw SyncGenerationException("duplicate artifact path")
if (
item.opt("version") !is Int ||
item.getInt("version") < 1 ||
item.opt("size") !is Number ||
item.getLong("size") !in 0..MAX_ARTIFACT_BYTES ||
item.opt("required") !is Boolean ||
!DIGEST.matches(item.getString("sha256"))
)
throw SyncGenerationException("invalid artifact metadata")
if (
item.getBoolean("required") &&
(item.getString("format") to item.getInt("version")) !in SUPPORTED
)
throw SyncGenerationException("unsupported required capability")
total = Math.addExact(total, item.getLong("size"))
if (total > MAX_GENERATION_BYTES)
throw SyncGenerationException("generation exceeds bound")
}
if (!ANDROID_KINDS.keys.all { it in names })
throw SyncGenerationException("missing required domain")
return root
}
private data class ValidatedGeneration(
val manifest: JSONObject,
val digest: String,
val artifacts: Map<String, String>,
)
private fun validatePublished(directory: File, expectedConsumer: String): ValidatedGeneration {
if (directory.isDirectory.not() || java.nio.file.Files.isSymbolicLink(directory.toPath()))
throw SyncGenerationException("generation directory missing or unsafe")
val marker = File(directory, "manifest.json")
if (
!marker.isFile ||
java.nio.file.Files.isSymbolicLink(marker.toPath()) ||
marker.length() > MAX_MANIFEST_BYTES
)
throw SyncGenerationException("complete manifest marker missing")
val raw = marker.readBytes()
val manifest = validateManifest(raw, expectedConsumer)
val artifacts = linkedMapOf<String, String>()
var total = 0L
val values = manifest.getJSONArray("artifacts")
for (index in 0 until values.length()) {
val item = values.getJSONObject(index)
val filename = item.getString("filename").substringAfterLast('/')
val file = File(directory, filename)
if (
!file.isFile ||
java.nio.file.Files.isSymbolicLink(file.toPath()) ||
file.canonicalFile.parentFile != directory.canonicalFile
)
throw SyncGenerationException("missing or unsafe listed artifact")
val declared = item.getLong("size")
if (file.length() != declared || declared > MAX_ARTIFACT_BYTES)
throw SyncGenerationException("artifact size mismatch")
total = Math.addExact(total, declared)
if (total > MAX_GENERATION_BYTES)
throw SyncGenerationException("generation exceeds bound")
val digest = MessageDigest.getInstance("SHA-256")
val bytes =
file.inputStream().use { input ->
val output = java.io.ByteArrayOutputStream(declared.toInt())
val buffer = ByteArray(1024 * 1024)
var count: Int
var seen = 0L
while (input.read(buffer).also { count = it } >= 0) {
if (count == 0) continue
seen += count
if (seen > declared)
throw SyncGenerationException("artifact grew during staging")
digest.update(buffer, 0, count)
output.write(buffer, 0, count)
}
output.toByteArray()
}
if (digest.digest().joinToString("") { "%02x".format(it) } != item.getString("sha256"))
throw SyncGenerationException("artifact digest mismatch")
artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8)
}
return ValidatedGeneration(manifest, sha256(raw), artifacts)
}
private fun failure(result: Any): String? =
when (result) {
is PcCatalogImportResult.Applied,
is ExerciseProfileStateImportResult.Applied,
is EquipmentDefinitionImportResult.Applied,
is MobileSessionImportResult.Applied,
is EquipmentAssociationImportResult.Applied,
is ExerciseBodyZoneImportResult.Applied,
is TrainingFeedbackImportResult.Applied,
is ExerciseAliasImportResult.Applied,
is ExecutionDraftImportResult.Applied,
is AiSessionDraftImportResult.Applied,
is CausalDeleteResult.Applied,
is CausalDeleteResult.Unchanged -> null
else -> result.toString()
}
private fun recordRejected(
manifest: JSONObject,
digest: String,
localPeer: String,
error: SyncGenerationException,
): String =
repository.inSyncGenerationTransaction { db ->
val generation = manifest.getString("generation_id")
db.rawQuery(
"SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?",
arrayOf(generation),
)
.use { known ->
if (known.moveToFirst()) {
if (known.getString(0) != digest)
throw SyncGenerationException(
"generation identity reused with different manifest"
)
return@inSyncGenerationTransaction known.getString(1)
}
}
var diagnostic = error.message ?: "generation rejected"
while (
diagnostic.toByteArray(StandardCharsets.UTF_8).size > MAX_DIAGNOSTIC_BYTES
) diagnostic = diagnostic.dropLast(1)
val at = OffsetDateTime.now().toString()
val producer = manifest.getJSONObject("producer").getString("peer_id")
val ack =
JSONObject()
.put("format", "trainlog-sync-ack")
.put("version", 1)
.put("ack_id", "ack_${generation.removePrefix("gen_")}")
.put("run_id", manifest.getString("run_id"))
.put("generation_id", generation)
.put("producer_peer_id", producer)
.put("consumer_peer_id", localPeer)
.put("manifest_sha256", digest)
.put("result", "rejected")
.put("durability", "sqlite-commit-rejection")
.put("consumed_at", at)
.put("diagnostic", diagnostic)
ack.put("payload_sha256", sha256(canonical(ack).toByteArray(StandardCharsets.UTF_8)))
val json = canonical(ack)
db.execSQL(
"INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",
arrayOf(
generation,
manifest.getString("run_id"),
producer,
localPeer,
if (manifest.isNull("parent_generation_id")) null
else manifest.getString("parent_generation_id"),
digest,
at,
"rejected",
"sqlite-commit-rejection",
diagnostic,
json,
),
)
db.execSQL(
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
arrayOf(
ack.getString("ack_id"),
generation,
manifest.getString("run_id"),
producer,
localPeer,
digest,
"rejected",
"sqlite-commit-rejection",
at,
diagnostic,
ack.getString("payload_sha256"),
),
)
json
}
/**
* Validates all bytes before mutation, then applies every listed domain and the durable
* consumption/ACK record in one outer SQLite transaction.
*/
fun consume(directory: File): String {
val localPeer = stablePeerId("android")
val frozen = validatePublished(directory, localPeer)
val manifest = frozen.manifest
val generation = manifest.getString("generation_id")
return try {
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?",
arrayOf(generation),
)
.use { known ->
if (known.moveToFirst()) {
if (known.getString(0) != frozen.digest)
throw SyncGenerationException(
"generation identity reused with different manifest"
)
return@inSyncGenerationTransaction known.getString(1)
}
}
val producerPeer = manifest.getJSONObject("producer").getString("peer_id")
val expectedParent =
db.rawQuery(
"SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2",
arrayOf(producerPeer),
)
.use {
if (!it.moveToFirst()) null
else {
val value = it.getString(0)
if (it.moveToNext())
throw SyncGenerationException(
"consumed generation lineage has multiple tips"
)
value
}
}
val suppliedParent =
if (manifest.isNull("parent_generation_id")) null
else manifest.getString("parent_generation_id")
if (suppliedParent != expectedParent)
throw SyncGenerationException("generation lineage is stale or unrelated")
val a = frozen.artifacts
fun apply(name: String, result: () -> Any) {
val json =
a[name] ?: throw SyncGenerationException("missing required domain: $name")
failure(result())?.let { throw SyncGenerationException("$name rejected: $it") }
}
// Tombstones are visible before live snapshots; typed generation
// imports know the complete causal envelope is present.
apply("causal-deletions") {
repository.applyCausalDeletionExportV1Json(checkNotNull(a["causal-deletions"]))
}
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
apply("exercise-profile-state") {
repository.applyExerciseProfileStateJson(
checkNotNull(a["exercise-profile-state"]),
allowPending = true,
)
}
apply("exercise-aliases") {
repository.applyExerciseAliasesJson(checkNotNull(a["exercise-aliases"]))
}
apply("equipment-definitions") {
repository.applyPcEquipmentDefinitionsJson(
checkNotNull(a["equipment-definitions"]),
true,
)
}
apply("history") {
repository.applyPcMobileExportV4Json(checkNotNull(a["history"]))
}
apply("equipment-associations") {
repository.applyPcEquipmentAssociationsJson(
checkNotNull(a["equipment-associations"])
)
}
apply("body-zones") {
repository.applyExerciseBodyZonesJson(checkNotNull(a["body-zones"]), true)
}
apply("feedback") {
repository.applyTrainingFeedbackJson(checkNotNull(a["feedback"]), true)
}
apply("execution-drafts") {
repository.applyExecutionDraftExportV1Json(checkNotNull(a["execution-drafts"]))
}
if ("ai-proposals" in a)
apply("ai-proposals") {
repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"]))
}
val consumedAt = OffsetDateTime.now().toString()
val ack =
JSONObject()
.put("format", "trainlog-sync-ack")
.put("version", 1)
.put("ack_id", "ack_${generation.removePrefix("gen_")}")
.put("run_id", manifest.getString("run_id"))
.put("generation_id", generation)
.put(
"producer_peer_id",
manifest.getJSONObject("producer").getString("peer_id"),
)
.put("consumer_peer_id", localPeer)
.put("manifest_sha256", frozen.digest)
.put("result", "consumed")
.put("durability", "sqlite-commit-full")
.put("consumed_at", consumedAt)
.put("diagnostic", "")
val payload = canonical(ack)
ack.put("payload_sha256", sha256(payload.toByteArray(StandardCharsets.UTF_8)))
val ackJson = canonical(ack)
db.execSQL(
"INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",
arrayOf(
generation,
manifest.getString("run_id"),
manifest.getJSONObject("producer").getString("peer_id"),
localPeer,
suppliedParent,
frozen.digest,
consumedAt,
"consumed",
"sqlite-commit-full",
"",
ackJson,
),
)
db.execSQL(
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
arrayOf(
ack.getString("ack_id"),
generation,
manifest.getString("run_id"),
manifest.getJSONObject("producer").getString("peer_id"),
localPeer,
frozen.digest,
"consumed",
"sqlite-commit-full",
consumedAt,
"",
ack.getString("payload_sha256"),
),
)
ackJson
}
} catch (error: SyncGenerationException) {
recordRejected(manifest, frozen.digest, localPeer, error)
}
}
fun acceptAcknowledgement(raw: ByteArray): String {
if (
raw.size > MAX_MANIFEST_BYTES ||
!repository.syncGenerationJsonHasUniqueKeys(raw.toString(StandardCharsets.UTF_8))
)
throw SyncGenerationException("invalid ACK JSON")
val ack =
try {
JSONObject(raw.toString(StandardCharsets.UTF_8))
} catch (_: Exception) {
throw SyncGenerationException("invalid ACK JSON")
}
boundedJson(ack)
val keys =
setOf(
"format",
"version",
"ack_id",
"run_id",
"generation_id",
"producer_peer_id",
"consumer_peer_id",
"manifest_sha256",
"result",
"durability",
"consumed_at",
"diagnostic",
"payload_sha256",
)
if (
ack.keys().asSequence().toSet() != keys ||
ack.optString("format") != "trainlog-sync-ack" ||
ack.opt("version") !is Int ||
ack.getInt("version") != 1 ||
ack.getString("result") !in setOf("consumed", "rejected") ||
ack.getString("durability") !=
(if (ack.getString("result") == "consumed") "sqlite-commit-full"
else "sqlite-commit-rejection") ||
ack.getString("diagnostic").toByteArray().size > MAX_DIAGNOSTIC_BYTES
)
throw SyncGenerationException("unsupported ACK")
requireId(ack.getString("run_id"), "sy")
requireId(ack.getString("generation_id"), "gen")
requireId(ack.getString("producer_peer_id"), "peer")
requireId(ack.getString("consumer_peer_id"), "peer")
if (
ack.getString("ack_id") !=
"ack_${ack.getString("generation_id").removePrefix("gen_")}" ||
!DIGEST.matches(ack.getString("manifest_sha256"))
)
throw SyncGenerationException("invalid ACK identity")
try {
OffsetDateTime.parse(ack.getString("consumed_at"))
} catch (_: Exception) {
throw SyncGenerationException("invalid ACK timestamp")
}
val payload = JSONObject(ack.toString())
val claimed =
payload.remove("payload_sha256") as? String
?: throw SyncGenerationException("ACK digest missing")
if (
!DIGEST.matches(claimed) ||
sha256(canonical(payload).toByteArray(StandardCharsets.UTF_8)) != claimed
)
throw SyncGenerationException("ACK digest mismatch")
return repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT run_id,producer_peer_id,consumer_peer_id,manifest_sha256,status FROM sync_generations WHERE generation_id=?",
arrayOf(ack.getString("generation_id")),
)
.use { row ->
if (!row.moveToFirst())
throw SyncGenerationException("ACK has no pending generation")
if (
ack.getString("run_id") != row.getString(0) ||
ack.getString("producer_peer_id") != row.getString(1) ||
ack.getString("consumer_peer_id") != row.getString(2) ||
ack.getString("manifest_sha256") != row.getString(3)
)
throw SyncGenerationException("ACK does not match pending generation")
val target =
if (ack.getString("result") == "consumed") "acknowledged" else "rejected"
if (row.getString(4) in setOf("acknowledged", "rejected")) {
if (row.getString(4) != target)
throw SyncGenerationException("conflicting ACK replay")
return@inSyncGenerationTransaction "unchanged"
}
if (row.getString(4) != "waiting_acknowledgement")
throw SyncGenerationException("generation is not awaiting ACK")
db.execSQL(
"UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",
arrayOf(
target,
ack.getString("consumed_at"),
ack.getString("generation_id"),
),
)
target
}
}
}
}

View file

@ -14,11 +14,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
sealed interface SyncRequestResult {
/** The full-generation coordinator completed both durable peer ACKs. */
data class Completed(
val runId: String,
) : SyncRequestResult
data class Requested(
val requestId: String,
) : SyncRequestResult

View file

@ -101,13 +101,6 @@ data class SessionDraftForm(
* mistake unfinished capture for a completed session.
*/
data class ActiveSessionDraft(
/** Stable lifecycle identity, allocated once when first persisted. */
val sessionId: String? = null,
/** Causal draft revision; local UI-only persistence may retain its tip. */
val revisionId: String = "dr_legacy_v1",
val parentRevisionId: String? = null,
/** Genuine lifecycle start, distinct from local form update time. */
val startedAt: String? = null,
val exercises: List<SessionExerciseDraft> = emptyList(),
val sessionType: SessionType = SessionType.TRAINING,
/** Existing max_test session updated atomically on finalization, if any. */

View file

@ -31,8 +31,6 @@ import com.labfytools.trainlog.data.KnowledgeConfidence
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.StartAiSessionDraftResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.AndroidBackupService
import com.labfytools.trainlog.data.AndroidBackupResult
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@ -277,7 +275,7 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
).joinToString(", ")
@Composable
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
val colors = LocalTrainlogColors.current
val context = LocalContext.current
val language = LocalLanguagePresentation.current
@ -300,25 +298,6 @@ fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCa
}
}
}
val backupService = remember { AndroidBackupService(context) }
val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
if (uri != null) {
val result = runCatching { context.contentResolver.openOutputStream(uri, "w")!!.use { backupService.create(repository, it) } }.getOrElse { AndroidBackupResult.Error(it.message ?: "Backup export failed.") }
message = strings.getString(if (result is AndroidBackupResult.Success) R.string.settings_backup_complete else R.string.settings_backup_failed)
}
}
val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
val verified = runCatching { context.contentResolver.openInputStream(uri)!!.use { backupService.verify(it).getOrThrow() } }
val result = verified.fold({ backupService.restore(repository, it) }, { AndroidBackupResult.Error(it.message ?: "Backup verification failed.") })
if (result is AndroidBackupResult.Error) {
android.widget.Toast.makeText(context, strings.getString(R.string.settings_restore_failed), android.widget.Toast.LENGTH_LONG).show()
}
// Restore closes the repository before replacement. Recreate even
// after a rolled-back failure so no UI owner retains that handle.
(context as? android.app.Activity)?.recreate()
}
}
TrainlogScreen(strings.getString(R.string.settings_screen)) {
TrainlogFrame(strings.getString(R.string.settings_language_section)) {
TrainlogInfo("${strings.getString(R.string.settings_language_label)} : " +
@ -352,12 +331,5 @@ fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCa
})
message?.let { TrainlogInfo(it, if (authorized) colors.success else colors.error) }
}
TrainlogFrame(strings.getString(R.string.settings_backup_section)) {
TrainlogInfo(strings.getString(R.string.settings_backup_plaintext))
TrainlogAction(strings.getString(R.string.settings_backup_create), strings.getString(R.string.settings_backup_create_description),
{ backupLauncher.launch("trainlog-backup.tlbackup") })
TrainlogAction(strings.getString(R.string.settings_backup_restore), strings.getString(R.string.settings_backup_restore_description),
{ restoreLauncher.launch(arrayOf("application/zip", "application/octet-stream")) })
}
}
}

View file

@ -25,10 +25,6 @@ import com.labfytools.trainlog.data.SyncExportResult
import com.labfytools.trainlog.data.SyncReceiptResult
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ForegroundGenerationResult
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
import com.labfytools.trainlog.data.canonicalExchangeDirectory
import com.labfytools.trainlog.data.logDirectExchange
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@ -41,24 +37,9 @@ import com.labfytools.trainlog.R
/* Package-visible for orchestration tests: this is the production ordering
* boundary, not a duplicate test-only implementation. */
internal suspend fun publishBundleAndRequest(
repository: TrainlogRepository,
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
): SyncRequestResult = withContext(Dispatchers.IO) {
val optIn = java.io.File(canonicalExchangeDirectory(), "trainlog-sync-generation-opt-in-v1.json")
if (optIn.isFile) {
val enabled = try {
val value = org.json.JSONObject(optIn.readText())
value.keys().asSequence().toSet() == setOf("format", "version", "enabled") &&
value.optString("format") == "trainlog-sync-generation-opt-in" &&
value.opt("version") is Int && value.getInt("version") == 1 && value.opt("enabled") == true
} catch (_: Exception) { false }
if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.")
return@withContext when (val result = SyncGenerationForegroundCoordinator(repository).run(canonicalExchangeDirectory())) {
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
}
}
logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
when (val opened = exporter.openStorageSnapshot()) {
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
@ -97,7 +78,6 @@ internal fun visibleReceiptStatus(
@Composable
fun SyncScreen(
repository: TrainlogRepository,
inbox: SyncCatalogInbox,
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
@ -335,15 +315,7 @@ fun SyncScreen(
syncRunning = true
coroutineScope.launch {
try {
when (val result = publishBundleAndRequest(repository, exporter, requestOutbox)) {
is SyncRequestResult.Completed -> {
// The generation coordinator has already consumed the
// desktop return and published its durable ACK. Waiting
// for the legacy receipt here would leave a false spinner.
success = true
pendingRequestId = null
status = strings.getString(R.string.sync_generation_complete)
}
when (val result = publishBundleAndRequest(exporter, requestOutbox)) {
is SyncRequestResult.Requested -> {
success = true
pendingRequestId = result.requestId

View file

@ -139,8 +139,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.Statistics -> StatisticsDashboard(repository)
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
AppRoute.Sync -> SyncScreen(inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(inbox) { exportSnapshot(); catalogRevision++ }
}}
}

View file

@ -75,7 +75,6 @@
<string name="sync_flow_description">Android → PC then PC → Android, in one operation.</string>
<string name="sync_authorize_first">File access is required. Authorize Documents/Trainlog first.</string>
<string name="sync_request_sent">Request sent · waiting for the PC…</string>
<string name="sync_generation_complete">Synchronization completed on Android and PC.</string>
<string name="sync_android_unsupported">Android is not supported.</string>
<string name="sync_folder_section">EXCHANGE FOLDER</string>
<string name="sync_authorize_folder">Authorize access to the Trainlog folder</string>
@ -397,13 +396,4 @@
<string name="exercise_order_saved">Session order saved.</string>
<string name="exercise_reorder_failed">Reorder was not saved: %1$s</string>
<string name="invalid_decimal_value">Enter a valid decimal value using “,” or “.”.</string>
<string name="settings_backup_section">Complete local backup</string>
<string name="settings_backup_plaintext">The file contains plaintext health data. Keep it in a private location.</string>
<string name="settings_backup_create">Create backup</string>
<string name="settings_backup_create_description">Export the complete database, active draft, and preferences.</string>
<string name="settings_backup_restore">Restore backup</string>
<string name="settings_backup_restore_description">Verify and restore this installation. Synchronization must remain stopped.</string>
<string name="settings_backup_complete">Complete backup exported.</string>
<string name="settings_backup_failed">Backup failed; any private staged copy was retained.</string>
<string name="settings_restore_failed">The backup is invalid or could not be restored.</string>
</resources>

View file

@ -75,7 +75,6 @@
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
<string name="sync_generation_complete">Synchronisation terminée sur Android et PC.</string>
<string name="sync_android_unsupported">Android non supporté.</string>
<string name="sync_folder_section">DOSSIER D’ÉCHANGE</string>
<string name="sync_authorize_folder">Autoriser l’accès au dossier Trainlog</string>
@ -397,13 +396,4 @@
<string name="exercise_order_saved">Ordre de la séance enregistré.</string>
<string name="exercise_reorder_failed">Réorganisation non enregistrée : %1$s</string>
<string name="invalid_decimal_value">Saisissez une valeur décimale valide avec « , » ou « . ».</string>
<string name="settings_backup_section">Sauvegarde locale complète</string>
<string name="settings_backup_plaintext">Le fichier contient vos données de santé en clair. Conservez-le dans un emplacement privé.</string>
<string name="settings_backup_create">Créer une sauvegarde</string>
<string name="settings_backup_create_description">Exporter la base complète, le brouillon actif et les préférences.</string>
<string name="settings_backup_restore">Restaurer une sauvegarde</string>
<string name="settings_backup_restore_description">Vérifier puis restaurer cette installation. La synchronisation doit rester arrêtée.</string>
<string name="settings_backup_complete">Sauvegarde complète exportée.</string>
<string name="settings_backup_failed">Échec de la sauvegarde ; toute copie privée créée est conservée.</string>
<string name="settings_restore_failed">Sauvegarde invalide ou restauration impossible.</string>
</resources>

View file

@ -1,96 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraftForm
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidBackupServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun completeBackupRestoresDraftPeerLedgersAndPreferences() {
val name = "backup-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
val service = AndroidBackupService(context)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Backup exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created)
.exercise
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 9, weightKg = 31.5)),
)
),
form = SessionDraftForm(selectedExercise = exercise, weightText = "31,"),
)
),
)
val peer = SyncGenerationService(repository).peerId()
context
.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE)
.edit()
.putString("language", "en")
.commit()
val output = ByteArrayOutputStream()
assertTrue(service.create(repository, output) is AndroidBackupResult.Success)
val verified = service.verify(ByteArrayInputStream(output.toByteArray())).getOrThrow()
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
repository = TrainlogRepository(context, name)
val draft = repository.loadActiveSessionDraft()
assertTrue(draft is ActiveDraftLoadResult.Loaded)
assertEquals("31,", (draft as ActiveDraftLoadResult.Loaded).draft.form.weightText)
assertEquals(peer, SyncGenerationService(repository).peerId())
assertEquals(
"en",
context
.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE)
.getString("language", null),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun corruptAndTraversalContainersAreRejectedBeforeRestore() {
val service = AndroidBackupService(context)
val output = ByteArrayOutputStream()
java.util.zip.ZipOutputStream(output).use { zip ->
zip.putNextEntry(java.util.zip.ZipEntry("../database.sqlite"))
zip.write(byteArrayOf(1))
zip.closeEntry()
}
assertTrue(service.verify(ByteArrayInputStream(output.toByteArray())).isFailure)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,129 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.io.File
import java.io.FileInputStream
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BridgeBackupMigrationTest {
@Test
fun verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner() {
val source = System.getenv("TRAINLOG_BRIDGE_BACKUP_INPUT")?.let(::File)
assumeTrue(source?.isFile == true)
val context: Context = ApplicationProvider.getApplicationContext()
val name = "bridge-migrate-${UUID.randomUUID()}.db"
val service = AndroidBackupService(context)
var repository = TrainlogRepository(context, name)
try {
val verified = FileInputStream(source!!).use { service.verify(it).getOrThrow() }
val historical = historicalDump(verified.database)
val activeDraft = historical.entries.single { it.key.startsWith("active_session_draft|") }
val hadActiveDraft = activeDraft.value.isNotEmpty()
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
repository = TrainlogRepository(context, name)
assertEquals(
hadActiveDraft,
repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded,
)
val migrated = context.getDatabasePath(name)
assertEquals(historical, historicalDump(migrated, historical.keys))
SQLiteDatabase.openDatabase(
migrated.path,
null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
)
.use { assertEquals(20, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
}
/**
* CONTRACT: every pre-migration column and value is compared exactly;
* additive v18-v20 tables/columns are outside the historical shape.
* INVARIANT: the supplied archive is opened read-only and only the
* test-owned restored database is migrated.
*/
private fun historicalDump(
databasePath: File,
shape: Set<String>? = null,
): Map<String, List<List<String>>> =
SQLiteDatabase.openDatabase(
databasePath.path,
null,
SQLiteDatabase.OPEN_READONLY,
)
.use { database ->
val tables =
database
.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' " +
"AND name NOT LIKE 'sqlite_%' AND name<>'android_metadata' " +
"ORDER BY name;",
null,
)
.use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.getString(0))
}
}
val specifications =
shape
?: tables
.map { table ->
val columns =
database.rawQuery("PRAGMA table_info(`$table`);", null).use {
cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.getString(1))
}
}
"$table|${columns.joinToString(",")}"
}
.toSet()
specifications.associateWith { specification ->
val table = specification.substringBefore('|')
val columns = specification.substringAfter('|').split(',')
val selection = columns.joinToString(",") { "`$it`" }
database
.rawQuery(
"SELECT $selection FROM `$table` ORDER BY rowid;",
null,
)
.use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
(0 until cursor.columnCount).map { column ->
when (cursor.getType(column)) {
android.database.Cursor.FIELD_TYPE_NULL -> "null"
android.database.Cursor.FIELD_TYPE_INTEGER ->
"integer:${cursor.getLong(column)}"
android.database.Cursor.FIELD_TYPE_FLOAT ->
"float:${java.lang.Double.toHexString(cursor.getDouble(column))}"
android.database.Cursor.FIELD_TYPE_STRING ->
"string:${cursor.getString(column)}"
android.database.Cursor.FIELD_TYPE_BLOB ->
"blob:${cursor.getBlob(column).contentToString()}"
else -> error("Unknown SQLite field type")
}
}
)
}
}
}
}
}
}

View file

@ -1,150 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.File
import java.time.Duration
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SyncDeploymentConversationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun awaitFile(file: File): File {
val deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos()
while (!file.isFile) {
if (System.nanoTime() >= deadline) error("timeout waiting for ${file.name}")
Thread.sleep(25)
}
return file
}
private fun publish(file: File, value: String) {
val temporary = File(file.parentFile, ".${file.name}.tmp-${UUID.randomUUID()}")
temporary.outputStream().use { output ->
output.write(value.toByteArray())
output.flush()
output.fd.sync()
}
check(temporary.renameTo(file)) { "cannot publish ${file.name}" }
}
@Test
fun foregroundAndroidServiceCompletesCorrelatedDesktopConversation() {
val suppliedTransport = System.getenv("TRAINLOG_SYNC_TRANSPORT_ROOT")
val transport =
suppliedTransport?.let(::File)
?: kotlin.io.path.createTempDirectory("deployment-conversation-").toFile()
val repositoryRoot =
generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile }
.first { File(it, "tools/sync_peer_worker.py").isFile }
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, databaseName)
var localWorker: Process? = null
try {
transport.mkdirs()
// A previous interrupted exchange leaves durable coordination
// objects behind. The next run must wait for correlated replacements.
File(transport, "desktop-consumption-ack-v1.json").writeText(
"""{"run_id":"sy_11111111-1111-4111-8111-111111111111","generation_id":"gen_11111111-1111-4111-8111-111111111111"}"""
)
File(transport, "desktop-generation-v1.json").writeText(
"""{"run_id":"sy_11111111-1111-4111-8111-111111111111","generation_id":"gen_22222222-2222-4222-8222-222222222222"}"""
)
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Deployment conversation",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created)
.exercise
val occurrence =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 7, weightKg = 22.5)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(occurrence))
),
)
val coordinator = SyncGenerationForegroundCoordinator(repository)
val release = System.getenv("TRAINLOG_SYNC_TEST_RELEASE_FILE")
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
val result =
executor.submit<ForegroundGenerationResult> {
coordinator.run(transport, Duration.ofSeconds(30)) {
release?.let { awaitFile(File(it)) }
}
}
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
if (suppliedTransport == null) {
val desktop = File(transport, "desktop.sqlite")
val fixture =
ProcessBuilder(
File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktop.absolutePath,
)
.directory(repositoryRoot)
.start()
assertEquals(0, fixture.waitFor())
localWorker =
ProcessBuilder(
"python3",
File(repositoryRoot, "tools/sync_peer_worker.py").absolutePath,
"--database",
desktop.absolutePath,
"--transport-root",
transport.absolutePath,
"--owned-root",
File(transport, "owned").absolutePath,
"--run-id",
"sy_${UUID.randomUUID()}",
"--expected-peer",
peer.getString("peer_id"),
"--timeout",
"30",
)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
}
assertTrue(
result.get(35, java.util.concurrent.TimeUnit.SECONDS)
is ForegroundGenerationResult.Completed
)
executor.shutdownNow()
repository.close()
repository = TrainlogRepository(context, databaseName)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded)
localWorker?.let { worker ->
val output = worker.inputStream.bufferedReader().readText()
assertEquals("worker failed:\n$output", 0, worker.waitFor())
assertTrue(output.contains("\"result\":\"completed\""))
}
} finally {
localWorker?.destroyForcibly()
repository.close()
context.deleteDatabase(databaseName)
if (suppliedTransport == null) transport.deleteRecursively()
}
}
}

View file

@ -1,520 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SyncGenerationServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v19MigrationPreservesDataAndInventsNoGenerationState() {
val name = "generation-migration-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).also {
assertTrue(
it.createExercise(
NewExerciseProfile(
"Migration sentinel",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) is CreateExerciseResult.Created
)
it.close()
}
val path = context.getDatabasePath(name)
SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READWRITE).use { db
->
listOf(
"sync_causal_publications",
"sync_acknowledgements",
"sync_consumed_generations",
"sync_generation_artifacts",
"sync_generations",
"sync_peer_identity",
)
.forEach { db.execSQL("DROP TABLE $it") }
db.execSQL("PRAGMA user_version=19")
}
val migrated = TrainlogRepository(context, name).also { it.listExercises() }
try {
SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(20, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())
assertTrue(it.getInt(0) > 0)
}
listOf(
"sync_generations",
"sync_consumed_generations",
"sync_acknowledgements",
"sync_causal_publications",
)
.forEach { table ->
db.rawQuery("SELECT COUNT(*) FROM $table", null).use {
assertTrue(it.moveToFirst())
assertEquals(0, it.getInt(0))
}
}
}
} finally {
migrated.close()
context.deleteDatabase(name)
}
}
@Test
fun lateSemanticFailureRollsBackWholeAndroidGeneration() {
val sourceName = "generation-rollback-source-${UUID.randomUUID()}.db"
val destinationName = "generation-rollback-destination-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-rollback-").toFile()
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
try {
val exercise =
(source.createExercise(
NewExerciseProfile(
"Rollback exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created)
.exercise
assertTrue(
source.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5)),
)
)
)
) is SaveSessionResult.Saved
)
val destinationService = SyncGenerationService(destination)
val identity =
destinationService.capture(root, "peer_11111111-1111-4111-8111-111111111111")
val peer =
JSONObject(java.io.File(identity.stagingDirectory, "manifest.json").readText())
.getJSONObject("producer")
.getString("peer_id")
val sourceService = SyncGenerationService(source)
val captured = sourceService.capture(root, peer)
val published = sourceService.publish(captured, java.io.File(root, "objects"))
val bad = "{}".toByteArray()
java.io.File(published, "feedback-v2.json").writeBytes(bad)
val manifest = JSONObject(java.io.File(published, "manifest.json").readText())
val artifacts = manifest.getJSONArray("artifacts")
for (index in 0 until artifacts.length()) if (
artifacts.getJSONObject(index).getString("logical_name") == "feedback"
)
artifacts
.getJSONObject(index)
.put("size", bad.size)
.put(
"sha256",
java.security.MessageDigest.getInstance("SHA-256").digest(bad).joinToString(
""
) {
"%02x".format(it)
},
)
java.io.File(published, "manifest.json").writeText(manifest.toString())
assertEquals(
"rejected",
JSONObject(destinationService.consume(published)).getString("result"),
)
assertTrue(destination.listSessions().isEmpty())
SQLiteDatabase.openDatabase(
context.getDatabasePath(destinationName).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
)
.use { db ->
db.rawQuery(
"SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'",
null,
)
.use {
assertTrue(it.moveToFirst())
assertEquals(1, it.getInt(0))
}
}
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test
fun causalOperationBytesRemainImmutableAcrossAndroidGenerations() {
val name = "generation-causal-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-causal-").toFile()
val repo = TrainlogRepository(context, name)
try {
val exercise =
(repo.createExercise(
NewExerciseProfile(
"Causal generation",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created)
.exercise
assertTrue(
repo.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 4)),
)
)
)
) is SaveSessionResult.Saved
)
val sessionId = repo.listSessions().single().sessionId
assertTrue(
repo.deleteCausally("session", sessionId, "peer_android_generation")
is CausalDeleteResult.Applied
)
val service = SyncGenerationService(repo)
val peer = "peer_33333333-3333-4333-8333-333333333333"
val first = service.capture(root, peer)
val second = service.capture(root, peer)
fun operation(captured: CapturedSyncGeneration) =
JSONObject(
java.io
.File(captured.stagingDirectory, "causal-deletions-v1.json")
.readText()
)
.getJSONArray("operations")
.getJSONObject(0)
assertEquals(operation(first).toString(), operation(second).toString())
assertTrue(operation(first).isNull("publication_context"))
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
)
.use { db ->
db.rawQuery(
"SELECT SUM(first_emission),COUNT(*)-SUM(first_emission) FROM sync_causal_publications",
null,
)
.use {
assertTrue(it.moveToFirst())
assertEquals(1, it.getInt(0))
assertEquals(1, it.getInt(1))
}
}
} finally {
repo.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
}
@Test
fun androidCaptureUsesOneSnapshotAgainstControlledWriter() {
val name = "generation-snapshot-${UUID.randomUUID()}.db"
val repo = TrainlogRepository(context, name)
try {
val first =
(repo.createExercise(
NewExerciseProfile("Snapshot A", RecordingMode.SETS, TrackingMode.REPS, 0)
) as CreateExerciseResult.Created)
.exercise
assertTrue(
repo.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = first,
sets = listOf(SessionSetDraft(reps = 3)),
)
)
)
) is SaveSessionResult.Saved
)
val attempted = CountDownLatch(1)
var writer: Thread? = null
val captured =
repo.captureSyncGenerationArtifacts {
writer =
Thread {
attempted.countDown()
repo.createExercise(
NewExerciseProfile(
"Snapshot B",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
)
}
.also { it.start() }
assertTrue(attempted.await(5, TimeUnit.SECONDS))
}
val completedWriter = checkNotNull(writer)
completedWriter.join(5000)
assertTrue(!completedWriter.isAlive)
assertEquals(
1,
JSONObject(captured.getValue("catalog")).getJSONArray("exercises").length(),
)
assertEquals(
1,
JSONObject(captured.getValue("exercise-profile-state"))
.getJSONArray("exercises")
.length(),
)
assertEquals(2, repo.listExercises().size)
} finally {
repo.close()
context.deleteDatabase(name)
}
}
@Test
fun androidGenerationCapturePublishConsumeAndAckSurviveReopen() {
val sourceName = "generation-source-${UUID.randomUUID()}.db"
val destinationName = "generation-destination-${UUID.randomUUID()}.db"
val desktopDestinationName = "generation-desktop-destination-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-android-").toFile()
var source = TrainlogRepository(context, sourceName)
var destination = TrainlogRepository(context, destinationName)
var desktopDestination = TrainlogRepository(context, desktopDestinationName)
try {
val created =
source.createExercise(
NewExerciseProfile(
"Generation exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created
val saved =
source.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = created.exercise,
sets = listOf(SessionSetDraft(reps = 7, weightKg = 21.5)),
)
)
)
)
assertTrue(saved is SaveSessionResult.Saved)
val destinationService = SyncGenerationService(destination)
val identityCapture =
destinationService.capture(root, "peer_11111111-1111-4111-8111-111111111111")
val destinationPeer =
JSONObject(
java.io.File(identityCapture.stagingDirectory, "manifest.json").readText()
)
.getJSONObject("producer")
.getString("peer_id")
val sourceService = SyncGenerationService(source)
val captured = sourceService.capture(root, destinationPeer)
val published = sourceService.publish(captured, java.io.File(root, "objects"))
val before =
published.listFiles()!!.associate { it.name to it.readBytes().contentHashCode() }
assertEquals(published, sourceService.publish(captured, java.io.File(root, "objects")))
assertEquals(
before,
published.listFiles()!!.associate { it.name to it.readBytes().contentHashCode() },
)
val ack = destinationService.consume(published)
assertEquals("acknowledged", sourceService.acceptAcknowledgement(ack.toByteArray()))
source.close()
destination.close()
source = TrainlogRepository(context, sourceName)
destination = TrainlogRepository(context, destinationName)
assertEquals(1, destination.listSessions().size)
assertEquals(
"unchanged",
SyncGenerationService(source).acceptAcknowledgement(ack.toByteArray()),
)
assertEquals(ack, SyncGenerationService(destination).consume(published))
// A desktop-created mutation uses the public C persistence API,
// then the production desktop generation entry point. This is not
// an Android generation re-exported through desktop.
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val desktopDb = java.io.File(root, "desktop-source.sqlite")
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val text = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$text",
0,
process.waitFor(),
)
return text
}
val desktopConsumerDb = java.io.File(root, "desktop-consumer.sqlite")
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopConsumerDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io
.File(repositoryRoot, "tools/sync_generation_exchange.py")
.absolutePath,
"peer-id",
"--database",
desktopConsumerDb.absolutePath,
)
)!!
.groupValues[1]
val androidToDesktop = sourceService.capture(root, desktopPeer)
val androidPublished =
sourceService.publish(
androidToDesktop,
java.io.File(root, "android-desktop-objects"),
)
val androidAckFile = java.io.File(root, "android-desktop-ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
androidPublished.absolutePath,
"--database",
desktopConsumerDb.absolutePath,
"--ack-output",
androidAckFile.absolutePath,
)
assertEquals(
"acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
val androidSessionId = source.listSessions().single().sessionId
assertEquals(
"1",
run(
"sqlite3",
desktopConsumerDb.absolutePath,
"SELECT count(*) FROM sessions WHERE session_id='$androidSessionId';",
)
.trim(),
)
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val androidPeerCapture =
SyncGenerationService(desktopDestination)
.capture(root, "peer_22222222-2222-4222-8222-222222222222")
val androidPeer =
JSONObject(
java.io
.File(androidPeerCapture.stagingDirectory, "manifest.json")
.readText()
)
.getJSONObject("producer")
.getString("peer_id")
val desktopOwned = java.io.File(root, "desktop-owned")
val desktopObjects = java.io.File(root, "desktop-objects")
val captureText =
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"capture-desktop",
"--database",
desktopDb.absolutePath,
"--owned-root",
desktopOwned.absolutePath,
"--consumer-peer",
androidPeer,
)
val desktopGeneration =
Regex("generation_id=(gen_[^ ]+)").find(captureText)!!.groupValues[1]
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"publish",
"--database",
desktopDb.absolutePath,
"--generation-id",
desktopGeneration,
"--object-root",
desktopObjects.absolutePath,
)
val desktopPublished = java.io.File(desktopObjects, "generations/$desktopGeneration")
val desktopAck = SyncGenerationService(desktopDestination).consume(desktopPublished)
val desktopAckFile =
java.io.File(root, "desktop-ack.json").apply { writeText(desktopAck) }
assertTrue(
run(
"python3",
java.io
.File(repositoryRoot, "tools/sync_generation_exchange.py")
.absolutePath,
"accept-ack",
desktopAckFile.absolutePath,
"--database",
desktopDb.absolutePath,
)
.contains("ACK_ACCEPT=acknowledged")
)
assertTrue(
desktopDestination.listSessions().any {
it.sessionId == "se_99999999-9999-4999-8999-999999999999"
}
)
} finally {
source.close()
destination.close()
desktopDestination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
context.deleteDatabase(desktopDestinationName)
root.deleteRecursively()
}
}
}

View file

@ -38,9 +38,6 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.nio.file.Files
import java.security.MessageDigest
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@ -510,469 +507,6 @@ class TrainlogRepositoryDraftTest {
}
}
@Test
fun v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations() {
val sourceName = "v4-source-${UUID.randomUUID()}.db"
val destinationName = "v4-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
val root = Files.createTempDirectory("trainlog-v4-roundtrip-").toFile()
try {
val exercise = createExercise(source, "V4 échange", RecordingMode.SETS, TrackingMode.REPS)
val entryId = "sxe_40000000-0000-4000-8000-000000000001"
assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft(
entryId = entryId, exercise = exercise, equipmentId = "leg_press",
plan = SessionExercisePlan(2, reps = 8, weightKg = 42.5,
loadMode = SessionLoadMode.EXTERNAL, restSeconds = 90),
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0),
SessionSetDraft(reps = 6, weightKg = 42.5)),
)))) is SaveSessionResult.Saved)
val sourceArtifact = JSONObject(source.buildMobileExportV4Json())
val session = sourceArtifact.getJSONArray("sessions").getJSONObject(0)
val sessionId = session.getString("session_id")
assertTrue(source.updateSyncNote("session", sessionId,
"quoted \"note\" \\ newline\né") is SyncNoteEditResult.Saved)
assertTrue(source.updateSyncNote("occurrence", entryId, "x".repeat(4096))
is SyncNoteEditResult.Saved)
assertEquals(SyncNoteEditResult.Invalid,
source.updateSyncNote("occurrence", entryId, "x".repeat(4097)))
val observation = source.saveBodyObservation(
BodyObservationDraft(bodyWeightKg = 81.25, waistCm = 84.0),
) as SaveBodyObservationResult.Saved
assertTrue(source.linkBodyObservationToSession(observation.observationId, sessionId))
assertTrue(source.updateSyncNote("observation", observation.observationId, "")
is SyncNoteEditResult.Saved)
val androidExport = File(root, "android-v4.json")
val completed = JSONObject(source.buildMobileExportV4Json())
val completedSession = completed.getJSONArray("sessions").getJSONObject(0)
val started = OffsetDateTime.parse(completedSession.getString("started_at"))
completedSession.put("ended_at", started.plusMinutes(1).toString())
androidExport.writeText(completed.toString())
val desktopDatabase = File(root, "desktop.sqlite")
val desktopExport = File(root, "desktop-v4.json")
runLifecycleBridge("history", androidExport, desktopDatabase, desktopExport)
val returned = JSONObject(desktopExport.readText())
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONArray(returned.getJSONArray("exercises").toString()))
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
val equalEnd = JSONObject(returned.toString())
equalEnd.getJSONArray("sessions").getJSONObject(0)
.put("ended_at", started.toString())
assertTrue(destination.applyPcMobileExportV4Json(equalEnd.toString())
is MobileSessionImportResult.Invalid)
val equivalentOffset = JSONObject(returned.toString())
equivalentOffset.getJSONArray("sessions").getJSONObject(0)
.put("ended_at", started.withOffsetSameInstant(ZoneOffset.UTC).toString())
assertTrue(destination.applyPcMobileExportV4Json(equivalentOffset.toString())
is MobileSessionImportResult.Invalid)
assertEquals(MobileSessionImportResult.Applied(1, 0, 1, 0),
destination.applyPcMobileExportV4Json(returned.toString()))
assertEquals(MobileSessionImportResult.Applied(0, 1, 0, 1),
destination.applyPcMobileExportV4Json(returned.toString()))
val detail = destination.getSessionDetail(sessionId)!!.exercises.single()
assertEquals(entryId, detail.entryId)
assertEquals(90, detail.plan!!.restSeconds)
assertEquals(listOf(8, 6), detail.sets.map { it.reps })
assertEquals(listOf(40.0, 42.5), detail.sets.map { it.weightKg })
assertEquals(sessionId, JSONObject(destination.buildMobileExportV4Json())
.getJSONArray("body_observations").getJSONObject(0).getString("session_id"))
assertEquals(semanticV4Projection(JSONObject(androidExport.readText())),
semanticV4Projection(JSONObject(destination.buildMobileExportV4Json())))
val secondDesktop = File(root, "desktop-fresh.sqlite")
val finalDesktopExport = File(root, "desktop-final-v4.json")
val destinationExport = File(root, "destination-v4.json")
destinationExport.writeText(destination.buildMobileExportV4Json())
runLifecycleBridge("history", destinationExport, secondDesktop, finalDesktopExport)
assertEquals(semanticV4Projection(JSONObject(destinationExport.readText())),
semanticV4Projection(JSONObject(finalDesktopExport.readText())))
} finally {
source.close(); destination.close()
context.deleteDatabase(sourceName); context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test
fun executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations() {
val sourceName = "draft-source-${UUID.randomUUID()}.db"
val destinationName = "draft-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
val root = Files.createTempDirectory("trainlog-draft-roundtrip-").toFile()
try {
val exercise = createExercise(source, "Draft bridge", RecordingMode.SETS, TrackingMode.REPS)
val entryId = "sxe_50000000-0000-4000-8000-000000000001"
assertEquals(ActiveDraftMutationResult.Saved, source.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(SessionExerciseDraft(
entryId = entryId, exercise = exercise,
plan = SessionExercisePlan(3, reps = 7, restSeconds = 75),
sets = listOf(SessionSetDraft(reps = 7)),
)), form = SessionDraftForm(weightText = "unfinished 42.")),
))
val initial = JSONObject(source.buildExecutionDraftExportV1Json())
assertFalse(initial.toString().contains("unfinished 42."))
val draft = initial.getJSONArray("drafts").getJSONObject(0)
val sessionId = draft.getString("session_id")
val androidExport = File(root, "android-draft.json").apply { writeText(initial.toString()) }
val desktopDatabase = File(root, "desktop.sqlite")
val desktopExport = File(root, "desktop-draft.json")
runLifecycleBridge("draft", androidExport, desktopDatabase, desktopExport)
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONObject(source.buildMobileExportV4Json()).getJSONArray("exercises"))
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
assertEquals(ExecutionDraftImportResult.Applied(1, 0, 0, 0),
destination.applyExecutionDraftExportV1Json(desktopExport.readText()))
assertEquals(ExecutionDraftImportResult.Applied(0, 0, 1, 0),
destination.applyExecutionDraftExportV1Json(desktopExport.readText()))
val destinationArtifact = File(root, "destination-draft.json").apply {
writeText(destination.buildExecutionDraftExportV1Json())
}
val freshDesktop = File(root, "desktop-fresh.sqlite")
val finalArtifact = File(root, "desktop-final-draft.json")
runLifecycleBridge("draft", destinationArtifact, freshDesktop, finalArtifact)
assertEquals(semanticDraftProjection(JSONObject(androidExport.readText())),
semanticDraftProjection(JSONObject(finalArtifact.readText())))
assertEquals(FinalizeActiveDraftResult.Saved(sessionId),
destination.finalizeExecutionDraft(sessionId))
destination.close()
val reopened = TrainlogRepository(context, destinationName)
assertEquals(ExecutionDraftImportResult.Applied(0, 0, 0, 1),
reopened.applyExecutionDraftExportV1Json(desktopExport.readText()))
reopened.close()
} finally {
source.close()
try { destination.close() } catch (_: Exception) { }
context.deleteDatabase(sourceName); context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test
fun causalSessionDeletionRoundTripsWithBothRealProducersAndBlocksReplay() {
val sourceName = "delete-source-${UUID.randomUUID()}.db"
val destinationName = "delete-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
val root = Files.createTempDirectory("trainlog-causal-delete-").toFile()
try {
val exercise = createExercise(source, "Delete bridge", RecordingMode.SETS, TrackingMode.REPS)
assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft(
entryId = "sxe_60000000-0000-4000-8000-000000000001",
exercise = exercise, sets = listOf(SessionSetDraft(reps = 9)),
)))) is SaveSessionResult.Saved)
val live = File(root, "live-v4.json").apply { writeText(source.buildMobileExportV4Json()) }
val sessionId = JSONObject(live.readText()).getJSONArray("sessions")
.getJSONObject(0).getString("session_id")
val androidDelete = source.deleteCausally("session", sessionId, "peer_android_test")
assertTrue(androidDelete is CausalDeleteResult.Applied)
val androidCausal = File(root, "android-causal.json").apply {
writeText(source.buildCausalDeletionExportV1Json())
}
try { source.buildMobileExportV4Json(); fail("protected legacy export must fail")
} catch (_: IllegalStateException) { }
val desktop = File(root, "desktop.sqlite")
runLifecycleBridge("history", live, desktop, File(root, "desktop-live-v4.json"))
runDesktopCausal("import", androidCausal, desktop)
val desktopCausal = File(root, "desktop-causal.json")
runDesktopCausal("export", desktopCausal, desktop)
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONObject(live.readText()).getJSONArray("exercises"))
val conflictName = "delete-conflict-${UUID.randomUUID()}.db"
val conflict = TrainlogRepository(context, conflictName)
assertTrue(conflict.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
assertTrue(conflict.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied)
val conflictExercise = conflict.listExercises().first { it.exerciseId == exercise.exerciseId }
fun corrected(reps: Int) = SessionDraft(listOf(SessionExerciseDraft(
entryId = "sxe_60000000-0000-4000-8000-000000000001",
exercise = conflictExercise, sets = listOf(SessionSetDraft(reps = reps)),
)))
assertTrue(conflict.correctCompletedSession(sessionId, corrected(10)) is CorrectCompletedSessionResult.Saved)
assertTrue(conflict.correctCompletedSession(sessionId, corrected(9)) is CorrectCompletedSessionResult.Saved)
assertTrue(conflict.applyCausalDeletionExportV1Json(androidCausal.readText()) is CausalDeleteResult.Conflict)
assertEquals(1, conflict.listSessions().size)
conflict.close(); context.deleteDatabase(conflictName)
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
assertTrue(destination.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied)
assertTrue(destination.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Applied)
assertTrue(destination.listSessions().isEmpty())
assertTrue(destination.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Unchanged)
assertTrue(destination.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Invalid)
destination.close()
val reopened = TrainlogRepository(context, destinationName)
assertTrue(reopened.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Unchanged)
assertTrue(reopened.listSessions().isEmpty())
reopened.close()
val desktopProducer = File(root, "desktop-producer.sqlite")
runLifecycleBridge("history", live, desktopProducer, File(root, "desktop-producer-live.json"))
val localOperation = File(root, "desktop-local-operation.json")
runDesktopCausal("delete", localOperation, desktopProducer, "session", sessionId)
val desktopProduced = File(root, "desktop-produced.json")
runDesktopCausal("export", desktopProduced, desktopProducer)
val reverseName = "delete-reverse-${UUID.randomUUID()}.db"
val reverse = TrainlogRepository(context, reverseName)
assertTrue(reverse.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
assertTrue(reverse.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied)
assertTrue(reverse.applyCausalDeletionExportV1Json(desktopProduced.readText()) is CausalDeleteResult.Applied)
val returned = File(root, "android-returned-causal.json").apply {
writeText(reverse.buildCausalDeletionExportV1Json())
}
val freshDesktop = File(root, "desktop-fresh.sqlite")
runLifecycleBridge("history", live, freshDesktop, File(root, "desktop-fresh-live.json"))
runDesktopCausal("import", returned, freshDesktop)
val finalArtifact = File(root, "desktop-final-causal.json")
runDesktopCausal("export", finalArtifact, freshDesktop)
assertEquals(semanticCausalProjection(JSONObject(desktopProduced.readText())),
semanticCausalProjection(JSONObject(finalArtifact.readText())))
reverse.close(); context.deleteDatabase(reverseName)
} finally {
try { source.close() } catch (_: Exception) { }
try { destination.close() } catch (_: Exception) { }
context.deleteDatabase(sourceName); context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test
fun causalFiveDomainDeletionsRoundTripWithAndroidAndDesktopProducers() {
val sourceName = "causal-domains-source-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val root = Files.createTempDirectory("trainlog-causal-domains-").toFile()
try {
val retired = createExercise(source, "Retraite échappée 💪", RecordingMode.SETS, TrackingMode.REPS)
val zoned = createExercise(source, "Zones croisées", RecordingMode.SETS, TrackingMode.REPS)
val unrelated = createExercise(source, "Témoin inchangé", RecordingMode.SETS, TrackingMode.REPS)
val aliasSource = createExercise(source, "Ancien nom causal", RecordingMode.SETS, TrackingMode.REPS)
val aliasInput = JSONObject().put("format", "trainlog-exercise-aliases").put("version", 1)
.put("aliases", JSONArray().put(JSONObject()
.put("source_exercise_id", aliasSource.exerciseId)
.put("canonical_exercise_id", retired.exerciseId)))
assertTrue(source.applyExerciseAliasesJson(aliasInput.toString()) is ExerciseAliasImportResult.Applied)
val zoneArtifact = JSONObject(source.buildExerciseBodyZonesJson())
fun zoneItem(id: String, primary: String, secondary: String) = JSONObject()
.put("exercise_id", id).put("primary_zone_id", primary)
.put("secondary_zone_ids", JSONArray().put(secondary))
zoneArtifact.put("exercises", JSONArray()
.put(zoneItem(retired.exerciseId, "arms", "shoulders"))
.put(zoneItem(zoned.exerciseId, "chest", "arms"))
.put(zoneItem(unrelated.exerciseId, "thighs", "glutes")))
assertTrue(source.applyExerciseBodyZonesJson(zoneArtifact.toString()) is ExerciseBodyZoneImportResult.Applied)
val targetEquipment = (source.createCustomEquipment("Presse privée ç") as CreateEquipmentResult.Created).equipment
val otherEquipment = (source.createCustomEquipment("Rack témoin") as CreateEquipmentResult.Created).equipment
val targetEntry = "sxe_62000000-0000-4000-8000-000000000001"
val otherEntry = "sxe_62000000-0000-4000-8000-000000000002"
val session = source.saveSession(SessionDraft(listOf(
SessionExerciseDraft(entryId = targetEntry, exercise = retired,
equipmentId = targetEquipment.equipmentId,
sets = listOf(SessionSetDraft(reps = 8, weightKg = 47.5))),
SessionExerciseDraft(entryId = otherEntry, exercise = zoned,
equipmentId = "leg_press", sets = listOf(SessionSetDraft(reps = 11))),
))) as SaveSessionResult.Saved
val targetFeedback = (source.saveExerciseFeedback(session.sessionId, targetEntry,
"Première impression échappée") as SaveFeedbackResult.Saved).stableId
assertTrue(source.reviseExerciseFeedback(targetFeedback,
"Révision durable 💪") is SaveFeedbackResult.Saved)
val otherFeedback = (source.saveExerciseFeedback(session.sessionId, otherEntry,
"Ressenti témoin") as SaveFeedbackResult.Saved).stableId
val targetObservation = (source.saveBodyObservation(BodyObservationDraft(
bodyWeightKg = 79.4, waistCm = 83.2, leftArmCm = 36.1,
)) as SaveBodyObservationResult.Saved).observationId
val otherObservation = (source.saveBodyObservation(BodyObservationDraft(
bodyWeightKg = 78.9, chestCm = 101.3,
)) as SaveBodyObservationResult.Saved).observationId
val live = File(root, "live-v4.json").apply { writeText(source.buildMobileExportV4Json()) }
val equipment = File(root, "equipment.json").apply { writeText(source.buildEquipmentDefinitionsJson()) }
val zones = File(root, "zones.json").apply { writeText(source.buildExerciseBodyZonesJson()) }
val feedback = File(root, "feedback.json").apply { writeText(source.buildTrainingFeedbackJson()) }
val aliases = File(root, "aliases.json").apply { writeText(source.buildExerciseAliasesJson()) }
val targets = linkedMapOf(
"exercise" to retired.exerciseId,
"body_observation" to targetObservation,
"custom_equipment" to targetEquipment.equipmentId,
"feedback" to targetFeedback,
"body_zone_relation" to "${zoned.exerciseId}|arms|secondary",
)
val manifest = File(root, "manifest.json").apply { writeText(JSONObject()
.put("history", live.absolutePath).put("equipment", equipment.absolutePath)
.put("zones", zones.absolutePath).put("feedback", feedback.absolutePath)
.put("aliases", aliases.absolutePath)
.put("targets", JSONObject(targets as Map<*, *>))
.put("other_observation_id", otherObservation)
.put("other_exercise_id", unrelated.exerciseId)
.put("other_equipment_id", otherEquipment.equipmentId)
.put("other_feedback_id", otherFeedback)
.put("zone_exercise_id", zoned.exerciseId).put("other_zone_id", "chest")
.put("session_id", session.sessionId).toString()) }
val androidReplicaDesktop = File(root, "android-source-desktop.sqlite")
val desktopArtifacts = File(root, "desktop-artifacts")
runLifecycleBridge("domains", manifest, androidReplicaDesktop, desktopArtifacts)
targets.forEach { (kind, target) ->
assertTrue("Android producer $kind", source.deleteCausally(kind, target,
"peer_android_domains") is CausalDeleteResult.Applied)
assertTrue("Android repeated delete $kind", source.deleteCausally(kind, target,
"peer_android_domains") is CausalDeleteResult.Unchanged)
}
val androidDelete = File(root, "android-domain-delete.json").apply {
writeText(source.buildCausalDeletionExportV1Json())
}
assertEquals(targets.keys, causalOperations(androidDelete).map { it.getString("target_kind") }.toSet())
runDesktopCausal("import", androidDelete, androidReplicaDesktop)
runDesktopCausal("import", androidDelete, androidReplicaDesktop)
assertDesktopDomainEffects(manifest, androidReplicaDesktop, File(root, "android-effects.json"))
runLifecycleBridge("refuse-domains", manifest, androidReplicaDesktop,
File(root, "desktop-stale-refusals.json"))
assertDesktopDomainEffects(manifest, androidReplicaDesktop, File(root, "android-effects-after-stale.json"))
val desktopRelay = File(root, "desktop-relay.json")
runDesktopCausal("export", desktopRelay, androidReplicaDesktop)
assertEquals(semanticCausalProjection(JSONObject(androidDelete.readText())),
semanticCausalProjection(JSONObject(desktopRelay.readText())))
val desktopProducer = File(root, "desktop-producer.sqlite")
val desktopProducerArtifacts = File(root, "desktop-producer-artifacts")
runLifecycleBridge("domains", manifest, desktopProducer, desktopProducerArtifacts)
targets.forEach { (kind, target) ->
val first = File(root, "desktop-$kind-first.json")
val second = File(root, "desktop-$kind-second.json")
runDesktopCausal("delete", first, desktopProducer, kind, target)
runDesktopCausal("delete", second, desktopProducer, kind, target)
assertEquals(first.readText(), second.readText())
}
val desktopDelete = File(root, "desktop-domain-delete.json")
runDesktopCausal("export", desktopDelete, desktopProducer)
assertDesktopDomainEffects(manifest, desktopProducer, File(root, "desktop-effects.json"))
val reverseName = "causal-domains-reverse-${UUID.randomUUID()}.db"
val reverse = TrainlogRepository(context, reverseName)
try {
assertTrue(reverse.applyPcEquipmentDefinitionsJson(
File(desktopProducerArtifacts, "equipment.json").readText()) is EquipmentDefinitionImportResult.Applied)
assertTrue(reverse.applyPcCatalogJson(
File(desktopProducerArtifacts, "catalog.json").readText()) is PcCatalogImportResult.Applied)
assertTrue(reverse.applyPcMobileExportV4Json(
File(desktopProducerArtifacts, "history.json").readText()) is MobileSessionImportResult.Applied)
assertTrue(reverse.applyExerciseBodyZonesJson(
File(desktopProducerArtifacts, "zones.json").readText()) is ExerciseBodyZoneImportResult.Applied)
assertTrue(reverse.applyTrainingFeedbackJson(
File(desktopProducerArtifacts, "feedback.json").readText()) is TrainingFeedbackImportResult.Applied)
assertTrue(reverse.applyExerciseAliasesJson(
File(desktopProducerArtifacts, "aliases.json").readText()) is ExerciseAliasImportResult.Applied)
assertTrue(reverse.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Applied)
assertTrue(reverse.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Unchanged)
assertAndroidDomainEffects(reverse, reverseName, targets, otherObservation,
unrelated.exerciseId, otherEquipment.equipmentId, otherFeedback, zoned.exerciseId)
assertTrue(reverse.applyPcMobileExportV4Json(
File(desktopProducerArtifacts, "history.json").readText()) is MobileSessionImportResult.Invalid)
assertTrue(reverse.applyPcEquipmentDefinitionsJson(
File(desktopProducerArtifacts, "equipment.json").readText()) is EquipmentDefinitionImportResult.Invalid)
assertTrue(reverse.applyExerciseBodyZonesJson(
File(desktopProducerArtifacts, "zones.json").readText()) is ExerciseBodyZoneImportResult.Invalid)
assertTrue(reverse.applyTrainingFeedbackJson(
File(desktopProducerArtifacts, "feedback.json").readText()) is TrainingFeedbackImportResult.Invalid)
val androidRelay = File(root, "android-relay.json").apply {
writeText(reverse.buildCausalDeletionExportV1Json())
}
runDesktopCausal("import", androidRelay, desktopProducer)
assertEquals(semanticCausalProjection(JSONObject(desktopDelete.readText())),
semanticCausalProjection(JSONObject(androidRelay.readText())))
reverse.close()
val reopened = TrainlogRepository(context, reverseName)
assertTrue(reopened.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Unchanged)
assertAndroidDomainEffects(reopened, reverseName, targets, otherObservation,
unrelated.exerciseId, otherEquipment.equipmentId, otherFeedback, zoned.exerciseId)
reopened.close()
} finally {
try { reverse.close() } catch (_: Exception) { }
context.deleteDatabase(reverseName)
}
} finally {
source.close(); context.deleteDatabase(sourceName); root.deleteRecursively()
}
}
@Test
fun causalDraftDeletionUsesTheSameRevisionForActivePendingAndDesktopReplicas() {
val sourceName = "causal-draft-source-${UUID.randomUUID()}.db"
val destinationName = "causal-draft-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
val root = Files.createTempDirectory("trainlog-causal-draft-").toFile()
try {
val exercise = createExercise(source, "Causal draft bridge", RecordingMode.SETS, TrackingMode.REPS)
assertEquals(ActiveDraftMutationResult.Saved, source.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(SessionExerciseDraft(
entryId = "sxe_61000000-0000-4000-8000-000000000001",
exercise = exercise, plan = SessionExercisePlan(2, reps = 6),
sets = listOf(SessionSetDraft(reps = 6)),
)), form = SessionDraftForm(weightText = "local unfinished input")),
))
val live = File(root, "draft-live.json").apply { writeText(source.buildExecutionDraftExportV1Json()) }
val sessionId = JSONObject(live.readText()).getJSONArray("drafts").getJSONObject(0).getString("session_id")
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONObject(source.buildMobileExportV4Json()).getJSONArray("exercises"))
val desktop = File(root, "desktop.sqlite")
runLifecycleBridge("draft", live, desktop, File(root, "desktop-live.json"))
assertTrue(source.deleteCausally("execution_draft", sessionId, "peer_android_test") is CausalDeleteResult.Applied)
val androidDelete = File(root, "android-delete.json").apply { writeText(source.buildCausalDeletionExportV1Json()) }
runDesktopCausal("import", androidDelete, desktop)
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
assertTrue(destination.applyExecutionDraftExportV1Json(live.readText()) is ExecutionDraftImportResult.Applied)
val desktopProducer = File(root, "desktop-producer.sqlite")
runLifecycleBridge("draft", live, desktopProducer, File(root, "desktop-producer-live.json"))
runDesktopCausal("delete", File(root, "desktop-operation.json"), desktopProducer,
"execution_draft", sessionId)
val desktopDelete = File(root, "desktop-delete.json")
runDesktopCausal("export", desktopDelete, desktopProducer)
assertTrue(destination.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Applied)
assertTrue(destination.applyExecutionDraftExportV1Json(live.readText()) is ExecutionDraftImportResult.Applied)
destination.close()
val reopened = TrainlogRepository(context, destinationName)
assertTrue(reopened.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Unchanged)
assertTrue(reopened.applyExecutionDraftExportV1Json(live.readText()) is ExecutionDraftImportResult.Applied)
reopened.close()
} finally {
try { source.close() } catch (_: Exception) { }
try { destination.close() } catch (_: Exception) { }
context.deleteDatabase(sourceName); context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test
fun importedCausalOperationsCannotRetireBuiltInIdentities() {
val repo = openRepository()
val builtInId = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"
assertNotNull(repo.getExerciseKnowledge(builtInId))
fun hostile(kind: String, target: String, predecessor: String): String {
val operation = JSONObject().put("operation_id", "del_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("target_kind", kind).put("target_id", target).put("creator_id", "hostile_fixture")
.put("predecessor_revision_id", predecessor).put("created_at", "2030-01-01T12:00:00Z")
.put("publication_context", JSONObject.NULL)
val encoded = canonicalJson(operation).toString()
operation.put("payload_sha256", MessageDigest.getInstance("SHA-256")
.digest(encoded.toByteArray()).joinToString("") { "%02x".format(it) })
return JSONObject().put("format", "trainlog-causal-deletions").put("version", 1)
.put("generated_at", "2030-01-01T12:00:00Z").put("operations", JSONArray().put(operation)).toString()
}
assertTrue(repo.deleteCausally("exercise", builtInId, "peer") is CausalDeleteResult.Invalid)
assertTrue(repo.applyCausalDeletionExportV1Json(hostile("exercise", builtInId, "lv_unknown")) is CausalDeleteResult.Invalid)
assertTrue(repo.applyCausalDeletionExportV1Json(hostile("custom_equipment", "rear_delt_pec_fly", "lv_unknown")) is CausalDeleteResult.Invalid)
val malformed = JSONObject(hostile("exercise", builtInId, "lv_unknown"))
malformed.getJSONArray("operations").getJSONObject(0).put("creator_id", 7)
assertTrue(repo.applyCausalDeletionExportV1Json(malformed.toString()) is CausalDeleteResult.Invalid)
assertEquals(0, JSONObject(repo.buildCausalDeletionExportV1Json()).getJSONArray("operations").length())
}
@Test
fun repeatedContinuousExercisePersistsDistinctOccurrencesAcrossReopenAndFinalize() {
val repo = openRepository()
@ -2260,7 +1794,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(20, cursor.getInt(0))
assertEquals(17, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +1921,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(20, cursor.getInt(0))
assertEquals(17, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
@ -2912,60 +2446,6 @@ class TrainlogRepositoryDraftTest {
assertEquals(3, exported.getJSONArray("sets").length())
}
@Test
fun stagedExecutionDraftPersistsPendingAndFinalizationRejectsStaleReplay() {
val source = openRepository()
val exercise = createExercise(source, "Lifecycle press", RecordingMode.SETS, TrackingMode.REPS)
assertEquals(ActiveDraftMutationResult.Saved, source.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(SessionExerciseDraft(
entryId = "sxe_lifecycle_active", exercise = exercise,
plan = SessionExercisePlan(2, reps = 8, weightKg = 40.0,
loadMode = SessionLoadMode.EXTERNAL, restSeconds = 90),
sets = listOf(SessionSetDraft(reps = 8, weightKg = 42.5)),
)), form = SessionDraftForm(weightText = "42.")),
))
val artifact = JSONObject(source.buildExecutionDraftExportV1Json())
val active = artifact.getJSONArray("drafts").getJSONObject(0)
assertFalse(active.has("weight_text"))
assertFalse(active.toString().contains("weightText"))
val sessionId = active.getString("session_id")
val exerciseCatalog = JSONObject(source.buildMobileExportV4Json()).getJSONArray("exercises")
source.close(); repository = null
context.deleteDatabase(databaseName)
val destination = openRepository()
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", exerciseCatalog)
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
assertEquals(ExecutionDraftImportResult.Applied(1, 0, 0, 0),
destination.applyExecutionDraftExportV1Json(artifact.toString()))
val pending = JSONObject(active.toString())
.put("session_id", "se_00000000-0000-4000-8000-000000000042")
.put("revision_id", "dr_00000000-0000-4000-8000-000000000042")
.put("state", "pending")
pending.getJSONArray("entries").getJSONObject(0)
.put("entry_id", "sxe_lifecycle_pending")
val pendingArtifact = JSONObject(artifact.toString())
.put("drafts", JSONArray().put(pending))
assertEquals(ExecutionDraftImportResult.Applied(0, 1, 0, 0),
destination.applyExecutionDraftExportV1Json(pendingArtifact.toString()))
destination.close(); repository = null
val reopened = openRepository()
assertEquals(2, JSONObject(reopened.buildExecutionDraftExportV1Json())
.getJSONArray("drafts").length())
assertEquals(ActivateExecutionDraftResult.ActiveDraftOccupied,
reopened.activatePendingExecutionDraft(pending.getString("session_id")))
assertEquals(FinalizeActiveDraftResult.Saved(sessionId), reopened.finalizeExecutionDraft(sessionId))
assertEquals(FinalizeActiveDraftResult.Saved(sessionId), reopened.finalizeExecutionDraft(sessionId))
assertEquals(ExecutionDraftImportResult.Applied(0, 0, 0, 1),
reopened.applyExecutionDraftExportV1Json(artifact.toString()))
assertEquals(ActivateExecutionDraftResult.Activated,
reopened.activatePendingExecutionDraft(pending.getString("session_id")))
assertEquals(pending.getString("session_id"),
(reopened.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft.sessionId)
}
@Test
fun exerciseAliasCompanionRekeysHistoryAndDraftAndReplaysIdempotently() {
val repo = openRepository()
@ -3049,125 +2529,6 @@ class TrainlogRepositoryDraftTest {
return canonicalJson(copy).toString()
}
private fun semanticV4Projection(value: JSONObject): String =
semanticV3Projection(value)
private fun semanticDraftProjection(value: JSONObject): String {
val copy = JSONObject(value.toString())
copy.remove("generated_at")
val source = copy.getJSONArray("drafts")
val sorted = (0 until source.length()).map { source.getJSONObject(it) }
.sortedBy { it.getString("session_id") }
copy.put("drafts", JSONArray().also { output -> sorted.forEach(output::put) })
return canonicalJson(copy).toString()
}
private fun semanticCausalProjection(value: JSONObject): String {
val copy = JSONObject(value.toString()); copy.remove("generated_at")
val source = copy.getJSONArray("operations")
val sorted = (0 until source.length()).map { source.getJSONObject(it) }
.sortedBy { it.getString("operation_id") }
copy.put("operations", JSONArray().also { output -> sorted.forEach(output::put) })
return canonicalJson(copy).toString()
}
private fun causalOperations(file: File): List<JSONObject> {
val values = JSONObject(file.readText()).getJSONArray("operations")
return (0 until values.length()).map { values.getJSONObject(it) }
}
private fun assertDesktopDomainEffects(manifest: File, database: File, output: File) {
runLifecycleBridge("inspect-domains", manifest, database, output)
val value = JSONObject(output.readText())
assertEquals(5, value.getInt("operation_count"))
val states = value.getJSONObject("states")
listOf("exercise", "body_observation", "custom_equipment", "feedback",
"body_zone_relation").forEach { assertEquals(1, states.getInt(it)) }
listOf("target_observation_rows", "target_exercise_available",
"target_equipment_available", "feedback_current_rows", "target_zone_rows")
.forEach { assertEquals(it, 0, value.getInt(it)) }
listOf("other_observation_rows", "target_exercise_history_rows",
"other_exercise_available", "target_equipment_history_rows",
"other_equipment_available", "builtin_equipment_rows", "other_feedback_current_rows", "other_zone_rows",
"session_rows").forEach { assertEquals(it, 1, value.getInt(it)) }
assertEquals(2, value.getInt("feedback_revision_rows"))
}
private fun assertAndroidDomainEffects(
repo: TrainlogRepository,
name: String,
targets: Map<String, String>,
otherObservation: String,
otherExercise: String,
otherEquipment: String,
otherFeedback: String,
zoneExercise: String,
) {
assertFalse(repo.listExercises().any { it.exerciseId == targets.getValue("exercise") })
assertTrue(repo.listExercises().any { it.exerciseId == otherExercise })
assertFalse(repo.listEquipment().any { it.equipmentId == targets.getValue("custom_equipment") })
assertTrue(repo.listEquipment().any { it.equipmentId == otherEquipment })
assertTrue(repo.listEquipment().any { it.equipmentId == "leg_press" })
assertFalse(repo.listBodyObservations().any { it.observationId == targets.getValue("body_observation") })
assertTrue(repo.listBodyObservations().any { it.observationId == otherObservation })
val sessionId = repo.listSessions().single().sessionId
assertFalse(repo.listExerciseFeedback(sessionId).any { it.feedbackId == targets.getValue("feedback") })
assertTrue(repo.listExerciseFeedback(sessionId).any { it.feedbackId == otherFeedback })
val db = SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY)
db.use {
fun count(sql: String, args: Array<String> = emptyArray()): Int =
it.rawQuery(sql, args).use { cursor -> cursor.moveToFirst(); cursor.getInt(0) }
assertEquals(1, count("SELECT COUNT(*) FROM exercises WHERE exercise_id=?",
arrayOf(targets.getValue("exercise"))))
assertEquals(1, count("SELECT COUNT(*) FROM equipment WHERE equipment_id=?",
arrayOf(targets.getValue("custom_equipment"))))
assertEquals(1, count("SELECT COUNT(*) FROM exercise_feedback WHERE feedback_id=?",
arrayOf(targets.getValue("feedback"))))
assertEquals(2, count("SELECT COUNT(*) FROM exercise_feedback_revisions WHERE feedback_id=?",
arrayOf(targets.getValue("feedback"))))
assertEquals(0, count("SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e " +
"ON e.id=z.exercise_row_id WHERE e.exercise_id=? AND z.zone_id='arms' AND z.role='secondary'",
arrayOf(zoneExercise)))
assertEquals(1, count("SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e " +
"ON e.id=z.exercise_row_id WHERE e.exercise_id=? AND z.zone_id='chest' AND z.role='primary'",
arrayOf(zoneExercise)))
assertEquals(5, count("SELECT COUNT(*) FROM sync_causal_state WHERE deleted=1"))
}
}
private fun runDesktopCausal(
mode: String,
artifact: File,
database: File,
kind: String? = null,
targetId: String? = null,
) {
val tool = findRepositoryFile("tools/causal_delete_exchange.py")
val arguments = mutableListOf("python3", tool.absolutePath, mode, artifact.absolutePath,
"--database", database.absolutePath)
if (kind != null && targetId != null) arguments += listOf(
"--kind", kind, "--target-id", targetId, "--creator-id", "peer_desktop_test")
val process = ProcessBuilder(arguments).redirectErrorStream(true).start()
val text = process.inputStream.bufferedReader().readText()
assertEquals("desktop causal tool failed: $text", 0, process.waitFor())
}
private fun runLifecycleBridge(
kind: String,
input: File,
database: File,
output: File,
) {
val bridge = findRepositoryFile("tests/support/roundtrip_desktop_lifecycle.py")
val process = ProcessBuilder(
"python3", bridge.absolutePath, kind, input.absolutePath,
database.absolutePath, output.absolutePath,
).redirectErrorStream(true).start()
val text = process.inputStream.bufferedReader().readText()
assertEquals("desktop lifecycle bridge failed: $text", 0, process.waitFor())
}
private fun canonicalJson(value: Any?): Any = when (value) {
is JSONObject -> JSONObject().also { normalized ->
value.keys().asSequence().toList().sorted().forEach { key ->

View file

@ -100,7 +100,7 @@ class SyncBundlePublicationTest {
assertTrue(feedback is SaveFeedbackResult.Saved)
val writesBeforeFirstClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested)
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(2, directory.enumerationCount)
val required = listOf(
"trainlog-mobile-export-v3.json",
@ -138,7 +138,7 @@ class SyncBundlePublicationTest {
ShadowLog.clear()
val bodyCreatesBefore = directory.createCount("trainlog-exercise-body-zones-v1.json")
val writesBeforeSecondClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested)
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(3, directory.enumerationCount)
val phases = ShadowLog.getLogsForTag(DIRECT_EXCHANGE_LOG_TAG).map { it.msg }
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") })

View file

@ -90,15 +90,6 @@ so an unchanged companion replay is a skip and cannot resurrect a started or
deleted proposal. Neither action manufactures performed sets, completed history,
MAX, or feedback.
Android schema v18 additively gives the execution draft its future stable
`session_id`, revision ancestry and `started_at`; adds bounded pending draft,
revision and finalization storage; and adds causal note state plus optional
observation-to-session ownership. Finalization inserts the completed session
with the same stable identities and records its ledger in the transaction that
removes the active draft. Retrying finalization is idempotent, and a stale
draft replay cannot recreate the finalized draft. Raw partial form strings
remain local and are absent from the lifecycle artifact.
The manual synchronization action publishes one prepared Android→PC bundle
before it exposes `trainlog-sync-request-v1.json`. The bundle contains mobile
V3, mobile equipment definitions, equipment associations V2, exercise aliases
@ -910,57 +901,3 @@ Cancel and Back are safe, outside taps cannot confirm, and the destructive
button is explicit. Draft occurrence warnings name nonzero performed-set,
continuous, MAX and feedback children; series deletion and whole-draft abandon
use the same gate.
Android schema v19 additively stores the same immutable causal-deletion
operations and current deleted-target state as desktop v20. Existing data is
preserved and no migration-time ancestry is invented. Repository entry points
create, validate, apply, export, and import the staged causal artifact.
Completed-session correction and body-observation relinking advance durable
non-deleted causal state in their mutation transaction. Session deletion
admission covers ordered occurrences, targets, confirmed sets/continuous/MAX,
notes, feedback revisions and linked observations. Draft admission uses the
stable draft revision for active and pending storage; unfinished form strings
remain local and never affect cross-platform identity.
Android schema v20 additively stores installation peer identity, outgoing
generation/artifact state, consumed-generation state, durable ACKs, and the
separate causal-operation publication ledger. Migration from v19 preserves
business, draft, finalization, revision, and causal rows and creates none of
those new records retroactively.
`SyncGenerationService` is an explicit staged entry point. It captures catalog,
V4 history, drafts, aliases, profiles, custom equipment, associations, BODY
ZONES, feedback, and causal deletions in one repository transaction; writes an
immutable private generation; publishes its manifest last; consumes all listed
domains plus the durable generation record in one outer transaction; and
replays the stored logical ACK after restart. The complete-envelope context is
internal and typed: standalone artifact methods retain their causal guards.
This service is not selected by the automatic V3 storage/request path.
## Complete local backup and restore
Settings exposes an explicit plaintext `.tlbackup` export and restore flow for
the same application identity. The versioned ZIP contains a SQLite-consistent
`VACUUM INTO` snapshot, active drafts and raw partial form input, synchronization
peer/generation/causal state, and the owned presentation/sync preferences. A
manifest written last records schema, package, sizes and SHA-256 digests.
Verification stages only the three exact bounded entries, validates integrity,
foreign keys, package, supported schema and digests before closing the live
repository. Restore retains the previous database and preferences until the
replacement validates; failure restores both. This is not an interchange
format and is intentionally unencrypted, so the UI warns the user to protect
the exported file.
The installed schema-v17 release has no backup UI. The reproducible
`tools/build_android_backup_bridge.py` recipe materializes that exact source,
adds only the backup surface, assigns a higher candidate version code and
builds/tests an APK without installing it. Its synthetic v17 archive is also
restored by current code and migrated through the normal v17-to-v20 path.
The authorized private rollout used the bridge only to add the backup surface
without clearing package data, verified the resulting plaintext archive, then
installed the current signed release variant through a strictly increasing
version-code chain. The final installed private candidate retains product
version 0.1.2 and schema v20. Its full-generation foreground coordinator waits
for correlated objects, tolerates stale objects left by interrupted runs, and
shows completion only after both durable peer acknowledgements.

View file

@ -1,12 +1,5 @@
# Architecture
Full-generation synchronization uses the common admission lock and a fixed
coordinator worker. Browser input cannot select commands, paths, timeouts or
capabilities. Peer identity/capabilities come from Android advertisement;
Android participates only during a foreground user action. Typed progress is
bounded and durable, and completion is corroborated by SQLite generation/ACK
records.
## 1. System boundary
Trainlog Core is the product authority and separates capture, durable history,
@ -421,24 +414,6 @@ carrier.
`trainlog-syncd` is a small user-session agent.
### Full-generation application orchestrator
The opt-in full-generation path is owned by `sync_orchestrator.py`, launched as
an owned process by the C Web adapter. It uses the same XDG `sync.lock` as the
legacy engine, never borrows the Web process's SQLite handle, and accepts peer
transport only from a trusted local configuration. The HTTP event loop only
admits work and reads a bounded durable report. V3 remains the default when the
trusted generation configuration is absent.
In `mtp` mode the worker invokes one fixed packaged helper. Its production
callbacks are the existing libudev/libmtp object operations; tests replace only
that typed I/O table. Candidate selection requires one exact stable peer
advertisement and required capabilities. Downloads are bounded and size
checked. Upload retries accept byte-identical objects, and each generation
manifest is published after its member objects. The helper runs inside the
orchestrator-owned process group and cannot be selected or parameterized by an
HTTP request.
It polls for a new Android request and invokes the same shared C synchronization
engine used by the TUI.
@ -493,9 +468,7 @@ silently rendered as unclassified.
### Desktop
Desktop SQLite schema v21 is canonical long-term history. Its additive v18 ->
v19 migration stores causal notes and execution-draft lifecycle state without
activating a new transport. Its v15 -> v16
Desktop SQLite schema v18 is canonical long-term history. Its v15 -> v16
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
one-time backfill copies the linked catalogue mode without changing child rows
or stable identities. The additive v16 -> v17 migration adds deterministic
@ -841,20 +814,15 @@ schema change.
## 13. Audited evolution constraints
Each standalone mutating importer has strict validation and a SQLite
transaction. The V2
Each mutating importer has strict validation and a SQLite transaction. The V2
association companion is a strict corroboration of the equipment already
carried by the mobile snapshot and does not rewrite divergent state. However,
one definitions/mobile/body-zones/associations publication has no common generation
manifest in the active V3 path and is not one cross-artifact database
transaction. A late association
manifest and is not one cross-artifact database transaction. A late association
conflict can therefore follow a successfully committed definitions or mobile
import; replay remains idempotent and existing conflicting content is not
overwritten. The explicit staged generation service now supplies that
separately versioned batch boundary: one source snapshot, immutable artifacts,
manifest-last publication, complete validation, and one destination
transaction including consumption/ACK state. It is intentionally not selected
by the active V3 engine.
overwritten. A future batch protocol must be separately versioned rather than
retrofitted into frozen formats.
Body-zone concurrency uses an internal canonical-state baseline. Equal states
are idempotent; the sole changed side wins; if both local and incoming states

View file

@ -6,11 +6,6 @@ Code must optimize for readability, correctness, and maintainability.
Avoid clever code when a straightforward implementation is easier to verify.
These rules apply equally to production code, tests, I/O doubles, migration
bridges, and repository tooling. Code must not be manually compacted to save
lines or prompt tokens. A grouped mission remains compatible with small,
reviewable commits.
## 2. C standard
The TUI uses C17.
@ -96,33 +91,11 @@ Good:
## 6. Formatting
The repository `.clang-format` defines the C presentation used by normalized
files. It targets 100 columns, four-space indentation, one statement per line,
explicit control-flow braces, vertical long parameter lists, and no short
function or control body on one line. Contract literals may exceed the target
when splitting them would make the protocol less reviewable.
The repository currently has no canonical `.clang-format` file. Do not apply a
bulk formatter with tool-default behavior; introducing a format definition and
any broad normalization requires a dedicated, reviewable change.
Do not apply the formatter blindly to the entire historical tree. A change
normalizes the files it owns and checks that explicit scope. The reproducible,
non-mutating check for the currently normalized C readability scope is:
```bash
clang-format --dry-run --Werror \
tui/src/dashboard_layout.c \
tui/src/generation_mtp.c \
tui/src/json_writer.c \
tui/src/web_dashboard.c \
tui/src/web_dashboard_json.c \
tui/include/trainlog/generation_mtp.h \
tui/tools/generation_mtp_adapter.c \
tui/tests/generation_mtp_adapter_double.c \
tui/tests/test_generation_mtp.c \
tui/tests/test_web_server.c
```
Formatting changes should not be mixed with unrelated semantic changes when
avoidable. Review readability explicitly before the final commit, including
tests and cleanup/error paths.
Formatting changes should not be mixed with unrelated semantic changes when avoidable.
## 7. Error handling
@ -157,7 +130,3 @@ The Android side should favor:
- small view models;
- no unnecessary framework abstraction;
- clear serialization types matching the Trainlog JSON contract.
Python and Kotlin follow the same human-readability requirement: avoid
single-line compound statements, compressed exception handling, and opaque
chains of unrelated mutations. Formatters are aids, not substitutes for review.

View file

@ -1,32 +1,7 @@
# Current implementation state
The opt-in full-generation path is operational on the private daily
installation. The loopback Web control and foreground Android coordinator use
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
persistent peer identities. The controlled rollout verified real backups, the
schema-v17 to v20 Android update chain, desktop v18 to v21 migration, three
complete hardware conversations, restart, and idempotent replay. Drive was not
configured and remains outside this local MTP result. This is a private 0.1.2
development deployment, not a public release; v0.1.1 is unchanged.
`TRAINLOG_NATIVE_BUILD_AND_CANDIDATE_V1` closes the native warning gate. Strict
GCC 16.2.1 and Clang 22.1.8 builds now pass the same 81-test inventory, and the
Clang ASan/UBSan build passes it with leak detection. Bounded path assembly and
the Web sync accepted response reject insufficient capacity without publishing
partial output. Fresh desktop, current-Android and schema-v17 bridge candidates
were rebuilt from code commit `dd8c91a946a9c836965818d8cc35b8f8bce74658`.
The rollout packager now keeps the legacy request daemon and one-shot helper in
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
mixed-checkout user-service execution during the controlled rollout.
Snapshot date: **2026-09-17**.
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
packaging workflows without changing their contracts. The scoped C formatter
check is canonical for those files only; it does not claim that the historical
repository has been globally reformatted.
This document owns the current implemented state. Historical checkpoints and
closed incidents belong in [reviews](reviews/) and the
[changelog](../CHANGELOG.md).
@ -50,9 +25,9 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v21 |
| Android SQLite | schema v20 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop SQLite | schema v18 |
| Android SQLite | schema v17 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
@ -72,19 +47,10 @@ parallel implementation of its rules.
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Complete synchronization gap contract | `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS` |
| Complete synchronization gap contract | `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
| Synchronization data/lifecycle slice | `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` |
| Synchronization causal deletion slice | `TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` |
| Synchronization generation/ACK slice | `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` (explicit staged entry points; active transport remains V3) |
| Generation MTP and Android backup | `TRAINLOG_SYNC_MTP_AND_ANDROID_BACKUP_V1=SOFTWARE_COMPLETE` (physical-device and user-operation gates remain) |
Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
@ -106,13 +72,6 @@ their current synchronization state from local typed status and counters; they
never inject a raw cross-device French summary into the visible interface. No
raw exchange or protocol format changed for interface language support.
Schema v19/v18 now persists enriched history (`ended_at`, causal session,
occurrence and observation notes, and observation-to-session links), stable
execution-draft identities, bounded pending drafts and finalization ledgers.
The explicit `trainlog-mobile-export` V4 and `trainlog-execution-drafts` V1
codecs are validation-stage entry points only. The active sync engine and
Android inbox/outbox still select V3 and do not move the draft artifact.
## Storage and synchronization
- Android stores capture data in its private SQLite database.
@ -127,23 +86,18 @@ Android inbox/outbox still select V3 and do not move the draft artifact.
- The desktop imports Android snapshots, publishes catalog/profile/feedback/AI
companions, and returns a receipt. Stable IDs, artifact idempotence and the
few domain-specific explicit states make defined replay paths idempotent.
Explicit staged generation services now provide coherent V4/companion
capture, immutable manifests, whole-generation SQLite consumption and
durable peer ACKs. A trusted opt-in configuration may select the bounded
production generation MTP adapter; absent configuration leaves V3 as the
default.
Current snapshots do not provide general tombstones, a common generation or
proof that Android durably consumed the desktop publication.
- SQLite database files are never synchronized.
- The desktop AI flow uses external `rclone` for Drive inbox/archive and
read-only history export. Android owns no Drive credentials.
The frozen [complete synchronization gap contract](design/sync_gap_contract_v1.md)
remains the authority for the later orchestrator. Mobile V3 still omits
documents the future target without implementing it. Mobile V3 still omits
`ended_at`, session/occurrence/body-observation notes, the body-observation to
session link and the active Android draft. Legacy imports remain transactional
per artifact. Explicit generation consumption instead validates every listed
artifact, applies all domains and its consumption record in one transaction,
and emits `consumed` only after commit. A legacy receipt remains distinct from
the correlated generation ACK.
session link and the active Android draft. Imports are transactional per
artifact, not globally across a logical publication. A receipt records desktop
processing; publication alone does not prove durable peer consumption.
Characterization now also freezes the observed preservation of local-only
session/body values on an identical replay, loss of an occurrence-local note
when V3 correction reconstructs that occurrence, delete-before-send exposure,
@ -261,17 +215,6 @@ four skips remain the documented optional historical v9, v12, v13, and v14
database fixtures. No hardware MTP, real Drive, adb, instrumented device test,
user database, exchange directory, or user service participated.
For the `TRAINLOG_SYNC_DATA_LIFECYCLE_V1` closeout on 2026-09-17, real staged
V4 history and execution-draft entry points completed both Android/desktop
producer directions through fresh destination databases. The closeout fixed
one Android V4 import defect that validated but failed to persist occurrence
plan fields. Isolated validation passed **75/75 normal Meson tests**,
**75/75 ASan/UBSan Meson tests**, Android debug assembly, and **207 Android
tests (203 passed, 4 skipped, 0 failed/error)**. The four skips remain the
optional real historical v9, v12, v13, and v14 database fixtures. The active
transport remains V3; no phone, MTP, Drive, service, user database, deployment,
or instrumented device test participated.
The Dashboard Core extraction tranche passes **62/62 normal Meson tests** and
**62/62 ASan/UBSan Meson tests**, including the standalone public-header and
Core characterization targets, plus both canonical JSON validators.
@ -327,33 +270,6 @@ smoke test are not automated. The latter is why
## Active limitations
`TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` provides isolated Android and
desktop producers/consumers for `trainlog-causal-deletions` V1. All contracted
domains are durably protected; exact replay is idempotent and unprovable or
concurrent ancestry conflicts before mutation. Protected legacy snapshots are
refused before resurrection. Staged generation acknowledgement is implemented,
but active synchronization still selects V3: no staged-service activation,
deployment, or user-data migration occurred.
The focused causal closeout additionally proves child-data delete/update
conflicts including change-and-revert, imported built-in and alias protection,
active/pending/desktop draft-revision equivalence, protected companion refusal,
current-view retirement and bounded pre-effect admission. The later generation
slice additively advances schemas to desktop v21/Android v20 without changing
active V3 transport selection.
The residual causal evidence also proves legitimate Android and desktop
producer directions separately for exercise retirement, populated body
observation deletion, custom-equipment retirement, multi-revision feedback
withdrawal and BODY ZONE relation removal. Production exchanges establish each
replica before deletion; stored effects, current readers, stale-companion
refusal and exact replay are checked after reopening the receiving store.
The isolated closeout passed 76/76 normal Meson tests, 76/76 ASan/UBSan
tests, Android debug assembly, and 211 Android tests (207 passed, four optional
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
- `WEB_NEXT_MODULE_SELECTION_V1`: Analyse, Programmes, Séances and Exercices
remain shell placeholders until their Core/API ownership and implementation
order are explicitly contracted.

View file

@ -16,8 +16,8 @@ is a later, separately versioned migration.
## 1. Status
```text
TRAINLOG_DATABASE_SCHEMA_VERSION=21
DATABASE_SCHEMA_V21=IMPLEMENTED
TRAINLOG_DATABASE_SCHEMA_VERSION=18
DATABASE_SCHEMA_V18=IMPLEMENTED
TRAINLOG_FORMAT_V1=FROZEN
```
@ -36,7 +36,7 @@ PRAGMA user_version;
Current value:
```text
21
18
```
The independent actual-set loads documented in the current desktop, Android
@ -49,35 +49,6 @@ Supported historical databases are migrated explicitly through the implemented
migration chain. A database newer than the running binary understands is
rejected.
Schema v20 additively introduces `sync_causal_operations` and
`sync_causal_state`. Operations are immutable and identify their target,
creator, causal predecessor, timestamp, payload digest, and an unassigned
publication context. Target state records the durable deleted revision. The
v19→v20 migration creates empty protection tables and does not fabricate
ancestry for existing rows. Applying an operation, recording its protection,
and performing dependent changes share one transaction.
Non-deleted rows in `sync_causal_state` are also the durable revision boundary
for supported corrections whose content can later return to an earlier value.
They do not fabricate migration ancestry: they are written only by a real
post-migration mutation. Session exercise replacement writes this revision in
the same transaction and refuses to modify a deleted target.
Schema v21 additively introduces stable installation peer identity, outgoing
generation/artifact state, consumed-generation state, durable ACK records, and
the separate immutable-operation publication ledger. The v20→v21 migration is
one SQLite transaction, preserves all business and causal rows, and creates no
historical generation, ancestry, consumption, or ACK. A complete generation
consumer owns one outer transaction covering every domain mutation, its
consumption record, and the data required to replay the same ACK after restart.
Transport publication and ACK-file writes remain outside that transaction.
Version 19 additively stores causal note revisions/current state and staged
execution drafts, their immutable revisions, pending/active state and durable
finalization ledger. It changes no published V1 format and performs no user
database migration until a v19 binary is deliberately deployed and opens that
database.
Version 7 assigns `session_exercises.entry_id` to each stable occurrence.
`exercise_id` remains only the catalogue identity and may occur more than once
in a session. The v6 → v7 migration rebuilds the obsolete uniqueness

View file

@ -1,34 +0,0 @@
# Sync deployment readiness V1 evidence
Status: **SUPERSEDED SOFTWARE BLOCKER — adapter and backup completed by the
MTP/Android-backup follow-up; physical and rollout gates remain**.
This corrective tranche replaces the earlier configurable peer fixture with a
fixed desktop worker and a production Android foreground coordinator. The real
Firefox scenario executes the C HTTP adapter, durable run state, both generation
services, transactional imports, real ACK creation/acceptance and store reopen.
It uses a directory object-I/O double and therefore does not claim USB/MTP.
Decisive targeted evidence:
- `SyncDeploymentConversationTest`: Android generation capture/publication,
desktop consume/ACK, desktop return generation, Android consume/ACK and draft
survival after reopen.
- `test_web_sync_browser.py`: Firefox click, observable desktop commit before the
Android barrier is released, completion, active draft display and reload.
- `test_sync_orchestrator.py`: untrusted-command rejection, pre-accumulation
64-KiB stdout enforcement, and SIGKILL escalation for an owned helper and
descendant that ignore SIGTERM.
- `test_sync_deployment_tools.py`: SQLite backup captures committed WAL state and
passes integrity/foreign-key checks; candidate inventory contains the fixed
helper and hashes.
The tests also exposed and corrected two integration defects: execution-draft
`active`/`pending` state was incorrectly treated as immutable revision content,
and coordination files could become visible before their bytes were complete.
Not performed: physical MTP, real phone access, release signature verification,
real Drive, user backups, main merge, service switch or deployment. The Selenium
`ResourceWarning` remains visible and is associated with an HTTP 405 during the
external Firefox/geckodriver teardown path; no blanket warning suppression is
used. It remains a toolchain limitation until independently fixed or upgraded.

View file

@ -1,24 +1,5 @@
# TRAINLOG_SYNC_GAP_CONTRACT_V1
## Causal deletion closeout implementation note
The staged causal-deletion implementation derives deletion admission from the
complete affected business projection, not only the parent row. Session
projection includes ordered occurrences, plans, confirmed facts, MAX,
occurrence/session note revisions, immutable feedback revisions and linked
observation identities. Observation projection includes every measurement,
its session identity and note revision. Feedback projection includes its full
immutable revision history. Supported corrections also persist a non-deleted
mutation revision, so a change followed by a content revert does not erase
evidence of the intervening edit. Unknown or incomparable ancestry conflicts;
timestamps never arbitrate it.
Execution-draft deletion uses `(session_id, revision_id)` on Android active and
pending storage and on desktop storage. Serialized JSON order, whitespace,
local row IDs and unfinished Android form strings are not causal identity.
Built-in exercise/equipment authorization is identical for local and imported
operations, including aliases that resolve to a built-in exercise.
Statut : `CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
Ce document définit la cible produit d'une synchronisation complète sans
@ -262,20 +243,17 @@ aucun écran de résolution ne sont définis dans ce lot.
## 8. Génération et manifest
**[IMPLEMENTED — PASS/FROZEN]** Every staged multi-artifact publication has a
unique opaque `generation_id` (a `gen_`-prefixed UUIDv4), never a timestamp.
The independent manifest is conceptually:
**[CONTRAT FUTUR — NON IMPLÉMENTÉ]** Toute publication multi-artifact possède un
`generation_id` opaque unique (UUIDv4 préfixé `gen_` recommandé), jamais un
timestamp seul. Le manifest indépendant est conceptuellement :
```json
{
"format": "trainlog-sync-manifest",
"version": 1,
"generation_id": "gen_<uuid-v4>",
"run_id": "sy_<uuid-v4>",
"producer": {"peer_id": "peer_<uuid-v4>", "kind": "android"},
"consumer_peer_id": "peer_<uuid-v4>",
"generated_at": "offset-date-time",
"parent_generation_id": null,
"artifacts": [{
"logical_name": "mobile-history",
"format": "trainlog-mobile-export",
@ -422,16 +400,16 @@ de fichiers Android anciens.
isolé reproductible ;
2. `TRAINLOG_SYNC_CHARACTERIZATION_V1` : compléter les preuves actuelles sur
toutes plateformes ;
3. `TRAINLOG_SYNC_DATA_LIFECYCLE_V1`: mobile V4, drafts and previously lost fields — `PASS/FROZEN`;
3. `TRAINLOG_SYNC_DATA_LIFECYCLE_V1` : mobile V4, drafts et champs perdus ;
4. `TRAINLOG_SYNC_CAUSAL_DELETE_V1` : opérations causales et tombstones ;
5. `TRAINLOG_SYNC_GENERATION_ACK_V1`: manifest, staging, publication,
transactional consumption, restart, and ACK — `PASS/FROZEN`;
5. `TRAINLOG_SYNC_GENERATION_ACK_V1` : manifest, staging, publication,
consommation, reprise et ack ;
6. `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1` : états et rapport complet ;
7. `TRAINLOG_WEB_SYNC_API_V1` : façade et endpoints ;
8. `TRAINLOG_WEB_SYNC_BUTTON_V1` : UX et rafraîchissement.
Each tranche remains bounded by its predecessors. The next implementation
cursor is `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`.
Chaque lot dépend des précédents et reste borné. Aucun n'est commencé par ce
contrat.
## 15. Statut des preuves
@ -465,14 +443,6 @@ on the real private-XDG lock. The assertion-level matrix is owned by
tombstones, coherent generations, causal merge, or a peer-consumption
acknowledgement.
**[CURRENT EVIDENCE]** `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` now has
real cross-implementation round trips for enriched mobile V4 history and the
separate execution-draft V1 artifact in both producer directions. The exact
entry points, assertions, commands, compiler comparison, test inventory, and
remaining transport boundary are recorded in `docs/tests.md`. This evidence
does not select V4 transport and does not implement causal deletion,
generation manifests, peer-consumption acknowledgement, or orchestration.
## 16. Sources propriétaires
- état/roadmap : `docs/current_state.md`, `docs/roadmap.md` ;

View file

@ -1,26 +0,0 @@
# Sync MTP and Android backup V1 evidence
Status: **SOFTWARE COMPLETE — hardware and user-operation gates pending**.
The production C adapter resolves the direct-MTP object tree and is selected
only by trusted `mode: mtp` configuration. Unit tests substitute its typed
libudev/libmtp callbacks, not its selection, bounds, download, retry or
manifest-last publication logic. The integrated headless-Firefox proof uses the
same algorithm over an object-store double and completes the real Android and
desktop generation/ACK conversation.
Android creates a SQLite-consistent, versioned plaintext archive containing the
database and owned preferences, verifies exact entries, bounds, hashes,
integrity, foreign keys, package and schema before replacement, and retains the
old database/preferences until restore verification succeeds. The pinned
schema-v17 bridge candidate builds without installation, passes its v17
round-trip test, and produces an archive that current Android restores and
migrates to schema v20.
Software evidence includes the Meson transport test, orchestrator process and
configuration tests, Android backup tests, bridge/current migration chain,
Android debug assembly, full isolated validation harness, and candidate
inventory verification. Physical MTP with an unlocked phone, release-signature
continuity, bridge/current APK installation, real user backup/restore, service
changes, main integration and deployment were not performed and require their
separate approvals.

View file

@ -1,75 +0,0 @@
# Sync Web end-to-end V1 evidence
Status: `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` on the development
branch. This status means implemented and validated with isolated stores and a
directory transport adapter. It does not mean physical MTP, Drive, deployment,
or a merge to `main`.
## Production call graph
```text
React SyncControl
-> POST /api/v1/sync (Host + Origin + ephemeral CSRF)
-> C Web admission (trusted opt-in config, one request identity)
-> owned sync_orchestrator.py process
-> common XDG sync.lock
-> trusted peer adapter
-> Android SyncGenerationService / desktop sync_generation_exchange.py
-> durable generation and ACK evidence
-> bounded run report
-> GET /api/v1/sync/status polling
-> Dashboard Core snapshot refresh after local commit
```
The deployed/default path remains V3. Full generation mode is unavailable
unless `TRAINLOG_SYNC_GENERATION_CONFIG` names an absolute, trusted local
configuration. HTTP input cannot select a database, executable, transport,
capability, path, timeout, or causal bypass.
## Requirement-to-proof matrix
| Requirement | Production entry point | Exact proof | Observed result |
|---|---|---|---|
| Web admission and passive status | `web_server.c` `/api/v1/sync`, `/api/v1/sync/status` | `web_server`, `sync API` | 202/409/403/405, passive GET and CSRF header pass |
| Async owned worker | `sync_orchestrator.py` | `sync_orchestrator`, Firefox scenario | HTTP remains responsive and child is reaped |
| Durable typed phases | atomic `.sync-run.json` replacement | `test_sync_orchestrator.py` | correlated completion survives reopen |
| Common contention | XDG `sync.lock` | orchestrator conflict test and legacy lock contract | second owner fails without a second import |
| Strict compatibility | orchestrator capability set | missing-capability test | `explicitly_degraded`, missing capabilities retained |
| Generation semantics | desktop and Android generation services | `sync_generation_exchange`, `SyncGenerationServiceTest` | coherent capture, whole transaction, ACK and replay pass |
| Independent producers | public C fixture and Android repository fixture | `androidGenerationCapturePublishConsumeAndAckSurviveReopen` | both direction chains pass after reopen |
| All mandatory domains | generation descriptors and apply order | generation suites and causal seven-kind test | required empty artifacts included and validated |
| Causal immutability | `sync_causal_publications` | causal multi-generation tests | operation bytes/digests unchanged |
| Late failure | transaction-neutral apply helpers | desktop and Android late-domain tests | prior writes roll back; no consumed ACK |
| Lost/late ACK | durable ACK tables | generation restart tests | exact ACK reconstructed; replay unchanged |
| UI click/reload | `SyncControl` | `test_web_sync_browser.py` | real Firefox click, completion, draft details and reload pass |
| Draft visibility | status `drafts` projection | `SyncControl.test.tsx`, Firefox scenario | stable ID/state/type/count shown read-only |
| Dashboard freshness | `onCommitted` callback | component test and browser flow | Core snapshot refetched after committed phase |
| Mutation security | existing Host/Origin/CSRF plus strict body | `web_server`, `sync API` | proxy origin retained; no CORS or client paths |
| Optional AI | separate `ai_midpoint` and `ai_post_sync` | orchestrator report validation | not-configured remains explicit, never main success |
| Default isolation | missing trusted config | C HTTP test | button disabled and POST returns explicit conflict |
The Firefox peer command in the browser scenario is the bounded directory
transport fixture. Cross-implementation Android/desktop behavior is proven by
the production generation suites in the same final validation run. The test
does not claim physical-device timing, MTP object behavior, or Drive access.
## Bounds
- request body: existing 4 KiB HTTP limit;
- headers: existing 8 KiB limit;
- durable report and each worker output stream: 64 KiB;
- trusted configuration: 16 KiB;
- diagnostic: 1024 UTF-8 bytes;
- draft summaries: 32;
- peer command: 16 fixed arguments, each at most 4096 bytes;
- trusted timeout: 1 to 900 seconds;
- frontend polling: one request at a time, 750 ms between completions;
- generation retention: existing eight outgoing generations per consumer.
## Real limitations
Android must participate through its foreground synchronization flow to create
a publication correlated to the accepted run. No always-on Android background
service was added. A waiting phase is therefore truthful and may require user
action. Physical MTP and Drive remain rollout gates. A browser disconnect does
not cancel the worker; server shutdown terminates only its owned process group.

View file

@ -78,13 +78,6 @@ The active completed-session synchronization artifact is separately versioned
`trainlog-mobile-export` V3 and is documented in [Synchronization
exchange](sync_exchange.md). It does not redefine this frozen V1 schema.
An explicit staged `trainlog-mobile-export` V4 codec carries nullable
`ended_at`, causal note values/revisions and observation `session_id`. A
separate strict `trainlog-execution-drafts` V1 codec carries confirmed business
state, stable draft/session and occurrence identities, ancestry and pending
state; it never carries raw UI form strings. Neither staged codec changes
`TRAINLOG_FORMAT_V1` or is selected by the active synchronization transport.
## 2. Design goal
Trainlog v1 must represent the training patterns required by the initial applications without turning the Android recorder into a complex training platform.
@ -645,69 +638,3 @@ Canonical validation requires both:
python tools/validate_json.py
python tools/validate_import_contract.py
```
## Staged causal deletion V1
`trainlog-causal-deletions` V1 is independent of every published snapshot.
Its root contains `format`, integer `version`, `generated_at`, and at most 4096
`operations`; the complete UTF-8 artifact is limited to 4 MiB. Each operation
contains `operation_id`, `target_kind`, `target_id`, `creator_id`,
`predecessor_revision_id`, `created_at`, `payload_sha256`, and a null
`publication_context`. Unknown fields, duplicate keys/IDs, invalid timestamps,
invalid hashes, unknown ancestry, and concurrent ancestry are rejected
atomically. Timestamps are evidence, never last-writer-wins authority.
The artifact is available only through explicit repository/tool entry points.
It is not selected by V3 transport. Null publication context is the typed
handoff to the separate generation association ledger, not consumption proof.
`predecessor_revision_id` identifies either the canonical complete business
projection or a durable mutation revision created by a supported correction.
For execution drafts the projection is exactly the stable session and draft
revision identities; raw JSON serialization is not identity. Imported
operations undergo the same built-in/alias authorization as local operations.
Readers consume at most the 4 MiB limit plus one sentinel byte, exporters
enumerate at most 4096 operations plus one sentinel row, and a local deletion
is refused before its effect if the complete protection artifact would exceed
either bound.
## Staged generation manifest and acknowledgement V1
`trainlog-sync-manifest` V1 is a strict independent envelope. It contains
`generation_id`, `run_id`, producer `{peer_id, kind}`, `consumer_peer_id`, an
offset-aware `generated_at`, nullable `parent_generation_id`, and the complete
artifact descriptor array. A descriptor contains `logical_name`, `format`,
integer `version`, generation-relative `filename`, exact byte `size`, lowercase
SHA-256, and boolean `required`. Unknown root/descriptor fields, duplicate JSON
keys, duplicate names/paths, unknown required capabilities, unsafe paths, wrong
peer context, and missing required domains are rejected.
The inclusive outer limits are 64 KiB per manifest, 32 descriptors, 64 MiB per
artifact, 256 MiB per generation, 64 ASCII bytes per logical name, 240 UTF-8
bytes per relative path, path depth three, JSON depth eight, and 8192 JSON
nodes. Tighter domain bounds still apply. Artifacts live under
`generations/<generation_id>/`; the manifest is the final visibility marker.
SHA-256 proves exact byte integrity under that manifest, not authentication,
freshness, or snapshot coherence.
`trainlog-sync-ack` V1 contains `ack_id`, run/generation and both peer
identities, the exact manifest digest, `consumed|rejected`, durability,
offset-aware completion time, a diagnostic limited to 1024 UTF-8 bytes, and a
digest of the ACK payload without `payload_sha256`. `consumed` uses
`sqlite-commit-full`; `rejected` uses `sqlite-commit-rejection`. Exact replay
returns the stored ACK. A wrong peer, run, generation, manifest digest, ACK
digest, timestamp, result/durability pair, or conflicting replay is rejected.
Generation membership never changes a causal V1 operation. The immutable
operation retains null `publication_context`, its original ID and digest;
`sync_causal_publications` records first emission and retransmission by
operation/generation identity.
## Operational full-generation run report
The Web/application boundary uses a separate bounded operational report; it
does not extend any frozen business artifact. It carries the stable run and
request IDs, trigger, requested/effective mode, peer and generation IDs,
canonical phase, monotonic progress revision, real or unavailable counters,
per-domain coverage, bounded draft summaries, diagnostics and separate AI
results. `completed` is accepted only after the worker supplies the correlated
manifest/ACK evidence. The report is at most 64 KiB and is replaced atomically
with private permissions.

View file

@ -1,67 +0,0 @@
# Full-generation synchronization V1 private deployment record
Date: 2026-09-17
Status: `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS`
This record deliberately omits phone serials, peer IDs, generation IDs,
credential locations, user content, and private backup hashes. Complete causal
evidence is retained in the private rollout journal.
## Installed set
- Trainlog 0.1.2 development; desktop schema v21 and Android schema v20.
- Versioned relocatable desktop bundle behind stable user-bin symlinks.
- Android release-variant APK installed only through compatible signed updates;
the final private rollout version code is 6.
- Production direct-libmtp generation adapter with one explicitly paired peer.
- Loopback Web service and the user-session request daemon are active from the
same versioned installation. No live-checkout runtime helper is used.
## Data and migration evidence
- The original Android schema-v17 store was backed up through the signed bridge
UI and verified for manifest, digests, SQLite integrity, foreign keys and an
exact semantic match to the pre-update store. The real archive migrated on a
copy before live installation.
- A post-migration Android backup was also verified. The real source contained
no active draft, so draft restoration was correctly not applicable; no
synthetic draft was inserted.
- The desktop schema-v18 store was copied with SQLite's backup API, validated,
and migrated on an isolated copy before the live schema-v21 open. Historical
table values were preserved.
- Backups, failed generations, rejection ACKs and successful generation/ACK
evidence remain retained. No one-sided rollback was performed after causal
writes began.
## Operational evidence
- The actual foreground Android advertisement exposed the required persistent
identity and all five capabilities.
- Production libudev/libmtp discovery and object transfer completed on the
selected unlocked phone.
- Three final bidirectional conversations completed with a durable consumed ACK
on each peer and all eight required domains reported successful.
- A service/application restart and a second non-destructive exchange produced
no duplicate business data. Logical hashes for 23 desktop business tables
and 540 rows were identical before and after replay; integrity and foreign-key
checks passed.
- Android visibly returned to an enabled action and displayed synchronization
completion for both Android and PC.
## Rollout repairs
The real rollout exposed and closed four bounded defects: feedback idempotence
depended on the SQLite row representation; the desktop package omitted several
runtime exporters; Android accepted stale coordination filenames without
run/generation correlation; and its UI waited for a legacy receipt after the
generation coordinator had already completed. Regression tests cover each
affected boundary.
## Remaining limits
The phone must be connected, unlocked and in the foreground synchronization
flow. Retained outgoing generations remain subject to the explicit capacity of
eight per peer; evidence is never silently evicted. Drive was not configured or
tested. This private deployment does not publish 0.1.2, create a tag or release,
or alter the existing v0.1.1 release.

View file

@ -1,10 +1,5 @@
# Roadmap
The controlled private rollout is complete for the paired daily installation:
physical libmtp, verified backups, signing continuity, migrations, correlated
ACKs, restart, and idempotent replay passed. Generation mode remains an
explicit per-installation opt-in; Drive is still unconfigured and unvalidated.
This document owns future work. The implemented baseline is summarized in
[current_state.md](current_state.md); completed narratives and evidence belong
in [reviews](reviews/) and [CHANGELOG.md](../CHANGELOG.md).
@ -24,7 +19,7 @@ distinction between measured values and estimates.
## Current baseline
Desktop schema v21, Android schema v20, Notcurses, direct
Desktop schema v18, Android schema v17, Notcurses, direct
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft
@ -38,30 +33,19 @@ visualizations are implemented; the other four Web routes remain placeholders.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_WEB_V1_NEXT_CONTRACT
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_DATA_LIFECYCLE_V1
```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
cursor: complete predecessor state, imported built-in authorization,
cross-platform draft revision identity and resource-bound admission are closed.
`TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN`: both platforms expose explicit
coherent capture, immutable manifest publication, whole-generation
transactional consumption, durable correlated ACK and restart/replay entry
points. The orchestrator, Web API and Web control are complete behind an
explicit trusted opt-in. The authorized private direct-MTP rollout passed on
real hardware; automatic V3 selection remains unchanged without that opt-in.
The next work returns to separately contracted Web surfaces.
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
defines the complete-sync target and its bounded dependency order.
`TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` supplies the reproducible isolated
desktop/Android validation entry point, JDK 17 gate and private JVM/XDG paths.
`TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` reuses and completes the
current-behavior evidence for cross-implementation V3 exchange, identities,
ordering, replay, partial publication, request/receipt processing and the real
lock. Data lifecycle, causal deletion, and generation/ACK slices are complete;
the orchestrator/report slice remains next and does not authorize an endpoint,
button, deployment, or transport activation here.
lock. The next cursor is only `TRAINLOG_SYNC_DATA_LIFECYCLE_V1`; it is not
started and does not yet authorize a protocol, schema, endpoint or button
change.
[Contract details](design/sync_gap_contract_v1.md).
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
@ -139,16 +123,8 @@ it does not authorize silently implementing a page.
The frozen contract orders future bounded lots as environment/test,
characterization, data/lifecycles, causal deletion, generation/consumption/ack,
orchestrator/report, Web API, then button/refresh. The environment/test and
characterization and data/lifecycle lots are complete. Enriched V4 history and
the separate execution-draft codec are implemented behind explicit entry
points, while active transport remains V3. Causal deletion is implemented and
validated through explicit staged entry points, including both real producer
directions for every contracted target kind; generation/consumption
acknowledgement, orchestration/reporting, Web sync, the production generation
MTP adapter and complete Android backup/restore are implemented on the
development branch. Remaining gates are physical-device validation, signed
bridge/current APK procedures, real user backups and an explicitly authorized
rollout.
characterization lots are complete; no production synchronization
implementation lot is started.
`WEB_DASHBOARD_V1=PASS/FROZEN` remains closed and unchanged.
### Gym catalog V1

View file

@ -1,24 +1,8 @@
# Synchronization exchange
## Causal deletion safety gate
Android and desktop expose staged producers and consumers for the independent
`trainlog-causal-deletions` V1 artifact; it is not in the active MTP bundle. A
protected store refuses legacy mobile snapshot export, and legacy mobile import
rejects protected session, observation, or exercise identities before mutation.
Deleted execution-draft replay is stale. Tombstone-free V1/V2/V3 behavior is
unchanged.
Legacy feedback, BODY ZONES and custom-equipment companions are also refused
when their domain has deleted causal state. Retired exercises and custom
equipment remain resolvable for history but are excluded from new-work lists;
withdrawn feedback keeps its immutable revisions while disappearing from the
current feedback view. Profile and alias state cannot make a retired exercise
available because availability is independently guarded by causal state.
## Machine-exercise Phase 1 compatibility
Desktop schema v21 and Android schema v20 retain mobile export V3, readable V1/V2 imports,
Desktop schema v18 and Android schema v17 retain mobile export V3, readable V1/V2 imports,
equipment definitions V1, equipment associations V2, exercise aliases V1, and
the BODY ZONES companion without wire-format changes. Machine metadata is not
silently added to a frozen artifact: stable exercise IDs and canonical names
@ -60,7 +44,6 @@ SESSION_GENERATOR_V1=PASS
BODY_ZONE_SYNC_V1=PASS
BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
```
@ -76,13 +59,6 @@ general correction; its older resumed-MAX compatibility remains bounded.
Synchronization artifacts are separate from the frozen Trainlog session JSON
v1 format.
V4 history and execution-draft V1 are staged codecs with explicit repository
and command-line entry points. They are deliberately absent from
`trainlog_sync_run()`, request/receipt publication and the Android automatic
inbox/outbox. Deployment must stop the old user worker, replace the installed
binary/scripts as one unit, migrate only through the new binary, and restart
only after compatibility checks; this ticket performs none of those steps.
## 2. Exchange directory
Canonical Android shared-storage directory:
@ -742,79 +718,12 @@ equal to the first pass, and both peers retained the same 32 relations for 20
of 23 stable exercise IDs. The final companion states were semantically equal
between passes and the three explicit unclassified states remained intact.
## 18. Staged coherent generations and durable acknowledgement
## 18. Audited protocol limitations
`tools/sync_generation_exchange.py` and Android `SyncGenerationService` are
the production staged owners for manifest V1 and ACK V1. The trusted opt-in
orchestrator can carry their object tree through the production direct-MTP
adapter; automatic V3 selection remains unchanged. Both allocate stable installation peer IDs and
opaque UUIDv4 generation/run IDs only in the explicitly opened staged store.
Desktop capture uses one private SQLite backup and runs every established
exporter against that immutable snapshot. Android holds one repository read
transaction while calling the existing domain exporters. Both validate domain
and outer limits before recording a captured generation. Publication copies
verified immutable bytes into `generations/<generation_id>/`, resumes only
byte-identical partial publication, and writes `manifest.json` last. Capacity
admits at most eight retained outgoing generations per consumer; exhaustion is
recoverable and never evicts an unacknowledged generation or causal proof.
The required artifact order is causal deletions, catalog/profile identity,
custom-equipment definitions, V4 history, aliases and occurrence equipment,
BODY ZONES, feedback, execution drafts, then optional desktop AI proposals.
All files are validated and frozen before mutation. Transaction-neutral desktop
helpers and the Android repository's outer transaction apply every business
row, causal state and the consumption/ACK record together. A late semantic
failure rolls back earlier writes and commits a separate bounded `rejected`
ACK; missing/incomplete transport input remains retryable and produces no ACK.
Lineage is scoped to the producer/consumer pair. Exact replay returns the
stored result; an accepted successor must name the last consumed generation as
parent. UUIDs, clocks, mtimes, and directory scans never choose a successor.
An ACK timeout leaves `waiting_acknowledgement`; a late exact ACK resolves it
idempotently. Causal V1 operation bytes remain unchanged across first emission
and retransmission because generation membership lives only in
`sync_causal_publications`.
The foreground Android coordinator correlates both durable desktop ACKs and
desktop generation references by the active run (and the Android generation
for its ACK). Objects retained from an interrupted conversation are ignored
until atomically replaced by matching objects. After both peer ACKs, Android
reports completion directly; it does not enter the legacy receipt wait state.
The 2026-09-17 private rollout exercised this path with the production libmtp
adapter on one paired Samsung phone. Three complete bidirectional conversations
passed, including service/application restart and an idempotent replay whose
23 desktop business tables and 540 rows retained identical logical hashes.
Rejected pre-fix generations and their ACKs were retained as causal evidence.
No Drive transport was configured or claimed by this validation.
The generation MTP adapter resolves `Documents/Trainlog` through libudev and
libmtp, selects one exact advertised peer/capability set across bounded devices
and storages, and mirrors only bounded regular object names. It downloads to
private temporary files before replacement, verifies reported and received
sizes, rejects ambiguous peers, traversal, excess depth/count/bytes and
truncation, and publishes generation manifests last. Production behavior is
tested by replacing only the typed libmtp/udev I/O callbacks with a filesystem
object double; the same compiled transport algorithm remains under test.
## 19. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is
passive. The worker owns blocking peer/transport work, shares `sync.lock` with
the TUI and daemon, and publishes bounded durable phases. Android foreground
participation is required for a fresh run-correlated publication. No filesystem
mtime or old generation can satisfy that freshness boundary.
The opt-in is `TRAINLOG_SYNC_GENERATION_CONFIG`, an absolute trusted local
configuration path. It is launcher-owned and cannot be supplied through HTTP.
Without it, the staged path reports disabled and the deployed V3 path remains
unchanged.
The complete-sync semantics remain frozen separately in
[`TRAINLOG_SYNC_GAP_CONTRACT_V1`](design/sync_gap_contract_v1.md). The staged
generation slice implements its batch boundary but does not activate it.
The future complete-sync semantics are frozen separately in
[`TRAINLOG_SYNC_GAP_CONTRACT_V1`](design/sync_gap_contract_v1.md). That document
is a non-implemented specification and does not extend any format described
here.
Each mutating importer validates strictly and owns a SQLite transaction. The
V2 association companion only corroborates equipment already imported in the
@ -823,9 +732,9 @@ definitions/mobile/body-zones/associations batch nevertheless has no common gene
or cross-file transaction. Independent “newest artifact” selection can
therefore observe a partially published generation; validation stops on a
mismatch, but an earlier artifact may already have committed. Replay is
idempotent and no conflicting local value is overwritten. That active legacy
design remains non-atomic. Explicit manifest V1 generation consumption
provides the separate atomic path without changing any published companion.
idempotent and no conflicting local value is overwritten. A future atomic-batch
design requires a new versioned manifest rather than a semantic change to any
published format.
Snapshots carry no exercise, session, body-observation, body-zone-relation, or equipment-definition
tombstones. Omission therefore never deletes one of those objects. The only

View file

@ -1,118 +0,0 @@
# Full-generation sync rollout readiness
## Operational rollout status
`TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` on 2026-09-17 for the
authorized private daily installation. Verified real Android and desktop
backups were retained, migration copies passed before live updates, the signing
identity and increasing Android version-code chain were verified, and the
production libmtp adapter completed bidirectional manifest/ACK conversations.
Restart and idempotent replay also passed. Drive was not configured. No public
release or tag was created, and v0.1.1 was not modified. The sanitized evidence
record is [sync rollout V1 deployment](reviews/sync_rollout_v1_deployment.md).
## Deployment-readiness contract
The generation path remains disabled unless desktop configuration authorizes a
specific persisted Android peer and Android contains the strict opt-in document
`Documents/Trainlog/trainlog-sync-generation-opt-in-v1.json`. Absence preserves
V3; malformed opt-in fails explicitly. The Web coordinator owns a fixed worker,
bounded typed progress and its descendant process group. The foreground Android
coordinator advertises its real peer identity, publishes manifest-last bytes,
consumes the return generation and emits a durable correlated ACK.
The isolated proof still uses the production generation adapter with only its
typed libudev/libmtp I/O callbacks replaced by an object-store double. The
separate deployment record owns the physical-phone evidence; neither proof
generalizes pairing to another phone or enables generation mode by default.
### Candidate, backup and recovery
1. Build and validate one revision, then run `python3
tools/package_sync_candidate.py --output <new-private-directory>`. Its
inventory records hashes, schemas, protocols, entry points and dependencies.
The relocatable bundle includes `trainlog`, `trainlog-sync-once`,
`trainlog-syncd`, the production generation MTP adapter and their matching
Python codecs. User units must point only into one versioned bundle; they
must not combine a packaged binary with helpers from a live checkout.
Stable user-bin symlinks are supported: each launcher resolves the link to
its physical bundle before locating `libexec` and `tools`.
Packaging validation imports the production orchestrator and peer worker
from the staged `tools` directory so their complete local module closure is
present before installation.
2. `assembleDebug` is development evidence only. Rollout requires the existing
release signing identity, verification of its public certificate and a valid
increasing `versionCode`. A bounded `TRAINLOG_ANDROID_VERSION_CODE` build
override may establish an update-only private rollout chain without changing
the product version, schema or protocol. The bridge recipe also produces a
signed release variant for installation; debug APKs remain test evidence.
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
foreign-key checks.
4. V3 is not an Android backup. Current Android has a complete verified backup
container. For the installed schema-v17 release, build the pinned bridge
candidate, verify signing/version continuity separately, install only with
explicit approval, create and verify the user backup, and retain it before
installing current Android. None of those device/user operations occurred in
this software mission.
5. Replacing a binary does not roll back a schema. Before any exchange, a
mutually compatible captured pair may be restored. After causal writes or
ACKs, never restore only one peer: stop synchronization, preserve both stores
and all artifacts, and use an explicit paired recovery plan.
6. The eight-generation-per-peer ceiling is an admission gate. Exhaustion must
preserve pending generations, tombstones, finalizations and ACK ledgers; it
must never trigger hidden eviction.
The grouped rollout approval covered the signed APK, desktop bundle, verified
user backups, coordinated service quiescence, pairing, synthetic smoke and
non-destructive hardware smoke. Those steps are complete for the recorded
private installation. Drive remains separate and untested.
The latest software-only candidate set was rebuilt under the private root
`/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit
`dd8c91a946a9c836965818d8cc35b8f8bce74658`. Its desktop inventory records every
packaged file digest plus schemas, protocols, entry points and runtime
dependencies; the inventory file SHA-256 is
`800034b409824a68cef45e5251dd4d94f00bd9e13b3387a1a302b8e2e1136839`. The current
debug APK SHA-256 is
`aca2f499ee9ff53ba5913a76a2a0117274e3e5545967ce361f6f126a6af497e1`; the
non-installed bridge APK SHA-256 is
`0a28d81cbb0818cdf2fee72d5024a4b706fcfda39f1182c682c12f000a026cb4`; and its
synthetic schema-v17 backup SHA-256 is
`06eb3999c6f3ce2fd8f551b38727a0a67fe6b48dde4c3244b3d50357afa7e6a3`.
These are unsigned development evidence, not deployment authorization.
## Compatible set
- desktop binary containing schema v21, the Web API and the generation worker;
- `sync_generation_exchange.py` and all matching transaction-neutral codecs;
- Android schema v20 with `SyncGenerationService`;
- peers advertising manifest V1, ACK V1, causal deletion V1, history V4 and
execution draft V1 under their stable `peer_<uuid-v4>` identities;
- a trusted local orchestrator configuration referencing the packaged peer/MTP
helper with fixed arguments.
## Controlled sequence
1. Back up both SQLite stores independently and record their schema versions.
2. Stop the user services only during the separately authorized deployment.
3. Install the mutually compatible desktop helper, binary and Android APK.
4. Migrate synthetic copies first; migration rollback means restoring a backup,
not merely replacing the binary.
5. Verify peer identities and advertised capabilities without pairing by name.
6. Perform one non-destructive foreground Android publication and desktop run.
7. Verify both manifest digests, both durable ACK records, lineage and readers.
8. Keep V3 selectable until the controlled full-generation smoke is accepted.
## Recovery limits
Do not delete an unacknowledged generation, the last acknowledged generation,
its previous valid generation, or causal evidence. An ACK timeout is unresolved,
not rollback. Eight retained outgoing generations exhaust admission and require
operator diagnosis; this tranche adds no tombstone or evidence garbage
collection. A migrated database is recovered from its verified backup, never by
assuming an older binary reverses its migration.
The recorded private installation completed physical MTP, service coordination
and APK/desktop deployment. Actual Drive configuration remains untested; the
eight-generation admission bound and foreground-phone requirement remain.

View file

@ -1,12 +1,5 @@
# Tests and validation
`SyncDeploymentConversationTest` executes both production generation services
and reopens Android storage. `test_web_sync_browser.py` adds a real Firefox click
through the C adapter, observes the desktop commit at an Android test barrier,
then releases the real ACK. This proves the directory transport double, not
physical MTP. Orchestrator tests cover bounded streams and descendant cleanup;
deployment-tool tests cover consistent SQLite backup and candidate inventory.
The `ai_history_export` test covers an empty database, a simple session,
distinct multi-occurrences of one exercise, ordered sets, exact occurrence
feedback, session-owned follow-up feedback, JSON escaping/UTF-8, MAX history,
@ -406,31 +399,6 @@ python3 tools/validate_sync_isolated.py --suite smoke
python3 tools/validate_sync_isolated.py --suite full --jdk /usr/lib/jvm/java-17-openjdk
```
`TRAINLOG_SYNC_DATA_LIFECYCLE_V1` adds schema v19/v18 migration assertions and
Android lifecycle coverage for confirmed-only serialization, persistent
pending state, occupied-singleton refusal, explicit activation, identity-
preserving idempotent finalization and stale replay. The closeout adds
`v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations` and
`executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations`. Their
bridge provisions only synthetic schema and invokes the real Android and
desktop staged entry points; it does not reconstruct wire data. The final
isolated run passed 75/75 normal native tests, 75/75 ASan/UBSan native tests
and 207 Android tests (203 passed, four unchanged historical-fixture skips),
plus both Python validators and `assembleDebug`. These are artifact/repository
proofs; they do not claim transport activation or global synchronization
atomicity.
The 75-versus-73 inventory contains no removed, renamed, merged, or disabled
native tests. With `web=auto` and no `web/node_modules`, Meson selects 73 tests
and omits exactly `web_frontend_typecheck` and `web_frontend`. After `npm ci`
from the locked package file and Meson reconfiguration, both the normal and
ASan/UBSan selections contain and pass all 75. Clang 22.1.8 is the compiler
used for those green builds. GCC 16.2.1 fails both baseline `77c1517` and the
lifecycle branch under the unchanged strict `-Werror` policy on
`-Wmisleading-indentation` in unchanged `json_writer.c`,
`web_dashboard_json.c`, and `web_dashboard.c`; this is a pre-existing
compiler-version limitation, not lifecycle evidence and not a relaxed gate.
Use `--jdk /absolute/jdk17/home` when auto-detection is unsuitable and
`--run-parent /absolute/private/parent` to select another parent outside the
problematic system `/tmp`. `--keep-run-dir` retains a successful run for
@ -523,11 +491,6 @@ canonical stable Android artifact. Release assets must come from the exact
tagged source state, and the tag, Android `versionName`, Meson project version,
TUI product version, asset names, and release title must agree.
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive
integer while keeping `versionName=0.1.2`. This build-only override is used only
to maintain a strictly increasing installed → backup bridge → current APK
chain; it does not change any schema or exchange-format version.
```bash
meson setup --reconfigure build
meson compile -C build
@ -723,16 +686,14 @@ passing tests:
| Request, receipt, and processed marker | `sync_body_zone_wiring` through `trainlog_sync_run()` | Receipt and report carry the exact `request_id`; identical request replay is refused; receipt-send failure leaves the prior processed marker unchanged | Added |
| Android post-receipt consumption | Desktop request/receipt proof plus Android typed receipt/inbox component tests | Desktop processing success and Android artifact-import success are separate component results; V1 has no durable peer-consumption acknowledgement | Component-scoped only |
| Inter-process lock | `sync_body_zone_wiring` against the real `sync.lock` acquisition with private `XDG_DATA_HOME` | A pipe-coordinated child holds the lock; the second trigger returns conflict before any MTP probe; every child is joined | Added |
| Enriched V4 completed history | `TrainlogRepositoryDraftTest.v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations` through Android V4 repository methods and the real desktop V4 importer/exporter | Android → desktop → fresh Android and desktop → Android → fresh desktop preserve stable identities, order, heterogeneous sets, plans, equipment, causal null/empty/escaped UTF-8 notes and idempotent replay | Added cross-implementation proof |
| Execution-draft V1 | `TrainlogRepositoryDraftTest.executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations` through Android repository methods and `execution_draft_exchange.py` | Both implementation directions preserve stable session/entry identity and confirmed facts; raw unfinished form text remains local; replay is unchanged and pre-finalization replay is stale after reopen/finalization | Added cross-implementation proof |
| General tombstones, coherent generations, causal deletion, and consumption acknowledgement | Causal-deletion suites plus `sync_generation_exchange` and `SyncGenerationServiceTest` | Explicit staged production entry points pass cross-implementation, rollback, replay, bounds and restart assertions; automatic V3 remains unchanged | PASS/FROZEN staged evidence |
| V4 drafts, general tombstones, coherent generations, causal merge, and consumption acknowledgement | Frozen gap contract only | No active production implementation exists | Future criterion, not tested green |
The cross-implementation helpers only provision minimal production-compatible
desktop schemas and launch production Python tools; they do not duplicate
their business rules. Kotlin tests use real Android exporters and importers on
distinct databases. Comparisons remove only volatile `generated_at`,
canonicalize object-key and unordered top-level collection order, and retain
occurrence/set array order as business data.
The cross-implementation helper only provisions the established minimal desktop
test schema and launches the production Python tools; it does not duplicate
their business rules. The Kotlin test uses the real Android V3 exporter and
importer on two distinct databases. Its comparison removes only the volatile
`generated_at`, canonicalizes object-key and unordered top-level collection
order, and retains occurrence/set array order as business data.
Request/receipt evidence remains deliberately bounded: the native test proves
desktop correlation, publication, replay handling, and marking order, while
@ -1202,176 +1163,3 @@ Documents and Download roots while failing on any legacy access.
Android unit tests and any instrumented tests run on an emulator; the primary
personal phone is reserved for manual, non-destructive install-and-sync smoke
validation and must not run `connectedDebugAndroidTest`.
## Generation, transaction, and acknowledgement V1
The normal Meson inventory includes `sync_generation_exchange` and
`schema_v21_migration`. The service suite exercises deterministic desktop
snapshot capture against a controlled writer, immutable publication/replay,
partial and substituted input, strict paths/capabilities/bounds, wrong ACK
context, durable ACK replay after reopen, late-domain rollback, rejected ACKs,
and unchanged causal-operation IDs/digests across first emission and
retransmission for all seven causal target kinds. The v20 fixture migration
preserves populated business state and creates no historical generation/ACK.
Android `SyncGenerationServiceTest` uses production repository codecs and the
filesystem object adapter. It proves a transaction-held capture against a
deterministically blocked writer, v19→v20 preservation, manifest-last immutable
publication, whole-generation rollback on a late semantic failure, committed
ACK replay after close/reopen, and causal-operation byte stability. Its real
cross-implementation test runs both chains: an Android-created session is
captured and transactionally consumed by the desktop Python service, and a
separate desktop-created session written through the public C17 database API is
captured by the desktop service and consumed by Android. Both ACKs return to
the original producer. The bridge moves bytes and invokes real entry points; it
does not author expected business JSON or repair artifacts.
After these additions the direct normal suites report 78/78 Meson tests and
216 Android tests: 212 passed, four historical-fixture skips, zero failures.
The grouped Web end-to-end tranche raises the direct normal and sanitizer
inventories to 79/79 and adds `sync_orchestrator`, protected sync API
coverage, frontend sync assertions and a real headless Firefox scenario in
`tests/test_web_sync_browser.py`. Its detailed matrix is
[`sync_web_end_to_end_v1_evidence.md`](design/sync_web_end_to_end_v1_evidence.md).
The browser uses the production C server and worker with an isolated directory
transport fixture; physical MTP and Drive are not claimed.
The publication adapter is an isolated directory/object-store double. No
physical MTP, phone, Drive, active service, or user database participates.
## Causal deletion V1
The normal Meson inventory includes `causal_delete_exchange`, which invokes
the production desktop service and proves all target kinds, relationship
safety, exact replay, conflicts, finalization protection, and rollback. Android
`causalSessionDeletionRoundTripsWithBothRealProducersAndBlocksReplay` uses the
real Android repository and desktop service in both producer directions with
fresh destinations, reopen/replay, and legacy non-resurrection.
`causalFiveDomainDeletionsRoundTripWithAndroidAndDesktopProducers` closes the
remaining producer/consumer matrix. The Android repository creates exercises,
populated observations, custom equipment, multi-revision feedback and BODY
ZONE relations; production V4 and companion tools establish the desktop
replica. Android-local deletion is consumed and replayed by the desktop
service. A second production-provisioned desktop replica creates each deletion
locally, exports it to Android, and receives Android's exact replay.
| Kind | Producer -> consumer | Stored/read-model assertion | Result |
|---|---|---|---|
| `exercise` | Android -> desktop | Historical row/reference retained; new-work predicate excludes it; unrelated exercise and alias protection remain | PASS |
| `exercise` | desktop -> Android | Repository list excludes it after reopen; history row remains; stale V4/catalog state cannot restore it | PASS |
| `body_observation` | Android -> desktop | Populated target row is absent; session and unrelated populated observation remain | PASS |
| `body_observation` | desktop -> Android | Repository list excludes the target after reopen; unrelated observation and session remain | PASS |
| `custom_equipment` | Android -> desktop | Custom definition/history remains but new-work predicate excludes it; built-in occurrence equipment remains | PASS |
| `custom_equipment` | desktop -> Android | Historical equipment row/reference remains; repository list excludes it; built-in equipment remains | PASS |
| `feedback` | Android -> desktop | Root and two immutable revisions remain; current predicate excludes it; unrelated feedback remains | PASS |
| `feedback` | desktop -> Android | Root/revisions remain after reopen; repository current view excludes it; unrelated feedback remains | PASS |
| `body_zone_relation` | Android -> desktop | Target secondary relation is absent; primary relation and exercise remain | PASS |
| `body_zone_relation` | desktop -> Android | Target secondary relation is absent after reopen; primary relation and exercise remain | PASS |
Every row uses a legitimate replica established through production exchange
entry points. Repeated local deletion returns the original immutable operation;
the opposite exporter preserves its operation ID, target, predecessor and
payload; replay after a new database connection is unchanged. Protected V4,
equipment, feedback and BODY ZONES legacy imports refuse stale restoration and
leave all accepted rows and causal state unchanged. Generic codec reuse is not
counted as a domain result.
Strict compilation uses Clang 22.1.8. GCC 16.2.1 retains the compared
pre-existing `-Werror=misleading-indentation` failure in unchanged Web baseline
sources; it is not reported as green and warning policy is unchanged.
The causal closeout full harness result is 76/76 normal Meson, 76/76
ASan/UBSan, and 211 Android tests: 207 passed and four optional historical
database fixtures skipped. `assembleDebug`, JSON validation, import-contract
validation, both frontend tests, and the isolated characterization all pass.
Focused closeout assertions are `test_child_mutations_invalidate_session_observation_and_feedback_deletes`,
`test_imported_builtin_and_alias_retirement_are_refused_without_writes`,
`test_draft_identity_is_semantic_revision_not_json_serialization`, and
`test_operation_count_and_input_byte_bounds_precede_effects` in the desktop
production-service suite. Android adds
`causalDraftDeletionUsesTheSameRevisionForActivePendingAndDesktopReplicas` and
`importedCausalOperationsCannotRetireBuiltInIdentities`; the existing session
round trip now also changes and reverts a confirmed set before proving the
remote deletion conflicts without losing business or causal state.
## Generation MTP and Android backup
`generation_mtp` drives the production transport algorithm through typed fake
libudev/libmtp callbacks. It covers exact peer selection, bounded download,
manifest-last publication, ambiguity and truncated-object rejection. The
Firefox end-to-end scenario also runs in `mtp` mode: only the callback table is
replaced by a filesystem object store while the C transport, Python worker,
Android generation service, desktop consumers and durable ACK path remain real.
`AndroidBackupServiceTest` creates and restores a populated current-schema
backup including an active draft, raw partial input, stable peer identity and
preferences, and rejects corrupt and traversal entries. The bridge build runs
the analogous test on the exact schema-v17 source and feeds its archive to
`BridgeBackupMigrationTest`, which verifies normal migration to schema v20.
These are software proofs. They do not claim physical USB/MTP behavior,
installation over the signed release, a real user backup, or a real-device
restore.
## Readability validation
The normalized C scope is checked non-mutatively with the explicit
`clang-format --dry-run --Werror` command in `docs/coding_style.md`. Clang C17,
the normal Meson inventory and the ASan/UBSan inventory preserve the existing
behavioral assertions. Python syntax/orchestrator tests, Android generation and
backup tests, the I/O-boundary MTP browser proof, JSON/import validators and
the isolated harness cover the other normalized sources.
GCC 16.2.1 and Clang 22.1.8 both pass separate strict C17 builds and the full
**81/81** Meson inventory with `werror=true` and warning level 3. The former GCC
`format-truncation` findings are closed by exact checked path construction in
the generation-MTP tests/double and by a production accepted-response
serializer that reports its byte count. Boundary tests cover exact fit,
one-byte insufficiency and multibyte path components; failure leaves no partial
path or response available to a caller. Clang ASan/UBSan passes the same
**81/81** inventory with leak detection.
The readability closeout preserves the **81/81** normal Meson and **81/81**
ASan/UBSan inventories. The isolated Android run reports **220 tests: 215
passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds on
JDK 17. The real Firefox/object-I/O-boundary MTP conversation also passes after
formatting. A source comparison against the pre-readability commit confirms
that the scoped test names and assertion counts are unchanged.
The deployment-tools regression also constructs the relocatable candidate and
requires its inventory to include the packaged `trainlog-sync-once` binary,
the `trainlog-syncd` launcher and daemon source, and the generation worker.
This prevents a user unit from silently executing a helper from a different
checkout than the installed desktop candidate.
The isolated JDK 17 harness reports **220 Android tests: 215 passed, five
skipped, zero failures/errors**. The skips are optional, externally supplied
copy gates: `BridgeBackupMigrationTest.verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner`,
`RealAndroidV9BodyZonesMigrationTest.realVersionNineCopyMigratesWithoutChangingExistingTables`,
`RealAndroidV14FeedbackRevisionMigrationTest.freshRealCopyMigratesLosslesslyToVersionFifteen`,
and the version-thirteen and version-twelve methods in
`RealAndroidV12MachineExerciseMigrationTest`. They require respectively the
bridge backup input, a real v9 fixture, a real v14 fixture/output directory,
and real v13/v12 fixtures with explicit output directories. Synthetic bridge
and current-schema migration proofs run separately; no user database is read.
The bridge migration test accepts both legitimate backup states: an active
draft may be present or absent. It snapshots every original table, column,
SQLite value type and value before restore, migrates through the production
repository owner, and compares that complete historical shape afterward. The
draft assertion follows the archived state instead of requiring the synthetic
fixture's sample draft.
## Private full-generation rollout validation
The authorized 2026-09-17 rollout added regression coverage for production
failures found only after installation: feedback import under `sqlite3.Row`,
the complete exporter inventory of the relocatable package, stale Android ACK
and generation-reference correlation, and the completed-generation UI state.
The real direct-libmtp smoke then completed three bidirectional conversations
on the paired phone. Restart and replay preserved identical logical hashes for
23 desktop business tables and 540 rows; SQLite integrity and foreign-key
checks passed, both outgoing desktop generations were acknowledged, and the
Android UI displayed completion. The private journal retains exact IDs and
hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone
was used only for non-destructive install, backup and manual sync validation.

View file

@ -1,208 +0,0 @@
#!/usr/bin/env python3
"""Bridge lifecycle artifacts through the real staged desktop entry points."""
import argparse
import json
import sqlite3
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tests"))
from test_session_exchange_v3 import V11_SCHEMA # noqa: E402
V19_ADDITIONS = """
ALTER TABLE session_exercises ADD COLUMN tracking_mode TEXT;
DROP TABLE exercise_body_zone_sync;
CREATE TABLE exercise_body_zone_sync(exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE,synced_state TEXT NOT NULL);
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT NOT NULL REFERENCES exercises(exercise_id) ON DELETE RESTRICT,CHECK(source_exercise_id<>canonical_exercise_id));
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT NOT NULL,raw_text TEXT NOT NULL);
CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT NOT NULL,raw_text TEXT NOT NULL);
CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT NOT NULL UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,observed_at TEXT NOT NULL,raw_text TEXT NOT NULL);
CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id TEXT NOT NULL REFERENCES session_followups(followup_id) ON DELETE CASCADE,created_at TEXT NOT NULL,raw_text TEXT NOT NULL);
CREATE TABLE sync_note_revisions(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,note TEXT,CHECK(note IS NULL OR length(CAST(note AS BLOB))<=4096),PRIMARY KEY(owner_kind,owner_id,revision_id));
CREATE TABLE sync_note_state(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,PRIMARY KEY(owner_kind,owner_id),FOREIGN KEY(owner_kind,owner_id,revision_id) REFERENCES sync_note_revisions(owner_kind,owner_id,revision_id));
CREATE TABLE execution_drafts(session_id TEXT PRIMARY KEY,session_type TEXT NOT NULL,source_session_id TEXT,started_at TEXT,revision_id TEXT NOT NULL,parent_revision_id TEXT,state TEXT NOT NULL,payload_json TEXT NOT NULL);
CREATE TABLE execution_draft_revisions(session_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,payload_json TEXT NOT NULL,PRIMARY KEY(session_id,revision_id));
CREATE TABLE execution_draft_finalizations(session_id TEXT PRIMARY KEY,final_revision_id TEXT NOT NULL,finalized_at TEXT NOT NULL);
CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,creator_id TEXT NOT NULL,predecessor_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,payload_sha256 TEXT NOT NULL,publication_context TEXT);
CREATE TABLE sync_causal_state(target_kind TEXT NOT NULL,target_id TEXT NOT NULL,current_revision_id TEXT NOT NULL,deleted INTEGER NOT NULL,operation_id TEXT,PRIMARY KEY(target_kind,target_id));
PRAGMA user_version=20;
"""
def run(arguments: list[str]) -> None:
result = subprocess.run(arguments, text=True, capture_output=True, check=False)
if result.returncode != 0:
raise RuntimeError(result.stdout + result.stderr)
def create_v19(path: Path) -> None:
# CONTRACT: fixture provisioning supplies only the production schema. All
# artifact semantics remain owned by the real desktop tools below.
with sqlite3.connect(path) as connection:
connection.executescript(V11_SCHEMA)
connection.executescript(V19_ADDITIONS)
def domain_exchange(manifest_path: Path, database: Path, output: Path) -> None:
"""Provision a replica only through the supported production exchanges."""
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
create_v19(database)
run([sys.executable, str(ROOT / "tools/import_equipment_definitions.py"),
manifest["equipment"], "--database", str(database)])
run([sys.executable, str(ROOT / "tools/import_mobile_export.py"),
manifest["history"], "--database", str(database)])
run([sys.executable, str(ROOT / "tools/import_exercise_body_zones.py"),
manifest["zones"], "--database", str(database)])
run([sys.executable, str(ROOT / "tools/import_training_feedback.py"),
manifest["feedback"], "--database", str(database)])
run([sys.executable, str(ROOT / "tools/import_exercise_aliases.py"),
manifest["aliases"], "--database", str(database)])
output.mkdir(parents=True)
commands = {
"history": ("export_pc_mobile.py", ["--version", "4"]),
"catalog": ("export_pc_catalog.py", []),
"equipment": ("export_equipment_definitions.py", []),
"zones": ("export_exercise_body_zones.py", []),
"feedback": ("export_training_feedback.py", []),
"aliases": ("export_exercise_aliases.py", []),
}
for name, (tool, extra) in commands.items():
target = output / f"{name}.json"
run([sys.executable, str(ROOT / "tools" / tool), str(target),
"--database", str(database), *extra])
def inspect_domains(manifest_path: Path, database: Path, output: Path) -> None:
"""Report persisted effects; production code owns every mutation above."""
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
with sqlite3.connect(database) as connection:
scalar = lambda sql, args=(): connection.execute(sql, args).fetchone()[0]
states = {kind: scalar(
"SELECT deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?",
(kind, target),
) for kind, target in manifest["targets"].items()}
result = {
"states": states,
"operation_count": scalar("SELECT COUNT(*) FROM sync_causal_operations"),
"target_observation_rows": scalar(
"SELECT COUNT(*) FROM body_observations WHERE observation_id=?",
(manifest["targets"]["body_observation"],)),
"other_observation_rows": scalar(
"SELECT COUNT(*) FROM body_observations WHERE observation_id=?",
(manifest["other_observation_id"],)),
"target_exercise_history_rows": scalar(
"SELECT COUNT(*) FROM exercises WHERE exercise_id=?",
(manifest["targets"]["exercise"],)),
"target_exercise_available": scalar(
"SELECT COUNT(*) FROM exercises e WHERE e.exercise_id=? AND NOT EXISTS("
"SELECT 1 FROM sync_causal_state s WHERE s.target_kind='exercise' "
"AND s.target_id=e.exercise_id AND s.deleted=1)",
(manifest["targets"]["exercise"],)),
"other_exercise_available": scalar(
"SELECT COUNT(*) FROM exercises e WHERE e.exercise_id=? AND NOT EXISTS("
"SELECT 1 FROM sync_causal_state s WHERE s.target_kind='exercise' "
"AND s.target_id=e.exercise_id AND s.deleted=1)",
(manifest["other_exercise_id"],)),
"target_equipment_history_rows": scalar(
"SELECT COUNT(*) FROM custom_equipment WHERE equipment_id=?",
(manifest["targets"]["custom_equipment"],)),
"target_equipment_available": scalar(
"SELECT COUNT(*) FROM custom_equipment e WHERE e.equipment_id=? AND NOT EXISTS("
"SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' "
"AND s.target_id=e.equipment_id AND s.deleted=1)",
(manifest["targets"]["custom_equipment"],)),
"other_equipment_available": scalar(
"SELECT COUNT(*) FROM custom_equipment e WHERE e.equipment_id=? AND NOT EXISTS("
"SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' "
"AND s.target_id=e.equipment_id AND s.deleted=1)",
(manifest["other_equipment_id"],)),
"builtin_equipment_rows": scalar(
"SELECT COUNT(*) FROM session_exercises WHERE equipment_id='leg_press'"),
"feedback_root_rows": scalar(
"SELECT COUNT(*) FROM exercise_feedback WHERE feedback_id=?",
(manifest["targets"]["feedback"],)),
"feedback_revision_rows": scalar(
"SELECT COUNT(*) FROM exercise_feedback_revisions WHERE feedback_id=?",
(manifest["targets"]["feedback"],)),
"feedback_current_rows": scalar(
"SELECT COUNT(*) FROM exercise_feedback f WHERE f.feedback_id=? AND NOT EXISTS("
"SELECT 1 FROM sync_causal_state s WHERE s.target_kind='feedback' "
"AND s.target_id=f.feedback_id AND s.deleted=1)",
(manifest["targets"]["feedback"],)),
"other_feedback_current_rows": scalar(
"SELECT COUNT(*) FROM exercise_feedback f WHERE f.feedback_id=? AND NOT EXISTS("
"SELECT 1 FROM sync_causal_state s WHERE s.target_kind='feedback' "
"AND s.target_id=f.feedback_id AND s.deleted=1)",
(manifest["other_feedback_id"],)),
"target_zone_rows": scalar(
"SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id "
"WHERE e.exercise_id=? AND z.zone_id=? AND z.role=?",
tuple(manifest["targets"]["body_zone_relation"].split("|"))),
"other_zone_rows": scalar(
"SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id "
"WHERE e.exercise_id=? AND z.zone_id=?",
(manifest["zone_exercise_id"], manifest["other_zone_id"])),
"session_rows": scalar("SELECT COUNT(*) FROM sessions WHERE session_id=?",
(manifest["session_id"],)),
}
output.write_text(json.dumps(result, sort_keys=True), encoding="utf-8")
def refuse_stale_domains(manifest_path: Path, database: Path, output: Path) -> None:
"""Require every protected legacy producer to refuse stale restoration."""
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
commands = {
"history": ("import_mobile_export.py", manifest["history"]),
"equipment": ("import_equipment_definitions.py", manifest["equipment"]),
"zones": ("import_exercise_body_zones.py", manifest["zones"]),
"feedback": ("import_training_feedback.py", manifest["feedback"]),
}
refused = {}
for name, (tool, artifact) in commands.items():
result = subprocess.run([sys.executable, str(ROOT / "tools" / tool), artifact,
"--database", str(database)],
text=True, capture_output=True, check=False)
refused[name] = result.returncode != 0
if not all(refused.values()):
raise RuntimeError(f"stale companion unexpectedly accepted: {refused}")
output.write_text(json.dumps(refused, sort_keys=True), encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("kind", choices=("history", "draft", "domains", "inspect-domains", "refuse-domains"))
parser.add_argument("input", type=Path)
parser.add_argument("database", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
if args.kind == "domains":
domain_exchange(args.input, args.database, args.output)
return
if args.kind == "inspect-domains":
inspect_domains(args.input, args.database, args.output)
return
if args.kind == "refuse-domains":
refuse_stale_domains(args.input, args.database, args.output)
return
create_v19(args.database)
if args.kind == "history":
run([sys.executable, str(ROOT / "tools/import_mobile_export.py"),
str(args.input), "--database", str(args.database)])
run([sys.executable, str(ROOT / "tools/export_pc_mobile.py"),
str(args.output), "--database", str(args.database), "--version", "4"])
else:
tool = str(ROOT / "tools/execution_draft_exchange.py")
run([sys.executable, tool, "import", str(args.input),
"--database", str(args.database)])
run([sys.executable, tool, "export", str(args.output),
"--database", str(args.database)])
if __name__ == "__main__":
main()

View file

@ -1,16 +0,0 @@
#!/usr/bin/env python3
"""Bounded isolated peer transport fixture for orchestrator/Web tests."""
import json, os, uuid
run_id=os.environ["TRAINLOG_SYNC_RUN_ID"]
def identity(prefix): return prefix+str(uuid.uuid4())
print(json.dumps({
"format":"trainlog-sync-worker-report","version":1,"run_id":run_id,
"producer_peer_id":identity("peer_"),"consumer_peer_id":identity("peer_"),
"inbound_generation_id":identity("gen_"),"outbound_generation_id":identity("gen_"),
"manifest_sha256":"a"*64,"result":"completed","sessions_reconciled":1,
"domains":{"history-v4":True,"execution-draft-v1":True,"causal-delete-v1":True,
"equipment":True,"aliases":True,"profiles":True,"body-zones":True,"feedback":True},
"drafts":[{"draft_id":identity("draft_"),"state":"pending","session_type":"strength","occurrence_count":2}],
"ai_midpoint":{"result":"not_configured"},"ai_post_sync":{"result":"not_configured"}
},sort_keys=True,separators=(",",":")))

View file

@ -1,172 +0,0 @@
#!/usr/bin/env python3
"""Production-service regressions for staged causal deletion."""
import importlib.util
import json
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
SPEC = importlib.util.spec_from_file_location("causal", ROOT / "tools/causal_delete_exchange.py")
causal = importlib.util.module_from_spec(SPEC); SPEC.loader.exec_module(causal)
SCHEMA = """
PRAGMA foreign_keys=ON;
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER);
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,session_row_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,exercise_row_id INTEGER REFERENCES exercises(id),entry_id TEXT UNIQUE,position INTEGER,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER,equipment_id TEXT,load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,notes TEXT);
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY REFERENCES session_exercises(id) ON DELETE CASCADE,max_weight_kg REAL);
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT,session_row_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL);
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT,label_name TEXT,equipment_type TEXT,load_semantics TEXT);
CREATE TABLE equipment(equipment_id TEXT PRIMARY KEY,equipment_type TEXT);
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT);
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT UNIQUE,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT,raw_text TEXT);
CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT,raw_text TEXT);
CREATE TABLE exercise_body_zones(exercise_row_id INTEGER REFERENCES exercises(id),zone_id TEXT,role TEXT,PRIMARY KEY(exercise_row_id,zone_id));
CREATE TABLE execution_drafts(session_id TEXT PRIMARY KEY,revision_id TEXT,parent_revision_id TEXT,payload_json TEXT);
CREATE TABLE execution_draft_finalizations(session_id TEXT PRIMARY KEY,final_revision_id TEXT,finalized_at TEXT);
CREATE TABLE sync_note_revisions(owner_kind TEXT,owner_id TEXT,revision_id TEXT,parent_revision_id TEXT,note TEXT,PRIMARY KEY(owner_kind,owner_id,revision_id));
CREATE TABLE sync_note_state(owner_kind TEXT,owner_id TEXT,revision_id TEXT,PRIMARY KEY(owner_kind,owner_id));
CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT,target_id TEXT,creator_id TEXT,predecessor_revision_id TEXT,created_at TEXT,payload_sha256 TEXT,publication_context TEXT);
CREATE TABLE sync_causal_state(target_kind TEXT,target_id TEXT,current_revision_id TEXT,deleted INTEGER,operation_id TEXT,PRIMARY KEY(target_kind,target_id));
PRAGMA user_version=20;
"""
class CausalDeleteTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(); self.path = Path(self.tmp.name) / "db.sqlite"
self.db = sqlite3.connect(self.path); self.db.executescript(SCHEMA)
self.db.execute("INSERT INTO exercises VALUES(1,'ex_user','User','sets','reps',0)")
self.db.execute("INSERT INTO sessions VALUES(1,'se_one','2030-01-01T10:00:00Z',NULL,'training','note')")
self.db.execute("INSERT INTO session_exercises VALUES(1,1,1,'sxe_one',0,'sets','reps',0,NULL,'external',60,1,8,NULL,32.5,'occurrence')")
self.db.execute("INSERT INTO performed_sets VALUES(1,1,0,8,NULL,32.5)")
self.db.execute("INSERT INTO body_observations VALUES(1,'bo_one','2030-01-01T11:00:00Z',80.0,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'body',1)")
self.db.execute("INSERT INTO custom_equipment VALUES('eq_one','Custom','','custom','external')")
self.db.execute("INSERT INTO exercise_feedback VALUES(1,'fb_one',1,'2030-01-01T10:30:00Z','root')")
self.db.execute("INSERT INTO exercise_feedback_revisions VALUES('fr_one','fb_one','2030-01-01T10:30:00Z','root')")
self.db.execute("INSERT INTO exercise_body_zones VALUES(1,'arms','primary')")
self.db.execute("INSERT INTO execution_drafts VALUES('se_draft','dr_one',NULL,'{}')")
self.db.commit()
def tearDown(self): self.db.close(); self.tmp.cleanup()
def delete(self, kind, target):
self.db.execute("BEGIN IMMEDIATE")
operation, outcome = causal.local_delete(self.db, kind, target, "peer_desktop_test")
self.db.commit(); self.assertEqual("applied", outcome); return operation
def test_domain_effects_replay_conflict_and_parent_safety(self):
feedback = self.delete("feedback", "fb_one")
self.assertEqual("root", self.db.execute("SELECT raw_text FROM exercise_feedback").fetchone()[0])
self.assertEqual(1, self.db.execute("SELECT count(*) FROM exercise_feedback_revisions").fetchone()[0])
self.delete("body_zone_relation", "ex_user|arms|primary")
self.assertEqual(0, self.db.execute("SELECT count(*) FROM exercise_body_zones").fetchone()[0])
self.delete("exercise", "ex_user"); self.delete("custom_equipment", "eq_one")
self.assertEqual(1, self.db.execute("SELECT count(*) FROM exercises").fetchone()[0])
self.assertEqual(1, self.db.execute("SELECT count(*) FROM custom_equipment").fetchone()[0])
self.delete("execution_draft", "se_draft")
self.assertEqual(0, self.db.execute("SELECT count(*) FROM execution_drafts").fetchone()[0])
session = self.delete("session", "se_one")
self.assertEqual(0, self.db.execute("SELECT count(*) FROM sessions").fetchone()[0])
self.assertEqual((1, None), self.db.execute("SELECT count(*),session_row_id FROM body_observations").fetchone())
self.assertIsNotNone(self.db.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id='se_one'").fetchone())
self.assertEqual("unchanged", causal.apply_operation(self.db, session))
conflicting = dict(session); conflicting["operation_id"] = "del_" + "1" * 36
conflicting["payload_sha256"] = causal.digest({k:v for k,v in conflicting.items() if k != "payload_sha256"})
with self.assertRaisesRegex(causal.CausalError, "concurrent deletion"):
causal.apply_operation(self.db, conflicting)
divergent = dict(feedback); divergent["creator_id"] = "other"
divergent["payload_sha256"] = causal.digest({k:v for k,v in divergent.items() if k != "payload_sha256"})
with self.assertRaisesRegex(causal.CausalError, "reused"):
causal.apply_operation(self.db, divergent)
def test_observation_delete_and_finalized_draft_conflict_roll_back(self):
self.delete("body_observation", "bo_one")
self.assertEqual(0, self.db.execute("SELECT count(*) FROM body_observations").fetchone()[0])
self.db.execute("INSERT OR REPLACE INTO execution_draft_finalizations VALUES('se_draft','dr_final','2030-01-01T12:00:00Z')")
self.db.commit(); before = self.db.execute("SELECT count(*) FROM sync_causal_operations").fetchone()[0]
self.db.execute("BEGIN IMMEDIATE")
with self.assertRaisesRegex(causal.CausalError, "finalization"):
causal.local_delete(self.db, "execution_draft", "se_draft", "peer")
self.db.rollback()
self.assertEqual(before, self.db.execute("SELECT count(*) FROM sync_causal_operations").fetchone()[0])
self.assertIsNotNone(self.db.execute("SELECT 1 FROM execution_drafts WHERE session_id='se_draft'").fetchone())
def test_builtin_exercise_cannot_be_retired(self):
built_in = next(iter(causal.load_exercise_names()))
self.db.execute("INSERT INTO exercises VALUES(2,?,'Built in','sets','reps',0)", (built_in,))
self.db.commit()
with self.assertRaisesRegex(causal.CausalError, "built-in"):
causal.local_delete(self.db, "exercise", built_in, "peer")
def hostile_operation(self, kind, target):
operation = {"operation_id": "del_" + "a" * 36, "target_kind": kind,
"target_id": target, "creator_id": "hostile_fixture",
"predecessor_revision_id": causal.live_revision(self.db, kind, target) or "lv_unknown",
"created_at": "2030-01-01T12:00:00Z", "publication_context": None}
operation["payload_sha256"] = causal.digest(operation)
return operation
def test_imported_builtin_and_alias_retirement_are_refused_without_writes(self):
built_in = next(iter(causal.load_exercise_names()))
self.db.execute("INSERT INTO exercises VALUES(2,?,'Built in','sets','reps',0)", (built_in,))
self.db.execute("INSERT INTO equipment VALUES('barbell','barbell')")
alias = "ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
self.db.execute("INSERT INTO exercises VALUES(3,?,'Alias row','sets','reps',0)", (alias,))
self.db.execute("INSERT INTO exercise_aliases VALUES(?,?)", (alias, built_in))
self.db.commit()
for operation in (self.hostile_operation("exercise", built_in),
self.hostile_operation("exercise", alias),
self.hostile_operation("custom_equipment", "barbell")):
with self.assertRaisesRegex(causal.CausalError, "built-in"):
causal.apply_operation(self.db, operation)
self.assertEqual(0, self.db.execute("SELECT COUNT(*) FROM sync_causal_operations").fetchone()[0])
self.assertEqual(0, self.db.execute("SELECT COUNT(*) FROM sync_causal_state").fetchone()[0])
def test_child_mutations_invalidate_session_observation_and_feedback_deletes(self):
session = self.hostile_operation("session", "se_one")
self.db.execute("UPDATE performed_sets SET weight_kg=35.0 WHERE id=1")
with self.assertRaisesRegex(causal.CausalError, "predecessor"):
causal.apply_operation(self.db, session)
self.assertEqual(35.0, self.db.execute("SELECT weight_kg FROM performed_sets WHERE id=1").fetchone()[0])
observation = self.hostile_operation("body_observation", "bo_one")
self.db.execute("UPDATE body_observations SET body_weight_kg=79.5,session_row_id=NULL WHERE observation_id='bo_one'")
with self.assertRaisesRegex(causal.CausalError, "predecessor"):
causal.apply_operation(self.db, observation)
feedback = self.hostile_operation("feedback", "fb_one")
self.db.execute("INSERT INTO exercise_feedback_revisions VALUES('fr_two','fb_one','2030-01-02T10:30:00Z','revised')")
with self.assertRaisesRegex(causal.CausalError, "predecessor"):
causal.apply_operation(self.db, feedback)
self.assertEqual(0, self.db.execute("SELECT COUNT(*) FROM sync_causal_operations").fetchone()[0])
def test_draft_identity_is_semantic_revision_not_json_serialization(self):
first = causal.live_revision(self.db, "execution_draft", "se_draft")
self.db.execute("UPDATE execution_drafts SET payload_json=? WHERE session_id='se_draft'", ('{ "b": 2, "a": 1 }',))
self.assertEqual(first, causal.live_revision(self.db, "execution_draft", "se_draft"))
self.db.execute("UPDATE execution_drafts SET revision_id='dr_two' WHERE session_id='se_draft'")
self.assertNotEqual(first, causal.live_revision(self.db, "execution_draft", "se_draft"))
def test_operation_count_and_input_byte_bounds_precede_effects(self):
row = ("exercise", "x", "peer", "lv_x", "2030-01-01T12:00:00Z", "0" * 64, None)
self.db.executemany("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,?)",
[(f"op_{index:04d}", *row) for index in range(causal.MAX_OPERATIONS)])
self.db.commit()
document = causal.export_document(self.db)
self.assertEqual(causal.MAX_OPERATIONS, len(document["operations"]))
self.db.execute("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,?)", ("op_over", *row)); self.db.commit()
with self.assertRaisesRegex(causal.CausalError, "artifact bound"):
causal.export_document(self.db)
oversized = Path(self.tmp.name) / "oversized.json"
oversized.write_bytes(b" " * (causal.MAX_BYTES + 1))
with self.assertRaisesRegex(causal.CausalError, "4 MiB"):
causal.load(oversized)
if __name__ == "__main__": unittest.main()

View file

@ -1,108 +0,0 @@
import json
import sqlite3
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class DeploymentToolsTest(unittest.TestCase):
def test_sqlite_backup_captures_committed_wal_and_restores_invariants(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "source.db"
backup = root / "backup.db"
database = sqlite3.connect(source)
database.execute("PRAGMA journal_mode=WAL")
database.execute("PRAGMA foreign_keys=ON")
database.executescript(
"CREATE TABLE parent(id INTEGER PRIMARY KEY);"
"CREATE TABLE child(parent_id INTEGER REFERENCES parent);"
"INSERT INTO parent VALUES(7);"
"INSERT INTO child VALUES(7);"
)
database.commit()
result = subprocess.run(
[
"python3",
str(ROOT / "tools/backup_trainlog_sqlite.py"),
str(source),
str(backup),
],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
with sqlite3.connect(backup) as restored:
self.assertEqual(
restored.execute("SELECT * FROM child").fetchall(),
[(7,)],
)
self.assertEqual(
restored.execute("PRAGMA integrity_check").fetchone(),
("ok",),
)
database.close()
def test_candidate_is_self_contained_and_hashed(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "candidate"
result = subprocess.run(
[
"python3",
str(ROOT / "tools/package_sync_candidate.py"),
"--output",
str(output),
],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.2")
self.assertTrue((output / "bin/trainlog").is_file())
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
self.assertTrue((output / "bin/trainlog-syncd").is_file())
self.assertTrue((output / "libexec/trainlog-sync-once").is_file())
stable_bin = Path(directory) / "stable-bin"
stable_bin.mkdir()
(stable_bin / "trainlog").symlink_to(output / "bin/trainlog")
linked = subprocess.run(
[str(stable_bin / "trainlog"), "--version"],
capture_output=True,
text=True,
)
self.assertEqual(linked.returncode, 0, linked.stderr)
self.assertIn("trainlog 0.1.2", linked.stdout)
imported = subprocess.run(
[
"python3",
"-c",
"import pathlib; import sync_peer_worker; import sync_orchestrator; "
"import sync_generation_exchange as g; "
"assert all((pathlib.Path.cwd()/row[5]).is_file() "
"for row in g.ARTIFACTS)",
],
cwd=output / "tools",
capture_output=True,
text=True,
)
self.assertEqual(imported.returncode, 0, imported.stderr)
self.assertTrue(
any(
row["path"] == "tools/trainlog_syncd.py"
for row in inventory["files"]
)
)
self.assertTrue(
any(
row["path"] == "tools/sync_peer_worker.py"
for row in inventory["files"]
)
)
if __name__ == "__main__":
unittest.main()

View file

@ -1,169 +0,0 @@
#!/usr/bin/env python3
"""Production generation/manifest/ACK service regressions."""
import json, sqlite3, sys, tempfile, unittest
from contextlib import closing
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
PEER_A="peer_11111111-1111-4111-8111-111111111111"
PEER_B="peer_22222222-2222-4222-8222-222222222222"
RUN="sy_33333333-3333-4333-8333-333333333333"
GEN="gen_44444444-4444-4444-8444-444444444444"
SCHEMA="""
PRAGMA foreign_keys=ON;
CREATE TABLE facts(id TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT,target_id TEXT,creator_id TEXT,predecessor_revision_id TEXT,created_at TEXT,payload_sha256 TEXT,publication_context TEXT);
CREATE TABLE sync_peer_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1),peer_id TEXT NOT NULL UNIQUE,kind TEXT NOT NULL);
CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,producer_kind TEXT,generated_at TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,status TEXT,manifest_json TEXT,staging_path TEXT,acknowledged_at TEXT);
CREATE TABLE sync_generation_artifacts(generation_id TEXT,logical_name TEXT,format TEXT,version INTEGER,filename TEXT,size_bytes INTEGER,sha256 TEXT,required INTEGER,PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename));
CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,consumed_at TEXT,result TEXT,durability TEXT,diagnostic TEXT,ack_json TEXT,UNIQUE(producer_peer_id,generation_id));
CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT);
CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id));
PRAGMA user_version=21;
"""
class GenerationTest(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory(); self.root=Path(self.temp.name)
self.database=self.root/"source.sqlite"
with closing(sqlite3.connect(self.database)) as db:
db.executescript(SCHEMA); db.execute("INSERT INTO facts VALUES('state','A')"); db.commit()
db.execute("INSERT INTO sync_peer_identity VALUES(1,?,'desktop')",(PEER_A,))
def tearDown(self): self.temp.cleanup()
@staticmethod
def artifacts():
return (("history","trainlog-mobile-export",4,"history.json",True,"controlled.py",()),)
def capture(self):
def exporter(_tool,_extra,output,snapshot):
with closing(sqlite3.connect(self.database)) as live: live.execute("UPDATE facts SET value='B'"); live.commit()
with closing(sqlite3.connect(snapshot)) as frozen: value=frozen.execute("SELECT value FROM facts").fetchone()[0]
output.write_text(json.dumps({"format":"trainlog-mobile-export","version":4,"state":value}))
with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}),mock.patch.object(generation,"run_export",exporter):
return generation.capture_desktop(self.database,self.root/"owned",PEER_B,RUN,GEN)
def validate(self,directory):
with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}):
return generation.validate_published(directory,PEER_B)
def publish(self):
with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}):
return generation.publish(self.database,GEN,self.root/"objects")
def test_coherent_capture_publish_replay_and_ack_restart(self):
stage,manifest,checksum=self.capture()
self.assertEqual("A",json.loads((stage/"history.json").read_text())["state"])
with closing(sqlite3.connect(self.database)) as db:self.assertEqual("B",db.execute("SELECT value FROM facts").fetchone()[0])
published=self.publish()
before={p.name:p.read_bytes() for p in published.iterdir()}
self.assertEqual(published,self.publish())
self.assertEqual(before,{p.name:p.read_bytes() for p in published.iterdir()})
checked,actual=self.validate(published);self.assertEqual(manifest["generation_id"],checked["generation_id"]);self.assertEqual(checksum,actual)
destination=self.root/"destination.sqlite"
with closing(sqlite3.connect(destination)) as db:
db.executescript(SCHEMA);db.execute("INSERT INTO sync_peer_identity VALUES(1,?,'desktop')",(PEER_B,));db.commit();db.execute("BEGIN IMMEDIATE")
db.execute("INSERT INTO facts VALUES('imported','yes')");ack=generation.record_consumed(db,manifest,checksum);db.commit()
ack_path=self.root/"ack.json";ack_path.write_bytes(generation.canonical(ack))
self.assertEqual("acknowledged",generation.accept_ack(self.database,ack_path));self.assertEqual("unchanged",generation.accept_ack(self.database,ack_path))
with closing(sqlite3.connect(destination)) as reopened:
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
def test_substitution_and_transaction_failure_leave_no_consumption(self):
_,manifest,checksum=self.capture();published=self.publish()
(published/"history.json").write_text("{}")
with self.assertRaisesRegex(generation.GenerationError,"size/digest"):self.validate(published)
destination=self.root/"rollback.sqlite"
with closing(sqlite3.connect(destination)) as db:
db.executescript(SCHEMA);db.execute("INSERT INTO sync_peer_identity VALUES(1,?,'desktop')",(PEER_B,));db.commit();db.execute("BEGIN IMMEDIATE")
db.execute("INSERT INTO facts VALUES('partial','rollback')");generation.record_consumed(db,manifest,checksum);db.rollback()
with closing(sqlite3.connect(destination)) as db:
self.assertEqual(0,db.execute("SELECT count(*) FROM facts WHERE id='partial'").fetchone()[0]);self.assertEqual(0,db.execute("SELECT count(*) FROM sync_consumed_generations").fetchone()[0])
def test_paths_duplicates_limits_and_wrong_ack_are_rejected(self):
_,manifest,checksum=self.capture()
with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}):
for filename in ("/absolute",f"generations/{GEN}/../escape",f"generations/{GEN}/deep/too-deep",f"generations\\{GEN}\\x"):
bad=json.loads(json.dumps(manifest));bad["artifacts"][0]["filename"]=filename
with self.assertRaises(generation.GenerationError):generation.validate_manifest_bytes(generation.canonical(bad),PEER_B)
duplicate=json.loads(json.dumps(manifest));duplicate["artifacts"].append(dict(duplicate["artifacts"][0]))
with self.assertRaises(generation.GenerationError):generation.validate_manifest_bytes(generation.canonical(duplicate),PEER_B)
exact=b" "*generation.MAX_MANIFEST
with self.assertRaisesRegex(generation.GenerationError,"invalid"):generation.validate_manifest_bytes(exact,PEER_B)
with self.assertRaisesRegex(generation.GenerationError,"byte bound"):generation.validate_manifest_bytes(exact+b" ",PEER_B)
self.publish()
published=self.root/"objects"/"generations"/GEN
original=(published/"manifest.json").read_bytes();(published/"manifest.json").write_bytes(original+b" ")
with self.assertRaisesRegex(generation.GenerationError,"different published bytes"):self.publish()
(published/"manifest.json").write_bytes(original)
wrong=generation.ack_document(RUN,GEN,PEER_A,PEER_B,checksum,"consumed",generation.now());wrong["consumer_peer_id"]="peer_55555555-5555-4555-8555-555555555555"
payload=dict(wrong);payload.pop("payload_sha256");wrong["payload_sha256"]=generation.digest_bytes(generation.canonical(payload))
path=self.root/"wrong.json";path.write_bytes(generation.canonical(wrong))
with self.assertRaisesRegex(generation.GenerationError,"does not match"):generation.accept_ack(self.database,path)
with closing(sqlite3.connect(self.database)) as db:self.assertEqual("waiting_acknowledgement",db.execute("SELECT status FROM sync_generations").fetchone()[0])
def test_missing_marker_unsupported_required_capability_and_aggregate_bounds(self):
empty=self.root/"partial";empty.mkdir()
with self.assertRaisesRegex(generation.GenerationError,"marker"):generation.validate_published(empty,PEER_B)
_,manifest,_=self.capture()
with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",set()):
with self.assertRaisesRegex(generation.GenerationError,"unsupported required"):
generation.validate_manifest_bytes(generation.canonical(manifest),PEER_B)
descriptors=[]
for index in range(4):
descriptors.append({"logical_name":f"part-{index}","format":"test","version":1,"filename":f"generations/{GEN}/p{index}","size":generation.MAX_ARTIFACT,"sha256":"0"*64,"required":False})
bounded=dict(manifest);bounded["artifacts"]=descriptors
with mock.patch.object(generation,"ARTIFACTS",()),mock.patch.object(generation,"SUPPORTED",set()):
generation.validate_manifest_bytes(generation.canonical(bounded),PEER_B)
bounded["artifacts"].append({"logical_name":"overflow","format":"test","version":1,"filename":f"generations/{GEN}/overflow","size":1,"sha256":"0"*64,"required":False})
with self.assertRaisesRegex(generation.GenerationError,"generation exceeds"):generation.validate_manifest_bytes(generation.canonical(bounded),PEER_B)
def test_causal_operation_bytes_survive_multiple_generation_associations(self):
kinds=("session","execution_draft","exercise","body_observation","custom_equipment","feedback","body_zone_relation")
operations=[]
for index,kind in enumerate(kinds,1):
operations.append({"operation_id":f"op_{index:08x}-aaaa-4aaa-8aaa-aaaaaaaaaaaa","target_kind":kind,"target_id":f"target-{index}","creator_id":"peer_cccccccc-cccc-4ccc-8ccc-cccccccccccc","predecessor_revision_id":f"lv_deadbeef{index}","created_at":"2026-09-17T10:00:00+02:00","payload_sha256":f"{index:x}"*64,"publication_context":None})
with closing(sqlite3.connect(self.database)) as db:
db.executemany("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,?)",[tuple(operation.values()) for operation in operations]);db.commit()
artifacts=(("causal-deletions","trainlog-causal-deletions",1,"causal-deletions-v1.json",True,"controlled.py",()),)
def exporter(_tool,_extra,output,_snapshot):output.write_text(json.dumps({"format":"trainlog-causal-deletions","version":1,"generated_at":"2026-09-17T10:00:00+02:00","operations":operations},sort_keys=True))
with mock.patch.object(generation,"ARTIFACTS",artifacts),mock.patch.object(generation,"SUPPORTED",{("trainlog-causal-deletions",1)}),mock.patch.object(generation,"run_export",exporter):
first=generation.capture_desktop(self.database,self.root/"causal",PEER_B,RUN,GEN)[0]
second_id="gen_55555555-5555-4555-8555-555555555555"
second=generation.capture_desktop(self.database,self.root/"causal",PEER_B,"sy_66666666-6666-4666-8666-666666666666",second_id)[0]
self.assertEqual(json.loads((first/"causal-deletions-v1.json").read_text())["operations"],json.loads((second/"causal-deletions-v1.json").read_text())["operations"])
with closing(sqlite3.connect(self.database)) as db:
self.assertEqual((7,7),tuple(db.execute("SELECT SUM(first_emission),COUNT(*)-SUM(first_emission) FROM sync_causal_publications").fetchone()))
self.assertEqual([operation["payload_sha256"] for operation in operations],[row[0] for row in db.execute("SELECT payload_sha256 FROM sync_causal_operations ORDER BY operation_id")])
def test_real_consumer_rolls_back_earlier_domain_write_on_late_failure(self):
names=("catalog","history","execution-drafts","exercise-aliases","exercise-profile-state","equipment-definitions","equipment-associations","body-zones","feedback","causal-deletions")
directory=self.root/"incoming";directory.mkdir()
artifacts=[]
for name in names:
filename=name+".json";(directory/filename).write_text("{}")
artifacts.append({"logical_name":name,"format":"test","version":1,"filename":f"generations/{GEN}/{filename}","size":2,"sha256":"0"*64,"required":True})
manifest={"format":generation.MANIFEST,"version":1,"generation_id":GEN,"run_id":RUN,"producer":{"peer_id":PEER_A,"kind":"android"},"consumer_peer_id":PEER_B,"generated_at":"2026-09-17T10:00:00+02:00","parent_generation_id":None,"artifacts":artifacts}
def write_fact(db,_payload,_trace=False):db.execute("INSERT INTO facts VALUES('partial','must roll back')");return {}
with mock.patch.object(generation,"validate_published",return_value=(manifest,"a"*64)), \
mock.patch.object(generation.causal_delete_exchange,"load",return_value={"operations":[]}), \
mock.patch.object(generation.execution_draft_exchange,"load",return_value={}), \
mock.patch.object(generation.import_exercise_aliases,"load",return_value=[]), \
mock.patch.object(generation,"strict_json",return_value={}), \
mock.patch.object(generation.import_equipment_definitions,"supplied_ids",return_value=set()), \
mock.patch.object(generation.import_equipment_definitions,"validate",return_value=[]), \
mock.patch.object(generation.import_mobile_export,"load_payload",return_value={}), \
mock.patch.object(generation.import_mobile_export,"validate_payload"), \
mock.patch.object(generation.import_equipment_associations,"load_mobile_occurrences",return_value={}), \
mock.patch.object(generation.import_exercise_body_zones,"parse_payload",return_value=[]), \
mock.patch.object(generation.import_training_feedback,"load",return_value={}), \
mock.patch.object(generation.import_exercise_profile_state,"apply_profile_state"), \
mock.patch.object(generation.import_equipment_definitions,"apply_definitions"), \
mock.patch.object(generation.import_mobile_export,"apply_payload",side_effect=write_fact), \
mock.patch.object(generation.import_exercise_aliases,"apply_aliases"), \
mock.patch.object(generation.import_equipment_associations,"apply_associations"), \
mock.patch.object(generation.import_exercise_body_zones,"apply_body_zones"), \
mock.patch.object(generation.import_training_feedback,"apply_feedback",side_effect=ValueError("late semantic failure")):
rejected=generation.consume_desktop(self.database,directory)
self.assertEqual("rejected",rejected["result"]);self.assertIn("late semantic",rejected["diagnostic"])
with closing(sqlite3.connect(self.database)) as db:
self.assertEqual(0,db.execute("SELECT COUNT(*) FROM facts WHERE id='partial'").fetchone()[0])
self.assertEqual(1,db.execute("SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'").fetchone()[0])
if __name__=="__main__":unittest.main()

View file

@ -1,137 +0,0 @@
import json, os, signal, subprocess, tempfile, time, unittest, uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ORCHESTRATOR = ROOT / "tools/sync_orchestrator.py"
def identity(prefix):
return prefix + str(uuid.uuid4())
class OrchestratorTest(unittest.TestCase):
def fixture(self, root, **overrides):
db = root / "trainlog.db"
db.touch()
state = Path(str(db) + ".sync-run.json")
run = identity("sy_")
request = identity("sy_")
state.write_text(
json.dumps(
{
"run_id": run,
"request_id": request,
"trigger": "web",
"phase": "requested",
"progress_revision": 1,
"result": "running",
}
)
)
config = {
"format": "trainlog-sync-orchestrator-config",
"version": 1,
"enabled": True,
"mode": "directory",
"expected_peer_id": identity("peer_"),
"transport_root": str(root / "transport"),
"owned_root": str(root / "owned"),
"timeout_seconds": 30,
} | overrides
(root / "transport").mkdir()
(root / "owned").mkdir()
path = root / "config.json"
path.write_text(json.dumps(config))
return db, state, path, run, request
def invoke(self, executable, values):
db, state, config, run, request = values
return subprocess.run(
[
"python3",
str(executable),
"--database",
str(db),
"--state",
str(state),
"--config",
str(config),
"--run-id",
run,
"--request-id",
request,
],
capture_output=True,
text=True,
)
def test_browser_configuration_cannot_select_command_or_boolean_timeout(self):
for override in (
{"peer_command": ["evil"]},
{"timeout_seconds": True},
{"mode": "unsupported"},
):
with self.subTest(override=override), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
values = self.fixture(root, **override)
result = self.invoke(ORCHESTRATOR, values)
self.assertEqual(result.returncode, 2)
self.assertEqual(json.loads(values[1].read_text())["phase"], "failed")
def test_stdout_bound_is_enforced_before_complete_accumulation(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sandbox = root / "program"
sandbox.mkdir()
(sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
(sandbox / "sync_peer_worker.py").write_text(
"import sys,time\nsys.stdout.write('x'*70000);sys.stdout.flush();time.sleep(30)\n"
)
values = self.fixture(root)
result = self.invoke(sandbox / "sync_orchestrator.py", values)
self.assertEqual(result.returncode, 2)
final = json.loads(values[1].read_text())
self.assertIn("exceeds 64 KiB", final["diagnostic"])
def test_shutdown_reaps_ignoring_helper_and_descendant(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sandbox = root / "program"
sandbox.mkdir()
pidfile = root / "pids"
(sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
(sandbox / "sync_peer_worker.py").write_text(
"import os,signal,time\nsignal.signal(signal.SIGTERM,signal.SIG_IGN)\npid=os.fork()\nif pid==0:\n signal.signal(signal.SIGTERM,signal.SIG_IGN);open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\nelse:\n open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\n"
% (str(pidfile), str(pidfile))
)
values = self.fixture(root)
db, state, config, run, request = values
process = subprocess.Popen(
[
"python3",
str(sandbox / "sync_orchestrator.py"),
"--database",
str(db),
"--state",
str(state),
"--config",
str(config),
"--run-id",
run,
"--request-id",
request,
]
)
deadline = time.monotonic() + 5
while not pidfile.is_file() and time.monotonic() < deadline:
time.sleep(0.02)
process.send_signal(signal.SIGTERM)
self.assertEqual(process.wait(timeout=10), 2)
for pid in map(int, pidfile.read_text().split()):
with self.assertRaises(ProcessLookupError):
os.kill(pid, 0)
self.assertEqual(json.loads(state.read_text())["phase"], "interrupted")
if __name__ == "__main__":
unittest.main()

View file

@ -27,13 +27,6 @@ def main():
out=d/"out.json";run("python",ROOT/"tools/export_training_feedback.py",out,"--database",db);x=json.loads(out.read_text());assert x["version"]==2 and len(x["exercise_feedback"])==1 and len(x["session_followups"])==2
revision={"revision_id":"fr_55555555-5555-4555-8555-555555555555","created_at":"2026-09-03T10:00:00+02:00","raw_text":"ressenti corrigé"};x["exercise_feedback"][0]["revisions"].append(revision);artifact.write_text(json.dumps(x),encoding="utf8")
run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db);again=run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db).stdout;assert "revisions_added=0" in again
# The generation worker imports history first, leaving sqlite3.Row enabled
# on the shared connection. Identical feedback must remain idempotent there.
import sys
sys.path.insert(0,str(ROOT/"tools"))
from import_training_feedback import apply_feedback,load
con=sqlite3.connect(db);con.row_factory=sqlite3.Row
con.execute("BEGIN IMMEDIATE");assert apply_feedback(con,load(artifact))==(0,3,0,4);con.rollback();con.close()
con=sqlite3.connect(db);assert con.execute("select count(*) from exercise_feedback_revisions").fetchone()[0]==2;assert con.execute("select raw_text from exercise_feedback").fetchone()[0]=="ressenti corrigé";con.close()
x["exercise_feedback"][0]["revisions"][-1]["raw_text"]="conflit";artifact.write_text(json.dumps(x),encoding="utf8");bad=subprocess.run(["python",str(ROOT/"tools/import_training_feedback.py"),str(artifact),"--database",str(db)],capture_output=True);assert bad.returncode!=0
print("TRAINING_FEEDBACK_SYNC_TEST=PASS")

View file

@ -1,228 +0,0 @@
#!/usr/bin/env python3
"""Real Firefox and Android production services in one correlated conversation."""
import json, os, signal, socket, sqlite3, subprocess, tempfile, time, unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUILD = ROOT / os.environ.get("TRAINLOG_WEB_E2E_BUILD", "build") / "tui"
GECKO = Path(
os.environ.get(
"TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver"
)
)
def await_condition(predicate, seconds=30):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(0.025)
raise AssertionError("condition did not become true")
class BrowserSyncTest(unittest.TestCase):
def test_button_starts_worker_reconnects_and_displays_committed_draft(self):
if not GECKO.is_file():
self.skipTest("pinned geckodriver unavailable")
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.support.ui import WebDriverWait
with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory:
root = Path(directory)
xdg = root / "data"
xdg.mkdir()
config = root / "config.json"
mode = os.environ.get("TRAINLOG_WEB_E2E_TRANSPORT_MODE", "directory")
transport = root / "transport"
transport.mkdir()
owned = root / "owned"
owned.mkdir()
remote = (
(root / "device/Documents/Trainlog") if mode == "mtp" else transport
)
remote.mkdir(parents=True, exist_ok=True)
release = root / "release-android"
android_log = (root / "android.log").open("wb")
android_env = os.environ.copy()
android_env.update(
{
"JAVA_HOME": "/usr/lib/jvm/java-17-openjdk",
"TRAINLOG_SYNC_TRANSPORT_ROOT": str(remote),
"TRAINLOG_SYNC_TEST_RELEASE_FILE": str(release),
}
)
android = subprocess.Popen(
[
"./gradlew",
"--no-daemon",
"testDebugUnitTest",
"--tests",
"com.labfytools.trainlog.data.SyncDeploymentConversationTest",
"--rerun-tasks",
],
cwd=ROOT / "android",
env=android_env,
stdout=android_log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
peer_file = await_condition(
lambda: (
(remote / "android-peer-v1.json")
if (remote / "android-peer-v1.json").is_file()
else None
),
120,
)
except Exception as error:
if android.poll() is None:
os.killpg(android.pid, signal.SIGTERM)
android.wait(timeout=10)
android_log.close()
raise AssertionError(
f"Android bridge did not advertise:\n{(root/'android.log').read_text(errors='replace')}"
) from error
peer = json.loads(peer_file.read_text())
config.write_text(
json.dumps(
{
"format": "trainlog-sync-orchestrator-config",
"version": 1,
"enabled": True,
"mode": mode,
"expected_peer_id": peer["peer_id"],
"transport_root": str(transport),
"owned_root": str(owned),
"timeout_seconds": 60,
}
)
)
with socket.socket() as reserved:
reserved.bind(("127.0.0.1", 0))
port = reserved.getsockname()[1]
env = os.environ.copy()
env.update(
{
"XDG_DATA_HOME": str(xdg),
"XDG_CONFIG_HOME": str(root / "config"),
"XDG_CACHE_HOME": str(root / "cache"),
"TRAINLOG_SYNC_GENERATION_CONFIG": str(config),
}
)
if mode == "mtp":
env.update(
{
"TRAINLOG_SYNC_MTP_ADAPTER": str(
BUILD / "generation-mtp-adapter-double"
),
"TRAINLOG_MTP_DOUBLE_ROOT": str(root / "device"),
}
)
server = subprocess.Popen(
[str(BUILD / "trainlog"), "--web", "--port", str(port)],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
driver = None
try:
for _ in range(100):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", port)) == 0:
break
time.sleep(0.05)
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
driver.get(f"http://127.0.0.1:{port}/")
wait = WebDriverWait(driver, 15)
button = wait.until(
lambda d: d.find_element(
By.XPATH, "//button[normalize-space()='Synchroniser']"
)
)
wait.until(lambda _: button.is_enabled())
button.click()
state_path = Path(
str(xdg / "trainlog" / "trainlog.db") + ".sync-run.json"
)
observed = await_condition(
lambda: (
json.loads(state_path.read_text())
if state_path.is_file()
and json.loads(state_path.read_text()).get("phase")
in ("local_import_committed", "failed", "interrupted")
else None
),
70,
)
self.assertEqual(
observed["phase"],
"local_import_committed",
f"state={observed}\nandroid={(root/'android.log').read_text(errors='replace')}",
)
committed = observed
self.assertEqual(committed["result"], "running")
with sqlite3.connect(xdg / "trainlog" / "trainlog.db") as database:
self.assertEqual(
database.execute(
"SELECT COUNT(*) FROM execution_drafts"
).fetchone()[0],
1,
)
release.touch()
final = await_condition(
lambda: (
json.loads(state_path.read_text())
if json.loads(state_path.read_text()).get("phase")
in ("completed", "failed", "interrupted")
else None
)
)
self.assertEqual(
final["phase"],
"completed",
f"state={final}\nandroid={((root/'android.log').read_text(errors='replace'))}",
)
wait.until(lambda d: "Synchronisation confirmée" in d.page_source)
summary = driver.find_element(
By.XPATH, "//summary[contains(.,'Synchronisation confirmée')]"
)
summary.click()
wait.until(
lambda d: "Brouillons reçus" in d.page_source
and "active" in d.page_source
)
driver.refresh()
wait.until(lambda d: "Synchronisation confirmée" in d.page_source)
state = json.loads(
Path(
str(xdg / "trainlog" / "trainlog.db") + ".sync-run.json"
).read_text()
)
self.assertEqual(state["phase"], "completed")
self.assertEqual(
android.wait(timeout=30),
0,
(root / "android.log").read_text(errors="replace"),
)
finally:
if driver is not None:
driver.quit()
os.killpg(server.pid, signal.SIGTERM)
server.wait(timeout=10)
if android.poll() is None:
os.killpg(android.pid, signal.SIGTERM)
android.wait(timeout=10)
android_log.close()
if __name__ == "__main__":
unittest.main()

View file

@ -1,81 +0,0 @@
package com.labfytools.trainlog.data
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
/** Minimal non-migrating bridge UI; it intentionally exposes backup/restore only. */
class BackupBridgeActivity : Activity() {
private lateinit var repository: TrainlogRepository
private lateinit var status: TextView
private val service by lazy { AndroidBackupService(applicationContext) }
override fun onCreate(state: Bundle?) {
super.onCreate(state)
repository = TrainlogRepository(applicationContext)
status =
TextView(this).apply {
text = "Plaintext local backup. Keep the exported file private."
}
val backup =
Button(this).apply {
text = "Create complete backup"
setOnClickListener {
startActivityForResult(
Intent(Intent.ACTION_CREATE_DOCUMENT)
.setType("application/zip")
.putExtra(Intent.EXTRA_TITLE, "trainlog-backup.tlbackup")
.addCategory(Intent.CATEGORY_OPENABLE),
1,
)
}
}
val restore =
Button(this).apply {
text = "Verify and restore backup"
setOnClickListener {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT)
.setType("application/zip")
.addCategory(Intent.CATEGORY_OPENABLE),
2,
)
}
}
setContentView(
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(status)
addView(backup)
addView(restore)
}
)
}
override fun onActivityResult(request: Int, result: Int, data: Intent?) {
super.onActivityResult(request, result, data)
if (result != RESULT_OK || data?.data == null) return
if (request == 1)
contentResolver.openOutputStream(data.data!!, "w")!!.use {
status.text = service.create(repository, it).toString()
}
else if (request == 2)
contentResolver.openInputStream(data.data!!)!!.use {
val verified =
service.verify(it).getOrElse { error ->
status.text = error.message
return
}
status.text = service.restore(repository, verified).toString()
recreate()
}
}
override fun onDestroy() {
repository.close()
super.onDestroy()
}
}

View file

@ -1,93 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraftForm
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BackupBridgeServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun schemaSeventeenWalDraftRoundTripsWithoutMigration() {
val name = "bridge-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
val service = AndroidBackupService(context)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile("Bridge", RecordingMode.SETS, TrackingMode.REPS, 0)
) as CreateExerciseResult.Created)
.exercise
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 7, weightKg = 20.0)),
)
),
form = SessionDraftForm(selectedExercise = exercise, weightText = "20,"),
)
),
)
context.getDatabasePath(name).let {
android.database.sqlite.SQLiteDatabase.openDatabase(
it.path,
null,
android.database.sqlite.SQLiteDatabase.OPEN_READWRITE,
)
.use { db ->
db.enableWriteAheadLogging()
db.execSQL("UPDATE active_session_draft SET weight_text='21,' WHERE id=1")
assertEquals(17, db.version)
}
}
val bytes = ByteArrayOutputStream()
assertTrue(service.create(repository, bytes) is AndroidBackupResult.Success)
System.getenv("TRAINLOG_BRIDGE_BACKUP_OUTPUT")?.let {
java.io.File(it).writeBytes(bytes.toByteArray())
}
val verified = service.verify(ByteArrayInputStream(bytes.toByteArray())).getOrThrow()
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
repository = TrainlogRepository(context, name)
assertEquals(
"21,",
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
.draft
.form
.weightText,
)
context.getDatabasePath(name).let {
android.database.sqlite.SQLiteDatabase.openDatabase(
it.path,
null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
)
.use { db -> assertEquals(17, db.version) }
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -1,36 +0,0 @@
#!/usr/bin/env python3
"""Create or restore a consistent Trainlog SQLite backup.
This tool never discovers a user database. Both source and destination must be
explicit, and restore refuses to overwrite an existing file. SQLite's backup
API includes committed WAL content without relying on a racy file copy.
"""
import argparse
import sqlite3
from pathlib import Path
def backup(source: Path, destination: Path) -> None:
if not source.is_file() or destination.exists():
raise RuntimeError("source must exist and destination must not exist")
destination.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(f"file:{source}?mode=ro", uri=True) as src, sqlite3.connect(destination) as dst:
src.backup(dst)
if dst.execute("PRAGMA integrity_check").fetchone() != ("ok",):
raise RuntimeError("backup integrity check failed")
violations = dst.execute("PRAGMA foreign_key_check").fetchall()
if violations:
raise RuntimeError(f"backup foreign-key violations: {len(violations)}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
backup(args.source, args.destination)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,112 +0,0 @@
#!/usr/bin/env python3
"""Materialize and build the non-migrating schema-v17 Android backup bridge."""
import argparse, io, os, shutil, subprocess, tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BASE = "77c15172e1401b91b8e2537c4b8548095ed50053"
def replace(path: Path, old: str, new: str):
value = path.read_text()
if value.count(old) != 1:
raise RuntimeError(f"bridge patch anchor mismatch: {path}: {old[:40]}")
path.write_text(value.replace(old, new))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--jdk", default="/usr/lib/jvm/java-17-openjdk")
args = parser.parse_args()
if args.output.exists():
raise RuntimeError("bridge output already exists")
args.output.mkdir(parents=True)
raw = subprocess.check_output(
["git", "archive", "--format=tar", BASE, "android", "catalog"], cwd=ROOT
)
with tarfile.open(fileobj=io.BytesIO(raw)) as archive:
for member in archive.getmembers():
target = (args.output / member.name).resolve()
if args.output.resolve() not in target.parents:
raise RuntimeError("unsafe archive member")
archive.extractall(args.output, filter="data")
android = args.output / "android"
repository = android / "app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt"
replace(
repository,
"import java.text.Normalizer",
"import java.text.Normalizer\nimport java.io.File",
)
replace(
repository,
" fun close() {\n database.close()\n }",
' fun close() {\n database.close()\n }\n\n internal fun backupDatabaseTo(destination: File) {\n require(!destination.exists())\n val escaped = destination.absolutePath.replace("\'", "\'\'")\n database.writableDatabase.execSQL("VACUUM INTO \'$escaped\'")\n }',
)
shutil.copy2(
ROOT / "android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt",
android / "app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt",
)
shutil.copy2(
ROOT / "tools/android_backup_bridge/BackupBridgeActivity.kt",
android / "app/src/main/java/com/labfytools/trainlog/data/BackupBridgeActivity.kt",
)
shutil.copy2(
ROOT / "tools/android_backup_bridge/BackupBridgeServiceTest.kt",
android / "app/src/test/java/com/labfytools/trainlog/data/BackupBridgeServiceTest.kt",
)
manifest = android / "app/src/main/AndroidManifest.xml"
replace(
manifest,
' <activity\n android:name=".MainActivity"',
' <activity android:name=".data.BackupBridgeActivity" android:exported="true" android:label="Trainlog Backup">\n <intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>\n </activity>\n <activity\n android:name=".MainActivity"',
)
gradle = android / "app/build.gradle.kts"
replace(
gradle,
"versionCode = 3",
'versionCode = providers.environmentVariable("TRAINLOG_BRIDGE_VERSION_CODE").orNull?.toInt() ?: 4',
)
env = os.environ.copy()
env["JAVA_HOME"] = args.jdk
env["TRAINLOG_BRIDGE_VERSION_CODE"] = env.get("TRAINLOG_BRIDGE_VERSION_CODE", "4")
env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"] = str(args.output / "schema17-synthetic.tlbackup")
subprocess.run(
[
str(android / "gradlew"),
"--no-daemon",
":app:testDebugUnitTest",
"--tests",
"com.labfytools.trainlog.data.BackupBridgeServiceTest",
":app:assembleRelease",
],
cwd=android,
env=env,
check=True,
)
migration_env = os.environ.copy()
migration_env["JAVA_HOME"] = args.jdk
migration_env["TRAINLOG_BRIDGE_BACKUP_INPUT"] = env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"]
subprocess.run(
[
str(ROOT / "android/gradlew"),
"--no-daemon",
":app:testDebugUnitTest",
"--tests",
"com.labfytools.trainlog.data.BridgeBackupMigrationTest",
"--rerun-tasks",
],
cwd=ROOT / "android",
env=migration_env,
check=True,
)
print(f"BRIDGE_BASE={BASE}")
print(
f"BRIDGE_RECIPE={subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()}"
)
print(f"BRIDGE_APK={android/'app/build/outputs/apk/release/app-release.apk'}")
if __name__ == "__main__":
main()

View file

@ -1,284 +0,0 @@
#!/usr/bin/env python3
"""Staged causal-deletion service; automatic synchronization remains V3."""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import sqlite3
import sys
import uuid
from pathlib import Path
from trainlog_sqlite import connect_database
from validate_json import parse_timestamp
from exercise_names import load_exercise_names
FORMAT = "trainlog-causal-deletions"
VERSION = 1
MAX_BYTES = 4 * 1024 * 1024
MAX_OPERATIONS = 4096
KINDS = {"session", "execution_draft", "exercise", "body_observation",
"custom_equipment", "feedback", "body_zone_relation"}
class CausalError(RuntimeError):
pass
def canonical(value) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def digest(value) -> str:
return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21):
raise CausalError(f"desktop schema v20/v21 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
if kind == "session":
row = db.execute("SELECT id,session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", (target,)).fetchone()
if row is None:
return None
session_row = row[0]
occurrences = []
for occurrence in db.execute(
"SELECT se.id,se.entry_id,e.exercise_id,se.position,se.recording_mode,se.tracking_mode,se.data_fields,se.equipment_id,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,se.notes "
"FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position,se.entry_id", (session_row,)
):
occurrence_row = occurrence[0]
sets = [list(value) for value in db.execute(
"SELECT position,reps,duration_seconds,weight_kg FROM performed_sets WHERE session_exercise_row_id=? ORDER BY position", (occurrence_row,))]
continuous = db.execute(
"SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", (occurrence_row,)).fetchone()
maximum = db.execute(
"SELECT max_weight_kg FROM max_results WHERE session_exercise_row_id=?", (occurrence_row,)).fetchone()
feedback = []
for root in db.execute(
"SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE session_exercise_row_id=? ORDER BY observed_at,feedback_id", (occurrence_row,)):
revisions = [list(value) for value in db.execute(
"SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", (root[0],))]
feedback.append([*root, revisions])
occurrence_note = db.execute(
"SELECT revision_id FROM sync_note_state WHERE owner_kind='occurrence' AND owner_id=?", (occurrence[1],)).fetchone()
occurrences.append([*occurrence[1:], sets,
None if continuous is None else list(continuous),
None if maximum is None else maximum[0], feedback,
None if occurrence_note is None else occurrence_note[0]])
session_note = db.execute(
"SELECT revision_id FROM sync_note_state WHERE owner_kind='session' AND owner_id=?", (target,)).fetchone()
links = [value[0] for value in db.execute(
"SELECT observation_id FROM body_observations WHERE session_row_id=? ORDER BY observation_id", (session_row,))]
return [*row[1:], occurrences, None if session_note is None else session_note[0], links]
elif kind == "execution_draft":
row = db.execute("SELECT session_id,revision_id FROM execution_drafts WHERE session_id=?", (target,)).fetchone()
elif kind == "exercise":
row = db.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises WHERE exercise_id=?", (target,)).fetchone()
elif kind == "body_observation":
row = db.execute("SELECT bo.observation_id,bo.observed_at,bo.body_weight_kg,bo.neck_cm,bo.shoulders_cm,bo.chest_cm,bo.waist_cm,bo.hips_cm,bo.left_arm_cm,bo.right_arm_cm,bo.left_forearm_cm,bo.right_forearm_cm,bo.left_thigh_cm,bo.right_thigh_cm,bo.left_calf_cm,bo.right_calf_cm,bo.notes,s.session_id FROM body_observations bo LEFT JOIN sessions s ON s.id=bo.session_row_id WHERE bo.observation_id=?", (target,)).fetchone()
if row is not None:
note = db.execute("SELECT revision_id FROM sync_note_state WHERE owner_kind='observation' AND owner_id=?", (target,)).fetchone()
return [*row, None if note is None else note[0]]
elif kind == "custom_equipment":
row = db.execute("SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment WHERE equipment_id=?", (target,)).fetchone()
elif kind == "feedback":
row = db.execute("SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE feedback_id=?", (target,)).fetchone()
if row is not None:
revisions = [list(value) for value in db.execute("SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", (target,))]
return [*row, revisions]
else:
parts = target.split("|")
if len(parts) != 3:
raise CausalError("body-zone target must be exercise_id|zone_id|role")
row = db.execute("SELECT e.exercise_id,z.zone_id,z.role FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id WHERE e.exercise_id=? AND z.zone_id=? AND z.role=?", parts).fetchone()
return None if row is None else list(row)
def live_revision(db: sqlite3.Connection, kind: str, target: str) -> str | None:
snapshot = target_snapshot(db, kind, target)
return None if snapshot is None else "lv_" + digest(snapshot)
def apply_effect(db: sqlite3.Connection, kind: str, target: str, operation_id: str) -> None:
if kind == "session":
db.execute("UPDATE body_observations SET session_row_id=NULL WHERE session_row_id=(SELECT id FROM sessions WHERE session_id=?)", (target,))
db.execute("DELETE FROM sessions WHERE session_id=?", (target,))
db.execute("INSERT OR IGNORE INTO execution_draft_finalizations VALUES(?,?,?)", (target, "deleted:" + operation_id, dt.datetime.now().astimezone().isoformat()))
elif kind == "execution_draft":
if db.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", (target,)).fetchone():
raise CausalError("draft finalization conflicts with deletion")
db.execute("DELETE FROM execution_drafts WHERE session_id=?", (target,))
elif kind == "body_observation":
db.execute("DELETE FROM body_observations WHERE observation_id=?", (target,))
elif kind == "body_zone_relation":
exercise, zone, role = target.split("|")
db.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?) AND zone_id=? AND role=?", (exercise, zone, role))
# Exercise/equipment retirement and feedback withdrawal retain the row and
# immutable history; availability/current-state readers consult the causal state.
def validate_operation(value: object) -> dict:
keys = {"operation_id", "target_kind", "target_id", "creator_id",
"predecessor_revision_id", "created_at", "payload_sha256",
"publication_context"}
if not isinstance(value, dict) or set(value) != keys:
raise CausalError("operation has unknown or missing fields")
for key, maximum in (("operation_id", 128), ("target_id", 512),
("creator_id", 128), ("predecessor_revision_id", 128)):
if not isinstance(value[key], str) or not 0 < len(value[key].encode()) <= maximum:
raise CausalError(f"invalid {key}")
if value["target_kind"] not in KINDS or value["publication_context"] is not None:
raise CausalError("invalid target kind or publication context")
if not isinstance(value["payload_sha256"], str) or len(value["payload_sha256"]) != 64 or any(c not in "0123456789abcdef" for c in value["payload_sha256"]):
raise CausalError("invalid payload digest")
parse_timestamp(value["created_at"], "created_at")
payload = {k: value[k] for k in keys - {"payload_sha256"}}
if digest(payload) != value["payload_sha256"]:
raise CausalError("operation payload digest mismatch")
return value
def apply_operation(db: sqlite3.Connection, operation: dict) -> str:
op = validate_operation(operation)
identity = op["operation_id"]
known = db.execute("SELECT target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256,publication_context FROM sync_causal_operations WHERE operation_id=?", (identity,)).fetchone()
expected = (op["target_kind"], op["target_id"], op["creator_id"], op["predecessor_revision_id"], op["created_at"], op["payload_sha256"], None)
if known:
if tuple(known) != expected:
raise CausalError("operation identity reused with different content")
return "unchanged"
authorize_target(db, op["target_kind"], op["target_id"])
state = db.execute("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", (op["target_kind"], op["target_id"])).fetchone()
current = state[0] if state else live_revision(db, op["target_kind"], op["target_id"])
if state and state[1]:
raise CausalError("concurrent deletion conflict")
if current is None:
raise CausalError("unknown target or ancestry")
if current != op["predecessor_revision_id"]:
raise CausalError("causal predecessor conflict")
count = db.execute("SELECT COUNT(*) FROM sync_causal_operations").fetchone()[0]
if count >= MAX_OPERATIONS:
raise CausalError("protection set exceeds operation bound")
db.execute("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,NULL)",
(identity, op["target_kind"], op["target_id"], op["creator_id"],
op["predecessor_revision_id"], op["created_at"], op["payload_sha256"]))
if len(canonical(export_document(db)).encode("utf-8")) > MAX_BYTES:
raise CausalError("protection set exceeds artifact byte bound")
apply_effect(db, op["target_kind"], op["target_id"], identity)
db.execute("INSERT OR REPLACE INTO sync_causal_state VALUES(?,?,?,?,?)",
(op["target_kind"], op["target_id"], identity, 1, identity))
return "applied"
def authorize_target(db: sqlite3.Connection, kind: str, target: str) -> None:
if kind == "exercise":
canonical = db.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (target,)).fetchone() if _table_exists(db, "exercise_aliases") else None
if target in load_exercise_names() or canonical is not None and canonical[0] in load_exercise_names():
raise CausalError("built-in exercise identity cannot be retired")
if kind == "custom_equipment" and _table_exists(db, "equipment") and db.execute(
"SELECT 1 FROM equipment WHERE equipment_id=? AND equipment_type<>'custom_machine'", (target,)).fetchone():
raise CausalError("built-in equipment identity cannot be retired")
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
return db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() is not None
def local_delete(db: sqlite3.Connection, kind: str, target: str, creator: str) -> tuple[dict, str]:
authorize_target(db, kind, target)
state = db.execute("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", (kind, target)).fetchone()
if state and state[1]:
row = db.execute("SELECT operation_id,target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256,publication_context FROM sync_causal_operations WHERE operation_id=?", (state[0],)).fetchone()
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
return dict(zip(keys, row)), "unchanged"
current = state[0] if state and not state[1] else live_revision(db, kind, target)
if current is None:
state = db.execute("SELECT operation_id FROM sync_causal_state WHERE target_kind=? AND target_id=? AND deleted=1", (kind, target)).fetchone()
if state:
row = db.execute("SELECT operation_id,target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256,publication_context FROM sync_causal_operations WHERE operation_id=?", state).fetchone()
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
return dict(zip(keys, row)), "unchanged"
raise CausalError("target not found")
operation = {"operation_id": "del_" + str(uuid.uuid4()), "target_kind": kind,
"target_id": target, "creator_id": creator,
"predecessor_revision_id": current,
"created_at": dt.datetime.now().astimezone().isoformat(),
"publication_context": None}
operation["payload_sha256"] = digest(operation)
return operation, apply_operation(db, operation)
def load(path: Path) -> dict:
with path.open("rb") as source:
raw = source.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
raise CausalError("artifact exceeds 4 MiB")
def unique(pairs):
result = {}
for key, value in pairs:
if key in result: raise CausalError("duplicate JSON key")
result[key] = value
return result
try: root = json.loads(raw.decode("utf-8"), object_pairs_hook=unique)
except (UnicodeError, json.JSONDecodeError) as error: raise CausalError("invalid UTF-8/JSON") from error
if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "operations"} or root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
raise CausalError("unsupported causal-deletion artifact")
parse_timestamp(root["generated_at"], "generated_at")
if not isinstance(root["operations"], list) or len(root["operations"]) > MAX_OPERATIONS:
raise CausalError("operations bound exceeded")
ids = set()
for value in root["operations"]:
op = validate_operation(value)
if op["operation_id"] in ids: raise CausalError("duplicate operation_id")
ids.add(op["operation_id"])
return root
def export_document(db: sqlite3.Connection) -> dict:
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
rows = db.execute("SELECT " + ",".join(keys) + " FROM sync_causal_operations ORDER BY operation_id LIMIT ?", (MAX_OPERATIONS + 1,)).fetchall()
if len(rows) > MAX_OPERATIONS: raise CausalError("protection set exceeds artifact bound")
document = {"format": FORMAT, "version": VERSION,
"generated_at": dt.datetime.now().astimezone().isoformat(),
"operations": [dict(zip(keys, row)) for row in rows]}
if len(canonical(document).encode("utf-8")) > MAX_BYTES:
raise CausalError("protection set exceeds artifact byte bound")
return document
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("mode", choices=("delete", "import", "export"))
parser.add_argument("path", type=Path)
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--kind", choices=sorted(KINDS)); parser.add_argument("--target-id")
parser.add_argument("--creator-id", default="peer_desktop_local")
args = parser.parse_args()
db = connect_database(args.database); require_schema(db)
try:
db.execute("BEGIN IMMEDIATE")
if args.mode == "delete":
if not args.kind or not args.target_id: raise CausalError("delete requires --kind and --target-id")
op, outcome = local_delete(db, args.kind, args.target_id, args.creator_id)
args.path.write_text(canonical(op), encoding="utf-8"); print(outcome)
elif args.mode == "import":
for op in load(args.path)["operations"]: print(op["operation_id"] + "=" + apply_operation(db, op))
else:
args.path.write_text(canonical(export_document(db)), encoding="utf-8"); print("CAUSAL_DELETE_EXPORT=PASS")
db.commit()
except Exception:
db.rollback(); raise
finally: db.close()
if __name__ == "__main__":
try: main()
except Exception as error:
print("CAUSAL_DELETE=FAIL " + str(error)); raise SystemExit(1)

View file

@ -1,296 +0,0 @@
#!/usr/bin/env python3
"""Explicit staged execution-draft codec and desktop lifecycle service.
This module is deliberately absent from trainlog_sync_run(): local persistence
success is not peer delivery and the active MTP protocol remains V3.
"""
import argparse
import json
import sqlite3
from datetime import datetime
from pathlib import Path
from trainlog_sqlite import connect_database
from validate_json import TrainlogSemanticError, parse_timestamp
FORMAT = "trainlog-execution-drafts"
VERSION = 1
MAX_DOCUMENT_BYTES = 4 * 1024 * 1024
MAX_DRAFTS = 16
MAX_ENTRIES = 64
ROOT_KEYS = {"format", "version", "generated_at", "drafts"}
DRAFT_KEYS = {"session_id", "session_type", "source_session_id", "started_at",
"revision_id", "parent_revision_id", "state", "note", "entries"}
ENTRY_KEYS = {"entry_id", "position", "exercise_id", "equipment_id",
"recording_mode", "tracking_mode", "data_fields", "load_mode",
"rest_seconds", "target", "sets", "continuous", "max_weight_kg",
"note", "feedback"}
class LifecycleError(RuntimeError):
pass
def exact(value, keys, label):
if not isinstance(value, dict) or set(value) != keys:
raise LifecycleError(f"{label}: exact keys required")
def text(value, label, nullable=False, maximum=128):
if nullable and value is None:
return
if not isinstance(value, str) or not value or len(value.encode()) > maximum:
raise LifecycleError(f"{label}: invalid text")
def timestamp(value, label, nullable=False):
if nullable and value is None:
return
text(value, label)
try:
parse_timestamp(value, label)
except TrainlogSemanticError as error:
raise LifecycleError(f"{label}: invalid timestamp") from error
def note(value, label):
if value is None:
return
exact(value, {"value", "revision_id", "parent_revision_id"}, label)
text(value["revision_id"], label + ".revision_id")
text(value["parent_revision_id"], label + ".parent_revision_id", nullable=True)
if value["parent_revision_id"] == value["revision_id"]:
raise LifecycleError(f"{label}: self parent")
if value["value"] is not None and (not isinstance(value["value"], str) or
len(value["value"].encode()) > 4096):
raise LifecycleError(f"{label}: note exceeds 4096 UTF-8 bytes")
def validate(document):
exact(document, ROOT_KEYS, "root")
if document["format"] != FORMAT or document["version"] != VERSION:
raise LifecycleError("unsupported execution-draft format/version")
timestamp(document["generated_at"], "generated_at")
drafts = document["drafts"]
if not isinstance(drafts, list) or len(drafts) > MAX_DRAFTS:
raise LifecycleError("drafts: invalid bound")
identities = set()
for di, draft in enumerate(drafts):
label = f"drafts[{di}]"
exact(draft, DRAFT_KEYS, label)
text(draft["session_id"], label + ".session_id")
if draft["session_id"] in identities:
raise LifecycleError(label + ": duplicate session_id")
identities.add(draft["session_id"])
if draft["session_type"] not in ("training", "max_test") or draft["state"] not in ("active", "pending"):
raise LifecycleError(label + ": invalid lifecycle enum")
text(draft["source_session_id"], label + ".source_session_id", nullable=True)
timestamp(draft["started_at"], label + ".started_at", nullable=True)
text(draft["revision_id"], label + ".revision_id")
text(draft["parent_revision_id"], label + ".parent_revision_id", nullable=True)
if draft["revision_id"] == draft["parent_revision_id"]:
raise LifecycleError(label + ": self parent")
note(draft["note"], label + ".note")
entries = draft["entries"]
if not isinstance(entries, list) or len(entries) > MAX_ENTRIES:
raise LifecycleError(label + ": entries bound")
entry_ids, positions = set(), set()
for ei, entry in enumerate(entries):
elabel = f"{label}.entries[{ei}]"
exact(entry, ENTRY_KEYS, elabel)
text(entry["entry_id"], elabel + ".entry_id")
text(entry["exercise_id"], elabel + ".exercise_id")
text(entry["equipment_id"], elabel + ".equipment_id", nullable=True)
if entry["entry_id"] in entry_ids or not isinstance(entry["position"], int) or entry["position"] < 0 or entry["position"] in positions:
raise LifecycleError(elabel + ": duplicate identity/position")
entry_ids.add(entry["entry_id"]); positions.add(entry["position"])
if entry["recording_mode"] not in ("sets", "continuous") or entry["tracking_mode"] not in ("reps", "duration"):
raise LifecycleError(elabel + ": invalid profile")
if entry["recording_mode"] == "continuous" and entry["tracking_mode"] != "duration":
raise LifecycleError(elabel + ": continuous requires duration")
if not isinstance(entry["data_fields"], int) or entry["data_fields"] < 0 or entry["data_fields"] & ~3:
raise LifecycleError(elabel + ": invalid data_fields")
if entry["load_mode"] not in ("none", "external", "assistance") or not isinstance(entry["rest_seconds"], int) or not 0 <= entry["rest_seconds"] <= 86400:
raise LifecycleError(elabel + ": invalid plan")
note(entry["note"], elabel + ".note")
if not isinstance(entry["feedback"], list) or len(entry["feedback"]) > 256:
raise LifecycleError(elabel + ": feedback bound")
feedback_ids = set()
for fi, feedback in enumerate(entry["feedback"]):
flabel = f"{elabel}.feedback[{fi}]"
exact(feedback, {"feedback_id", "observed_at", "raw_text", "revisions"}, flabel)
text(feedback["feedback_id"], flabel + ".feedback_id")
if feedback["feedback_id"] in feedback_ids:
raise LifecycleError(flabel + ": duplicate feedback_id")
feedback_ids.add(feedback["feedback_id"])
timestamp(feedback["observed_at"], flabel + ".observed_at")
text(feedback["raw_text"], flabel + ".raw_text", maximum=8192)
if not isinstance(feedback["revisions"], list) or len(feedback["revisions"]) > 32:
raise LifecycleError(flabel + ": revisions bound")
revision_ids = set()
for ri, revision in enumerate(feedback["revisions"]):
rlabel = f"{flabel}.revisions[{ri}]"
exact(revision, {"revision_id", "created_at", "raw_text"}, rlabel)
text(revision["revision_id"], rlabel + ".revision_id")
timestamp(revision["created_at"], rlabel + ".created_at")
text(revision["raw_text"], rlabel + ".raw_text", maximum=8192)
if revision["revision_id"] in revision_ids:
raise LifecycleError(rlabel + ": duplicate revision_id")
revision_ids.add(revision["revision_id"])
# The V4 history validator owns exact performed-data shapes. Draft
# import refuses ambiguous simultaneous representations here.
shapes = sum(value is not None for value in
(entry["sets"], entry["continuous"], entry["max_weight_kg"]))
if shapes > 1:
raise LifecycleError(elabel + ": contradictory actual shapes")
return document
def load(path):
raw = path.read_bytes()
if len(raw) > MAX_DOCUMENT_BYTES:
raise LifecycleError("document exceeds 4 MiB")
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise LifecycleError("duplicate JSON key: " + key)
result[key] = value
return result
try:
return validate(json.loads(raw.decode("utf-8"), object_pairs_hook=unique))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise LifecycleError("invalid UTF-8/JSON") from error
def canonical(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def causal_payload(draft):
"""Return immutable revision content without receiver scheduling state.
CONTRACT: ``revision_id`` identifies the draft content and causal parent;
``active`` versus ``pending`` is selected independently by each receiver.
A round trip may therefore rewrite only ``state`` without creating a
contradictory revision.
"""
value = dict(draft)
value.pop("state", None)
return canonical(value)
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21):
raise LifecycleError(f"desktop schema v19/v20/v21 required, found v{version}")
def import_document(connection, document, own_transaction=True):
require_schema(connection)
outcomes = []
if own_transaction:
connection.execute("BEGIN IMMEDIATE")
try:
for draft in document["drafts"]:
session_id, revision = draft["session_id"], draft["revision_id"]
payload = canonical(draft)
causal = causal_payload(draft)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=? AND deleted=1", (session_id,)).fetchone():
outcomes.append((session_id, "stale")); continue
if connection.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", (session_id,)).fetchone():
outcomes.append((session_id, "stale")); continue
current = connection.execute(
"SELECT revision_id,payload_json,state FROM execution_drafts WHERE session_id=?", (session_id,)).fetchone()
known = connection.execute(
"SELECT parent_revision_id,payload_json FROM execution_draft_revisions WHERE session_id=? AND revision_id=?",
(session_id, revision)).fetchone()
if known:
if (known[0] != draft["parent_revision_id"] or
causal_payload(json.loads(known[1])) != causal):
raise LifecycleError("contradictory revision: " + session_id)
outcomes.append((session_id, "unchanged" if current and current[0] == revision else "stale")); continue
if current and draft["parent_revision_id"] != current[0]:
outcomes.append((session_id, "conflict")); continue
if not current:
active = connection.execute("SELECT 1 FROM execution_drafts WHERE state='active'").fetchone()
pending = connection.execute("SELECT COUNT(*) FROM execution_drafts WHERE state='pending'").fetchone()[0]
state = "pending" if active else "active"
if state == "pending" and pending >= MAX_DRAFTS:
raise LifecycleError("pending execution-draft capacity exhausted")
connection.execute(
"INSERT INTO execution_drafts VALUES(?,?,?,?,?,?,?,?)",
(session_id, draft["session_type"], draft["source_session_id"], draft["started_at"],
revision, draft["parent_revision_id"], state, payload))
outcome = state
else:
connection.execute(
"UPDATE execution_drafts SET revision_id=?,parent_revision_id=?,payload_json=? WHERE session_id=?",
(revision, draft["parent_revision_id"], payload, session_id))
outcome = "applied"
connection.execute("INSERT INTO execution_draft_revisions VALUES(?,?,?,?)",
(session_id, revision, draft["parent_revision_id"], payload))
outcomes.append((session_id, outcome))
if own_transaction:
connection.commit()
except Exception:
if own_transaction:
connection.rollback()
raise
return outcomes
def export_document(connection):
require_schema(connection)
drafts = [json.loads(row[0]) for row in connection.execute(
"SELECT payload_json FROM execution_drafts ORDER BY CASE state WHEN 'active' THEN 0 ELSE 1 END,session_id")]
for draft, row in zip(drafts, connection.execute(
"SELECT state FROM execution_drafts ORDER BY CASE state WHEN 'active' THEN 0 ELSE 1 END,session_id")):
draft["state"] = row[0]
return {"format": FORMAT, "version": VERSION,
"generated_at": datetime.now().astimezone().isoformat(), "drafts": drafts}
def activate(connection, session_id):
require_schema(connection)
connection.execute("BEGIN IMMEDIATE")
try:
if connection.execute("SELECT 1 FROM execution_drafts WHERE state='active'").fetchone():
result = "occupied"
elif connection.execute("SELECT 1 FROM execution_drafts WHERE session_id=? AND state='pending'", (session_id,)).fetchone():
connection.execute("UPDATE execution_drafts SET state='active' WHERE session_id=?", (session_id,)); result = "activated"
else:
result = "not_found"
connection.commit(); return result
except Exception:
connection.rollback(); raise
def main():
parser = argparse.ArgumentParser()
parser.add_argument("mode", choices=("import", "export", "activate"))
parser.add_argument("path", type=Path)
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--session-id")
args = parser.parse_args()
connection = connect_database(args.database)
try:
if args.mode == "import":
for identity, outcome in import_document(connection, load(args.path)):
print(f"{identity}={outcome}")
elif args.mode == "export":
args.path.write_text(canonical(export_document(connection)), encoding="utf-8")
print("EXECUTION_DRAFT_EXPORT=PASS")
else:
if not args.session_id: raise LifecycleError("--session-id required")
print("activation=" + activate(connection, args.session_id))
finally:
connection.close()
if __name__ == "__main__":
try:
main()
except Exception as error:
print("EXECUTION_DRAFT=FAIL " + str(error))
raise SystemExit(1)

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ def default_database():
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db"
def main(complete_causal_envelope=False):
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, default=default_database())
@ -24,10 +24,8 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
raise ValueError("schema desktop v8 à v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ORDER BY equipment_id")]

View file

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

View file

@ -32,7 +32,7 @@ def default_database():
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db"
def main(complete_causal_envelope=False):
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, default=default_database())
@ -41,10 +41,8 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18):
raise ValueError("schema desktop v11-v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = []
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None:

View file

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

View file

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

View file

@ -59,37 +59,18 @@ def validate_v3_timestamp(value, label):
raise ValueError(f"{label}: date-heure Trainlog persistée invalide") from error
def note_payload(connection, owner_kind, owner_id, value):
row = connection.execute(
"SELECT r.revision_id,r.parent_revision_id,r.note FROM sync_note_state s "
"JOIN sync_note_revisions r USING(owner_kind,owner_id,revision_id) "
"WHERE s.owner_kind=? AND s.owner_id=?", (owner_kind, owner_id)).fetchone()
if row is None:
if value is not None:
raise ValueError(f"note héritée sans révision causale: {owner_kind}/{owner_id}")
return None
if row[2] != value:
raise ValueError(f"cache de note incohérent: {owner_kind}/{owner_id}")
return {"value": row[2], "revision_id": row[0], "parent_revision_id": row[1]}
def main(complete_causal_envelope=False):
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, default=default_database())
parser.add_argument("--version", type=int, choices=(2, 3, 4), default=3)
parser.add_argument("--version", type=int, choices=(2, 3), default=3)
args = parser.parse_args()
con = connect_database(args.database)
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21):
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) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
known_equipment = supplied_equipment_ids()
canonical_names = load_exercise_names()
known_equipment.update(row[0] for row in con.execute(
@ -101,25 +82,18 @@ def main(complete_causal_envelope=False):
exported = dict(row)
exported["name"] = canonical_names.get(row["exercise_id"], row["name"])
root["exercises"].append(exported)
for session in con.execute("SELECT id,session_id,started_at,session_type,ended_at,notes FROM sessions ORDER BY started_at,id"):
for session in con.execute("SELECT id,session_id,started_at,session_type FROM sessions ORDER BY started_at,id"):
# CONTRACT: a V3 producer must not publish history which the exact
# temporal readers reject. V2 export retains its published behavior.
if args.version >= 3:
if args.version == 3:
validate_v3_timestamp(session["started_at"],
f"session_id={session['session_id']} started_at")
payload = {key: session[key] for key in ("session_id", "started_at", "session_type")}
if args.version == 4:
if session["ended_at"] is not None:
validate_v3_timestamp(session["ended_at"], f"session_id={session['session_id']} ended_at")
if parse_timestamp(session["ended_at"], "ended_at") <= parse_timestamp(session["started_at"], "started_at"):
raise ValueError("ended_at doit être postérieur à started_at")
payload["ended_at"] = session["ended_at"]
payload["note"] = note_payload(con, "session", session["session_id"], session["notes"])
payload["exercises"] = []
# INVARIANT: v16 exports the occurrence-owned tracking snapshot;
# older readable schemas retain their bounded catalogue fallback.
tracking_sql = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode"
sql = f"SELECT se.id,se.entry_id,se.position,se.recording_mode,{tracking_sql},se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,se.notes FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position"
sql = f"SELECT se.id,se.entry_id,se.position,se.recording_mode,{tracking_sql},se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position"
for entry in con.execute(sql, (session["id"],)):
validate_plan(entry)
if entry["recording_mode"] == "continuous" or entry["max_weight_kg"] is not None:
@ -144,8 +118,8 @@ def main(complete_causal_envelope=False):
"name": canonical_names.get(entry["exercise_id"], entry["name"]),
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
"data_fields": entry["data_fields"],
"load_mode": entry["load_mode"] if args.version >= 3 else "none",
"rest_seconds": entry["rest_seconds"] if args.version >= 3 else 0,
"load_mode": entry["load_mode"] if args.version == 3 else "none",
"rest_seconds": entry["rest_seconds"] if args.version == 3 else 0,
"equipment_id": entry["equipment_id"]}
if args.version == 3:
if not has_target:
@ -160,16 +134,6 @@ def main(complete_causal_envelope=False):
if entry["target_duration_seconds"] is not None: target["duration_seconds"] = entry["target_duration_seconds"]
if entry["target_weight_kg"] is not None: target["weight_kg"] = entry["target_weight_kg"]
item["target"] = target
elif args.version == 4:
if not has_target:
item["target"] = None
else:
target = {"sets": entry["target_sets"]}
if entry["target_reps"] is not None: target["reps"] = entry["target_reps"]
if entry["target_duration_seconds"] is not None: target["duration_seconds"] = entry["target_duration_seconds"]
if entry["target_weight_kg"] is not None: target["weight_kg"] = entry["target_weight_kg"]
item["target"] = target
item["note"] = note_payload(con, "occurrence", entry["entry_id"], entry["notes"])
if entry["max_weight_kg"] is not None:
# CONTRACT: explicit max is an occurrence result, never a
# synthetic one-repetition performed set.
@ -191,18 +155,15 @@ def main(complete_causal_envelope=False):
"waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm",
"left_forearm_cm", "right_forearm_cm", "left_thigh_cm",
"right_thigh_cm", "left_calf_cm", "right_calf_cm")
columns = ",".join(("bo.observation_id", "bo.observed_at") + tuple("bo." + n for n in metric_names))
for row in con.execute(f"SELECT {columns},s.session_id,bo.notes FROM body_observations bo LEFT JOIN sessions s ON s.id=bo.session_row_id ORDER BY bo.observed_at,bo.id"):
if args.version >= 3:
columns = ",".join(("observation_id", "observed_at") + metric_names)
for row in con.execute(f"SELECT {columns} FROM body_observations ORDER BY observed_at,id"):
if args.version == 3:
# INVARIANT: validate every stored instant before touching the
# destination artifact, so corruption cannot clobber a prior file.
validate_v3_timestamp(row["observed_at"],
f"observation_id={row['observation_id']} observed_at")
item = {"observation_id": row["observation_id"], "observed_at": row["observed_at"]}
item.update({name: row[name] for name in metric_names if row[name] is not None})
if args.version == 4:
item["session_id"] = row["session_id"]
item["note"] = note_payload(con, "observation", row["observation_id"], row["notes"])
root["body_observations"].append(item)
args.output.write_text(json.dumps(root, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print("PC_MOBILE_EXPORT=PASS")

View file

@ -6,12 +6,11 @@ from pathlib import Path
def instant(value): return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
def main(complete_causal_envelope=False):
def main():
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21): raise ValueError("schema desktop v15-v21 requis")
if 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 db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18): raise ValueError("schema desktop v15-v18 requis")
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()
if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations")

View file

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

View file

@ -85,7 +85,7 @@ def load_mobile_occurrences(path):
payload = json.loads(path.read_text(encoding="utf-8"))
if (
payload.get("format") != "trainlog-mobile-export"
or payload.get("version") not in (2, 3, 4)
or payload.get("version") not in (2, 3)
or not isinstance(payload.get("sessions"), list)
):
fail("snapshot mobile V2 de preuve invalide")
@ -117,24 +117,6 @@ def load_mobile_occurrences(path):
return occurrences
def apply_associations(connection, payload, known, mobile_occurrences):
if payload.get("format") != FORMAT or payload.get("version") != VERSION or set(payload) != {"format", "version", "generated_at", "associations"}:
fail("extension équipement non supportée")
known = set(known); known.update(row[0] for row in connection.execute("SELECT equipment_id FROM custom_equipment"))
associations = validate_associations(payload["associations"], known)
for session_id, entry_id, exercise_id, equipment_id in associations:
exists = connection.execute("SELECT e.exercise_id,se.equipment_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?", (session_id, entry_id)).fetchone()
if exists is None: fail(f"entrée séance inconnue: {session_id}/{entry_id}")
stored_canonical = canonical_exercise_id(connection, exists[0]); incoming_canonical = canonical_exercise_id(connection, exercise_id)
if stored_canonical != incoming_canonical:
proof = mobile_occurrences.get((session_id, entry_id))
incoming_still_exists = connection.execute("SELECT 1 FROM exercises WHERE exercise_id=?", (exercise_id,)).fetchone() is not None
if not (proof == exercise_id and not incoming_still_exists) and not approved_machine_split(session_id, entry_id, incoming_canonical, stored_canonical, mobile_occurrences):
fail(f"conflit exercice association: {session_id}/{entry_id}")
if exists[1] != equipment_id: fail(f"conflit association équipement: {session_id}/{entry_id}")
return len(associations)
def approved_machine_split(session_id, entry_id, incoming_id, local_id,
mobile_occurrences):
"""Corroborate one frozen completed-history split with the current snapshot."""
@ -185,10 +167,18 @@ def main():
)
args = parser.parse_args()
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
if payload.get("format") != FORMAT or payload.get("version") != VERSION:
fail("extension équipement non supportée")
if set(payload) != {"format", "version", "generated_at", "associations"}:
fail("clés extension équipement invalides")
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
fail("schema desktop v8 à v21 requis")
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
fail("schema desktop v8 à v16 requis")
known = load_catalog(args.catalog)
known.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))
associations = validate_associations(payload["associations"], known)
proof_path = args.mobile_export
if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
@ -199,9 +189,41 @@ def main():
if proof_path is not None
else {}
)
association_count = apply_associations(connection, payload, load_catalog(args.catalog), mobile_occurrences)
for session_id, entry_id, exercise_id, equipment_id in associations:
exists = connection.execute(
"SELECT e.exercise_id,se.equipment_id FROM session_exercises se "
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
"WHERE s.session_id=? AND se.entry_id=?",
(session_id, entry_id)).fetchone()
if exists is None:
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
# WHY: the association companion can outlive the creator ID used
# by its source occurrence. The durable flattened alias is the
# synchronization identity evidence and is stronger than raw text.
stored_canonical = canonical_exercise_id(connection, exists[0])
incoming_canonical = canonical_exercise_id(connection, exercise_id)
if stored_canonical != incoming_canonical:
proof = mobile_occurrences.get((session_id, entry_id))
incoming_still_exists = connection.execute(
"SELECT 1 FROM exercises WHERE exercise_id=?;",
(exercise_id,),
).fetchone() is not None
alias_reconciliation = proof == exercise_id and not incoming_still_exists
split_reconciliation = approved_machine_split(
session_id, entry_id, incoming_canonical,
stored_canonical, mobile_occurrences)
if not alias_reconciliation and not split_reconciliation:
fail(f"conflit exercice association: {session_id}/{entry_id}")
# CONTRACT: the first fallback is only for schemas/runs without
# a persistent alias. The second is the closed v13 split table
# above; no source ID becomes a global one-to-many alias.
# INVARIANT: identity reconciliation never changes session_id,
# entry_id, equipment_id, or any persisted occurrence. A distinct
# live canonical exercise therefore remains a hard conflict.
if exists[1] != equipment_id:
fail(f"conflit association équipement: {session_id}/{entry_id}")
print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS")
print(f"associations={association_count}")
print(f"associations={len(associations)}")
finally:
connection.close()

View file

@ -51,23 +51,6 @@ def validate(payload, reserved):
return output
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
fail("schema desktop v8 à v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0
for definition in definitions:
row = connection.execute("SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment WHERE equipment_id=?", (definition[0],)).fetchone()
if row is not None and tuple(row) != definition: fail(f"conflit définition équipement: {definition[0]}")
for definition in definitions:
cursor = connection.execute("INSERT OR IGNORE INTO custom_equipment(equipment_id,display_name,label_name,equipment_type,load_semantics) VALUES(?,?,?,?,?)", definition)
if cursor.rowcount == 1: imported += 1
else: skipped += 1
return imported, skipped
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
@ -78,8 +61,26 @@ def main():
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
fail("schema desktop v8 à v16 requis")
imported = skipped = 0
# Validate every same-ID row before inserting any definition.
for definition in definitions:
row = connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment WHERE equipment_id=?", (definition[0],)).fetchone()
if row is not None and tuple(row) != definition:
fail(f"conflit définition équipement: {definition[0]}")
with connection:
imported, skipped = apply_definitions(connection, definitions)
for definition in definitions:
cursor = connection.execute(
"INSERT OR IGNORE INTO custom_equipment"
"(equipment_id,display_name,label_name,equipment_type,load_semantics) VALUES(?,?,?,?,?)",
definition)
if cursor.rowcount == 1:
imported += 1
else:
skipped += 1
print("EQUIPMENT_DEFINITIONS_IMPORT=PASS")
print(f"definitions_imported={imported}")
print(f"definitions_skipped={skipped}")

View file

@ -54,35 +54,6 @@ def load(path):
return result
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
fail("schema desktop v12-v21 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None: fail(f"cible canonique absente: {canonical}")
current = con.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (source,)).fetchone()
if current is not None:
if current[0] != canonical: fail(f"conflit alias: {source}")
continue
retired = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (source,)).fetchone()
if retired is not None:
if retired[1:] != target[1:]: fail(f"profil incompatible: {source}")
primary = lambda row_id: con.execute("SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", (row_id,)).fetchone()
sp, tp = primary(retired[0]), primary(target[0])
if sp and tp and sp[0] != tp[0]: fail(f"zone primaire incompatible: {source}")
chosen = tp[0] if tp else (sp[0] if sp else None)
if chosen:
con.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?", (target[0], chosen))
con.execute("INSERT INTO exercise_body_zones VALUES(?,?,'primary')", (target[0], chosen))
con.execute("INSERT OR IGNORE INTO exercise_body_zones SELECT ?,zone_id,'secondary' FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' AND zone_id<>COALESCE(?, '')", (target[0], retired[0], chosen))
con.execute("UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?", (target[0], retired[0]))
con.execute("DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?)", (retired[0], target[0]))
con.execute("UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?", (canonical, source))
con.execute("DELETE FROM exercises WHERE id=?", (retired[0],))
con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (source, canonical))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
@ -92,8 +63,35 @@ def main():
con = connect_database(args.database)
try:
con.execute("PRAGMA foreign_keys=ON")
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18):
fail("schema desktop v12-v16 requis")
con.execute("BEGIN IMMEDIATE")
apply_aliases(con, aliases)
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None:
fail(f"cible canonique absente: {canonical}")
current = con.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (source,)).fetchone()
if current is not None:
if current[0] != canonical: fail(f"conflit alias: {source}")
continue
retired = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (source,)).fetchone()
if retired is not None:
if retired[1:] != target[1:]: fail(f"profil incompatible: {source}")
primary = lambda row_id: con.execute("SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", (row_id,)).fetchone()
sp, tp = primary(retired[0]), primary(target[0])
if sp and tp and sp[0] != tp[0]: fail(f"zone primaire incompatible: {source}")
chosen = tp[0] if tp else (sp[0] if sp else None)
if chosen:
con.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?", (target[0], chosen))
con.execute("INSERT INTO exercise_body_zones VALUES(?,?,'primary')", (target[0], chosen))
con.execute("INSERT OR IGNORE INTO exercise_body_zones SELECT ?,zone_id,'secondary' FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' AND zone_id<>COALESCE(?, '')", (target[0], retired[0], chosen))
con.execute("UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?", (target[0], retired[0]))
con.execute("DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?)", (retired[0], target[0]))
# INVARIANT: earlier sources stay one hop from a live target;
# deleting an intermediate canonical can never create a chain.
con.execute("UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?", (canonical, source))
con.execute("DELETE FROM exercises WHERE id=?", (retired[0],))
con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (source, canonical))
con.commit()
except Exception:
con.rollback(); raise

View file

@ -149,57 +149,6 @@ def replace(connection, row_id, primary, secondary):
)
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21): raise ImportFailure("schema desktop v11-v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct
# primary/secondary claims may be equal but are never unioned.
for exercise_id,primary,secondary in parsed:
row_id=resolve_exercise_row(connection,exercise_id,mobile_proof);incoming=(primary,secondary);group=grouped.get(row_id)
if group is None:grouped[row_id]=(exercise_id,incoming)
elif group[1]!=incoming:raise ImportFailure(f"données de zones incompatibles après résolution d'alias: {group[0]} / {exercise_id}")
planned=[]
for row_id,(exercise_id,incoming) in grouped.items():
primary,secondary=incoming;local_primary,local_secondary=current(connection,row_id);local_state=state(local_primary,local_secondary);incoming_state=state(primary,secondary)
baseline_row=connection.execute("SELECT synced_state FROM exercise_body_zone_sync WHERE exercise_row_id=?",(row_id,)).fetchone();baseline=None if baseline_row is None else baseline_row[0]
if local_state==incoming_state:action="skip"
elif baseline is not None and local_state==baseline:action="update"
elif baseline is not None and incoming_state==baseline:action="keep"
elif baseline is None and local_state=="|":action="update"
else:raise ImportFailure(f"conflit zones simultané: {exercise_id}")
planned.append((action,row_id,primary,secondary,incoming_state))
updated=skipped=kept_local=0
# WHY: aliases are compatibility identities, not extra exercises. Apply
# once per canonical row so input order cannot change the chosen payload.
for action,row_id,primary,secondary,incoming_state in planned:
if action=="keep":kept_local+=1;continue
if action=="update":replace(connection,row_id,primary,secondary);updated+=1
else:skipped+=1
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)",(row_id,incoming_state))
return updated,skipped,kept_local
def parse_payload(payload):
zones=load_catalog();exact(payload,{"format","version","generated_at","exercises"},"racine")
if payload["format"]!="trainlog-exercise-body-zones" or payload["version"]!=1 or isinstance(payload["version"],bool) or not isinstance(payload["generated_at"],str) or not payload["generated_at"] or not isinstance(payload["exercises"],list):raise ImportFailure("companion zones v1 invalide")
try: generated_at=datetime.fromisoformat(payload["generated_at"].replace("Z","+00:00"))
except ValueError as error:raise ImportFailure("generated_at invalide") from error
if generated_at.utcoffset() is None:raise ImportFailure("generated_at sans offset")
parsed=[];seen=set()
for index,item in enumerate(payload["exercises"]):
exact(item,{"exercise_id","primary_zone_id","secondary_zone_ids"},f"exercises[{index}]");exercise_id=item["exercise_id"];primary=item["primary_zone_id"];secondary=item["secondary_zone_ids"]
if not isinstance(exercise_id,str) or EXERCISE_ID_PATTERN.fullmatch(exercise_id) is None or exercise_id in seen:raise ImportFailure(f"exercises[{index}].exercise_id invalide")
if primary is not None and (not isinstance(primary,str) or primary not in zones):raise ImportFailure(f"exercises[{index}].primary_zone_id invalide")
if not isinstance(secondary,list) or any(not isinstance(zone,str) or zone not in zones for zone in secondary):raise ImportFailure(f"exercises[{index}].secondary_zone_ids invalide")
if len(secondary)!=len(set(secondary)) or primary in secondary:raise ImportFailure(f"exercises[{index}]: zones dupliquées")
if primary is None and secondary:raise ImportFailure(f"exercises[{index}]: secondaires sans zone principale")
if any(zones[zone]["kind"]=="group" for zone in ([primary] if primary else [])+secondary):raise ImportFailure(f"exercises[{index}]: relation parent dérivable interdite")
seen.add(exercise_id);parsed.append((exercise_id,primary,sorted(secondary)))
return parsed
def main():
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
@ -210,8 +159,42 @@ def main():
help="snapshot V2 importé juste avant, utilisé seulement pour prouver un ID réconcilié",
)
args = parser.parse_args()
zones = load_catalog()
payload = json.loads(args.input.read_text(encoding="utf-8"))
parsed = parse_payload(payload)
exact(payload, {"format", "version", "generated_at", "exercises"}, "racine")
if payload["format"] != "trainlog-exercise-body-zones" or payload["version"] != 1 or \
isinstance(payload["version"], bool) or \
not isinstance(payload["generated_at"], str) or not payload["generated_at"] or \
not isinstance(payload["exercises"], list):
raise ImportFailure("companion zones v1 invalide")
try:
generated_at = datetime.fromisoformat(payload["generated_at"].replace("Z", "+00:00"))
except ValueError as error:
raise ImportFailure("generated_at invalide") from error
if generated_at.utcoffset() is None:
raise ImportFailure("generated_at sans offset")
parsed = []
seen = set()
for index, item in enumerate(payload["exercises"]):
exact(item, {"exercise_id", "primary_zone_id", "secondary_zone_ids"}, f"exercises[{index}]")
exercise_id = item["exercise_id"]
primary = item["primary_zone_id"]
secondary = item["secondary_zone_ids"]
if not isinstance(exercise_id, str) or EXERCISE_ID_PATTERN.fullmatch(exercise_id) is None or \
exercise_id in seen:
raise ImportFailure(f"exercises[{index}].exercise_id invalide")
if primary is not None and (not isinstance(primary, str) or primary not in zones):
raise ImportFailure(f"exercises[{index}].primary_zone_id invalide")
if not isinstance(secondary, list) or any(not isinstance(zone, str) or zone not in zones for zone in secondary):
raise ImportFailure(f"exercises[{index}].secondary_zone_ids invalide")
if len(secondary) != len(set(secondary)) or primary in secondary:
raise ImportFailure(f"exercises[{index}]: zones dupliquées")
if primary is None and secondary:
raise ImportFailure(f"exercises[{index}]: secondaires sans zone principale")
if any(zones[zone]["kind"] == "group" for zone in ([primary] if primary else []) + secondary):
raise ImportFailure(f"exercises[{index}]: relation parent dérivable interdite")
seen.add(exercise_id)
parsed.append((exercise_id, primary, sorted(secondary)))
# A V2 snapshot is reconciliation evidence only when the caller selected
# and supplied that exact snapshot. Never discover a sibling implicitly:
@ -220,9 +203,68 @@ def main():
if args.mobile_export is not None else {})
connection = connect_database(args.database)
updated = skipped = kept_local = 0
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18):
raise ImportFailure("schema desktop v11-v16 requis")
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("BEGIN IMMEDIATE")
updated,skipped,kept_local=apply_body_zones(connection,parsed,mobile_proof)
grouped = {}
for exercise_id, primary, secondary in parsed:
row_id = resolve_exercise_row(connection, exercise_id, mobile_proof)
incoming = (primary, secondary)
group = grouped.get(row_id)
if group is None:
grouped[row_id] = (exercise_id, incoming)
elif group[1] != incoming:
# CONTRACT: secondary zones are direct ordered-set metadata, so
# alias coalescing may accept equality but must never union two
# companion claims or choose between primary/secondary roles.
raise ImportFailure(
f"données de zones incompatibles après résolution d'alias: "
f"{group[0]} / {exercise_id}"
)
# INVARIANT: resolve and compare the complete alias groups before the
# first write. This makes one reconciliation decision per canonical
# exercise and prevents input order from changing the chosen payload.
planned = []
for row_id, (exercise_id, incoming) in grouped.items():
primary, secondary = incoming
local_primary, local_secondary = current(connection, row_id)
local_state = state(local_primary, local_secondary)
incoming_state = state(primary, secondary)
baseline_row = connection.execute(
"SELECT synced_state FROM exercise_body_zone_sync WHERE exercise_row_id=?", (row_id,),
).fetchone()
baseline = None if baseline_row is None else baseline_row[0]
if local_state == incoming_state:
planned.append(("skip", row_id, primary, secondary, incoming_state))
elif baseline is not None and local_state == baseline:
planned.append(("update", row_id, primary, secondary, incoming_state))
elif baseline is not None and incoming_state == baseline:
planned.append(("keep", row_id, primary, secondary, incoming_state))
elif baseline is None and local_state == "|":
planned.append(("update", row_id, primary, secondary, incoming_state))
else:
raise ImportFailure(f"conflit zones simultané: {exercise_id}")
# WHY: aliases are compatibility identities, not additional exercises.
# Applying the plan once per resolved row avoids duplicate mutations and
# cannot recreate a retired source ID.
for action, row_id, primary, secondary, incoming_state in planned:
if action == "keep":
kept_local += 1
continue
if action == "update":
replace(connection, row_id, primary, secondary)
updated += 1
else:
skipped += 1
connection.execute(
"INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)",
(row_id, incoming_state),
)
connection.commit()
print("EXERCISE_BODY_ZONES_IMPORT=PASS")
print(f"zones_updated={updated}")

View file

@ -46,18 +46,22 @@ def validate(item):
revision_ids.add(rid);previous=rid
if any(history[-1][key]!=item[key] for key in keys): fail("pointe historique incohérente")
def apply_profile_state(con, root, allow_pending=False):
def main():
p=argparse.ArgumentParser();p.add_argument("artifact",type=Path);p.add_argument("--database",required=True,type=Path);p.add_argument("--allow-pending",action="store_true");p.add_argument("--mobile-export",type=Path,help="sync pre-pass identity proof; permits identities introduced by the following catalog import");a=p.parse_args()
try:
root=json.loads(a.artifact.read_text(encoding="utf-8"))
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21): fail("schema desktop v17-v21 requis")
applied=equal=ancestor=pending=0;seen=set()
parse_timestamp(root["generated_at"], "generated_at")
con=connect_database(a.database);con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18): fail("schema desktop v17/v18 requis")
applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE")
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]
if eid in seen: fail("exercise_id profile-state dupliqué")
seen.add(eid)
row=con.execute("""SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields,e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id,e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?""",(eid,)).fetchone()
if row is None:
if allow_pending: pending+=1;continue
if a.allow_pending or a.mobile_export is not None: pending+=1;continue
fail(f"identité exercice inconnue: {eid}")
if any(row[k]!=item[k] for k in ADJ): fail(f"conflit identité/machine immutable: {eid}")
local_profile=(row["recording_mode"],row["tracking_mode"],row["data_fields"]);incoming=(item["recording_mode"],item["tracking_mode"],item["data_fields"])
@ -86,14 +90,6 @@ def apply_profile_state(con, root, allow_pending=False):
con.execute("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=? WHERE exercise_row_id=?",(item["revision_id"],item["parent_revision_id"],int(item["legacy_seed"]),row["id"]))
con.execute("UPDATE exercises SET recording_mode=?,tracking_mode=?,data_fields=? WHERE id=?",(*incoming,row["id"]))
applied+=1
return applied,equal,ancestor,pending
def main():
p=argparse.ArgumentParser();p.add_argument("artifact",type=Path);p.add_argument("--database",required=True,type=Path);p.add_argument("--allow-pending",action="store_true");p.add_argument("--mobile-export",type=Path,help="sync pre-pass identity proof; permits identities introduced by the following catalog import");a=p.parse_args()
try:
root=json.loads(a.artifact.read_text(encoding="utf-8"));con=connect_database(a.database)
con.execute("BEGIN IMMEDIATE")
applied,equal,ancestor,pending=apply_profile_state(con,root,a.allow_pending or a.mobile_export is not None)
con.commit();print(f"EXERCISE_PROFILE_STATE_IMPORT=PASS applied={applied} equal={equal} ancestor={ancestor} pending={pending}")
except (OSError,json.JSONDecodeError,sqlite3.Error,ValueError) as e:
try:

View file

@ -9,7 +9,6 @@ import os
import sqlite3
import sys
import unicodedata
import uuid
from pathlib import Path
from validate_json import TrainlogSemanticError, parse_timestamp
@ -49,7 +48,6 @@ SESSION_KEYS = {
"session_type",
"exercises",
}
V4_SESSION_KEYS = SESSION_KEYS | {"ended_at", "note"}
SESSION_EXERCISE_KEYS = {
"exercise_id",
@ -67,13 +65,11 @@ V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | {
"entry_id", "position", "equipment_id", "max_weight_kg"
}
V3_SESSION_EXERCISE_KEYS = V2_SESSION_EXERCISE_KEYS | {"target"}
V4_SESSION_EXERCISE_KEYS = V3_SESSION_EXERCISE_KEYS | {"note"}
BODY_BASE_KEYS = {
"observation_id",
"observed_at",
}
V4_BODY_KEYS = BODY_BASE_KEYS | {"session_id", "note"}
BODY_METRIC_KEYS = {
"body_weight_kg",
@ -259,14 +255,14 @@ def load_payload(path):
"format mobile export invalide"
)
if payload["version"] not in (1, 2, 3, 4):
if payload["version"] not in (1, 2, 3):
raise ImportFailure(
"version mobile export non supportée"
)
# CONTRACT: V1/V2 retain their published nonempty-string admission. V3 is
# the current analysis-bearing artifact and uses the exact Trainlog parser.
if payload["version"] >= 3:
if payload["version"] == 3:
require_v3_timestamp(payload["generated_at"], "generated_at")
else:
require_nonempty_string(payload["generated_at"], "generated_at")
@ -451,24 +447,6 @@ def validate_set_item(
return ("duration", duration)
def validate_note(value, label):
if value is None:
return
require_exact_keys(value, {"value", "revision_id", "parent_revision_id"},
{"value", "revision_id", "parent_revision_id"}, label)
revision = require_nonempty_string(value["revision_id"], f"{label}.revision_id")
if len(revision.encode("utf-8")) > 128:
raise ImportFailure(f"{label}.revision_id: hors bornes")
parent = value["parent_revision_id"]
if parent is not None:
require_nonempty_string(parent, f"{label}.parent_revision_id")
if len(parent.encode("utf-8")) > 128 or parent == revision:
raise ImportFailure(f"{label}.parent_revision_id: invalide")
note = value["value"]
if note is not None and (not isinstance(note, str) or len(note.encode("utf-8")) > 4096):
raise ImportFailure(f"{label}.value: texte UTF-8 hors bornes")
def validate_session_exercise(
item,
label,
@ -477,7 +455,7 @@ def validate_session_exercise(
version,
):
is_v2 = version >= 2
keys = V4_SESSION_EXERCISE_KEYS if version == 4 else V3_SESSION_EXERCISE_KEYS if version == 3 else (
keys = V3_SESSION_EXERCISE_KEYS if version == 3 else (
V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS)
require_exact_keys(
item,
@ -486,8 +464,6 @@ def validate_session_exercise(
- {"sets", "continuous", "max_weight_kg"},
label,
)
if version == 4:
validate_note(item["note"], f"{label}.note")
if is_v2:
require_nonempty_string(item["entry_id"], f"{label}.entry_id")
@ -674,8 +650,12 @@ def validate_sessions(
):
label = f"sessions[{index}]"
session_keys = V4_SESSION_KEYS if payload["version"] == 4 else SESSION_KEYS
require_exact_keys(session, session_keys, session_keys, label)
require_exact_keys(
session,
SESSION_KEYS,
SESSION_KEYS,
label,
)
session_id = require_nonempty_string(
session["session_id"],
@ -689,21 +669,13 @@ def validate_sessions(
seen_ids.add(session_id)
if payload["version"] >= 3:
if payload["version"] == 3:
# INVARIANT: accepted V3 history must remain readable by temporal
# analysis; validation completes before run_import mutates SQLite.
require_v3_timestamp(session["started_at"], f"{label}.started_at")
else:
require_nonempty_string(session["started_at"], f"{label}.started_at")
if payload["version"] == 4:
validate_note(session["note"], f"{label}.note")
if session["ended_at"] is not None:
require_v3_timestamp(session["ended_at"], f"{label}.ended_at")
if parse_timestamp(session["ended_at"], f"{label}.ended_at") <= parse_timestamp(
session["started_at"], f"{label}.started_at"):
raise ImportFailure(f"{label}.ended_at: doit être postérieur au début")
if session["session_type"] not in (
"training",
"max_test",
@ -776,16 +748,18 @@ def validate_sessions(
def validate_body(payload):
seen_ids = set()
allowed = BODY_BASE_KEYS | BODY_METRIC_KEYS
if payload["version"] == 4:
allowed |= V4_BODY_KEYS
for index, observation in enumerate(
payload["body_observations"]
):
label = f"body_observations[{index}]"
required = BODY_BASE_KEYS | ({"session_id", "note"} if payload["version"] == 4 else set())
require_exact_keys(observation, allowed, required, label)
require_exact_keys(
observation,
allowed,
BODY_BASE_KEYS,
label,
)
observation_id = require_nonempty_string(
observation["observation_id"],
@ -799,14 +773,10 @@ def validate_body(payload):
seen_ids.add(observation_id)
if payload["version"] >= 3:
if payload["version"] == 3:
require_v3_timestamp(observation["observed_at"], f"{label}.observed_at")
else:
require_nonempty_string(observation["observed_at"], f"{label}.observed_at")
if payload["version"] == 4:
validate_note(observation["note"], f"{label}.note")
if observation["session_id"] is not None:
require_nonempty_string(observation["session_id"], f"{label}.session_id")
present_metrics = (
set(observation.keys())
@ -846,9 +816,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
raise ImportFailure(
f"base desktop schema v5 à v21 attendue, version trouvée: {version}"
f"base desktop schema v5 à v18 attendue, version trouvée: {version}"
)
@ -1298,68 +1268,6 @@ def occurrence_plan_values(item):
)
def apply_note(connection, owner_kind, owner_id, payload, table, where, arguments):
"""Apply one direct causal child without using wall-clock ordering."""
if payload is None:
retained = connection.execute(
"SELECT r.note FROM sync_note_state s JOIN sync_note_revisions r "
"USING(owner_kind,owner_id,revision_id) WHERE s.owner_kind=? AND s.owner_id=?",
(owner_kind, owner_id)).fetchone()
if retained is not None:
connection.execute(f"UPDATE {table} SET notes=? WHERE {where}",
(retained[0], *arguments))
return "unchanged"
current = connection.execute(
"SELECT revision_id FROM sync_note_state WHERE owner_kind=? AND owner_id=?",
(owner_kind, owner_id)).fetchone()
existing = connection.execute(
"SELECT parent_revision_id,note FROM sync_note_revisions "
"WHERE owner_kind=? AND owner_id=? AND revision_id=?",
(owner_kind, owner_id, payload["revision_id"])).fetchone()
if existing is not None:
if tuple(existing) != (payload["parent_revision_id"], payload["value"]):
raise ImportFailure(f"révision de note contradictoire: {owner_kind}/{owner_id}")
if current is not None and current[0] == payload["revision_id"]:
return "unchanged"
raise ImportFailure(f"révision de note obsolète: {owner_kind}/{owner_id}")
if current is not None and payload["parent_revision_id"] != current[0]:
raise ImportFailure(f"conflit causal de note: {owner_kind}/{owner_id}")
if current is None and payload["parent_revision_id"] is not None:
raise ImportFailure(f"parent de note inconnu: {owner_kind}/{owner_id}")
connection.execute(
"INSERT INTO sync_note_revisions(owner_kind,owner_id,revision_id,parent_revision_id,note) "
"VALUES(?,?,?,?,?)", (owner_kind, owner_id, payload["revision_id"],
payload["parent_revision_id"], payload["value"]))
connection.execute(
"INSERT INTO sync_note_state(owner_kind,owner_id,revision_id) VALUES(?,?,?) "
"ON CONFLICT(owner_kind,owner_id) DO UPDATE SET revision_id=excluded.revision_id",
(owner_kind, owner_id, payload["revision_id"]))
connection.execute(f"UPDATE {table} SET notes=? WHERE {where}",
(payload["value"], *arguments))
return "applied"
def note_matches(connection, owner_kind, owner_id, payload):
row = connection.execute(
"SELECT r.revision_id,r.parent_revision_id,r.note FROM sync_note_state s "
"JOIN sync_note_revisions r USING(owner_kind,owner_id,revision_id) "
"WHERE s.owner_kind=? AND s.owner_id=?", (owner_kind, owner_id)).fetchone()
if payload is None:
return row is None
return row is not None and tuple(row) == (
payload["revision_id"], payload["parent_revision_id"], payload["value"])
def v4_session_metadata_matches(connection, session_row_id, session):
header = connection.execute(
"SELECT ended_at FROM sessions WHERE id=?", (session_row_id,)).fetchone()
if header is None or header[0] != session["ended_at"] or not note_matches(
connection, "session", session["session_id"], session["note"]):
return False
return all(note_matches(connection, "occurrence", item["entry_id"], item["note"])
for item in session["exercises"])
def import_set_session_exercise(
connection,
session_row_id,
@ -1440,7 +1348,6 @@ def import_set_session_exercise(
duration, set_item.get("weight_kg"),
),
)
return session_exercise_row_id
def import_continuous_session_exercise(
connection,
@ -1506,7 +1413,6 @@ def import_continuous_session_exercise(
continuous.get("distance_km"),
),
)
return cursor.lastrowid
def import_max_session_exercise(
@ -1546,7 +1452,6 @@ def import_max_session_exercise(
"VALUES(?,?);",
(cursor.lastrowid, item["max_weight_kg"]),
)
return cursor.lastrowid
def import_sessions(
@ -1586,12 +1491,12 @@ def import_sessions(
current = [(row[0], row[1]) for row in rows]
legacy = all(value[0].startswith("sxe_legacy_") or value[0].startswith("sxe_v1_") for value in current)
header = connection.execute(
"SELECT started_at,session_type,ended_at FROM sessions WHERE id=?;",
"SELECT started_at,session_type FROM sessions WHERE id=?;",
(session_row_id,),
).fetchone()
resumable_max = (
header is not None
and tuple(header[:2]) == (session["started_at"], "max_test")
and tuple(header) == (session["started_at"], "max_test")
and session["session_type"] == "max_test"
and len(incoming) >= len(current)
and all(current[index] == incoming[index]
@ -1599,9 +1504,9 @@ def import_sessions(
)
incoming_by_id = dict(incoming)
correction = (
payload["version"] >= 3
payload["version"] == 3
and header is not None
and tuple(header[:2]) == (session["started_at"], session["session_type"])
and tuple(header) == (session["started_at"], session["session_type"])
and len(incoming_by_id) == len(incoming)
and all(entry_id not in dict(current)
or dict(current)[entry_id] == exercise_id
@ -1619,8 +1524,7 @@ def import_sessions(
session_row_id,
session,
exercise_mapping,
) and (payload["version"] != 4 or v4_session_metadata_matches(
connection, session_row_id, session)):
):
report["sessions_skipped"] += 1
continue
if not legacy and not (resumable_max or correction):
@ -1649,15 +1553,6 @@ def import_sessions(
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 9:
connection.execute("DELETE FROM max_results WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
connection.execute("DELETE FROM session_exercises WHERE session_row_id=?;", (session_row_id,))
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 20:
existing_causal = connection.execute(
"SELECT deleted FROM sync_causal_state WHERE target_kind='session' AND target_id=?",
(session["session_id"],),).fetchone()
if existing_causal is not None and existing_causal[0]:
raise ImportFailure("session protégée causalement: " + session["session_id"])
connection.execute(
"INSERT OR REPLACE INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted,operation_id) VALUES('session',?,?,0,NULL)",
(session["session_id"], "mu_" + str(uuid.uuid4())),)
report["sessions_reconciled"] += 1
else:
cursor = connection.execute(
@ -1668,30 +1563,17 @@ def import_sessions(
ended_at,
session_type,
notes
) VALUES(?, ?, ?, ?, NULL);
) VALUES(?, ?, NULL, ?, NULL);
""",
(
session["session_id"],
session["started_at"],
session.get("ended_at"),
session["session_type"],
),
)
session_row_id = cursor.lastrowid
if payload["version"] == 4:
current_end = connection.execute(
"SELECT ended_at FROM sessions WHERE id=?", (session_row_id,)).fetchone()[0]
incoming_end = session["ended_at"]
if current_end is None and incoming_end is not None:
connection.execute("UPDATE sessions SET ended_at=? WHERE id=?",
(incoming_end, session_row_id))
elif current_end is not None and incoming_end is not None and current_end != incoming_end:
raise ImportFailure("conflit ended_at pour " + session["session_id"])
apply_note(connection, "session", session["session_id"], session["note"],
"sessions", "id=?", (session_row_id,))
for position, item in enumerate(
session["exercises"]
):
@ -1734,7 +1616,7 @@ def import_sessions(
)
if "max_weight_kg" in item:
occurrence_row_id = import_max_session_exercise(
import_max_session_exercise(
connection,
session_row_id,
position,
@ -1742,7 +1624,7 @@ def import_sessions(
row_id,
)
elif item["recording_mode"] == "continuous":
occurrence_row_id = import_continuous_session_exercise(
import_continuous_session_exercise(
connection,
session_row_id,
position,
@ -1750,16 +1632,13 @@ def import_sessions(
row_id,
)
else:
occurrence_row_id = import_set_session_exercise(
import_set_session_exercise(
connection,
session_row_id,
position,
item,
row_id,
)
if payload["version"] == 4:
apply_note(connection, "occurrence", item["entry_id"], item["note"],
"session_exercises", "id=?", (occurrence_row_id,))
retained_ids = {item["entry_id"] for item in session["exercises"]}
for entry_id, feedback_id, observed_at, raw_text in retained_feedback:
@ -1905,29 +1784,13 @@ def import_body(
for observation in payload[
"body_observations"
]:
parent_row_id = None
if payload["version"] == 4 and observation["session_id"] is not None:
parent = connection.execute(
"SELECT id FROM sessions WHERE session_id=?", (observation["session_id"],)).fetchone()
if parent is None:
raise ImportFailure("parent de l'observation introuvable: " + observation["session_id"])
parent_row_id = parent[0]
existing = connection.execute(
f"SELECT id,observed_at,{','.join(metric_order)},session_row_id FROM body_observations WHERE observation_id=?",
f"SELECT observed_at,{','.join(metric_order)} FROM body_observations WHERE observation_id=?",
(observation["observation_id"],)).fetchone()
if existing is not None:
expected = [observation["observed_at"]] + [observation.get(metric) for metric in metric_order]
if list(existing[1:1 + len(expected)]) != expected:
if list(existing) != expected:
raise ImportFailure("conflit observation corporelle: " + observation["observation_id"])
if payload["version"] == 4:
current_parent = existing[-1]
if current_parent is None and parent_row_id is not None:
connection.execute("UPDATE body_observations SET session_row_id=? WHERE id=?",
(parent_row_id, existing[0]))
elif current_parent is not None and parent_row_id is not None and current_parent != parent_row_id:
raise ImportFailure("conflit parent observation: " + observation["observation_id"])
apply_note(connection, "observation", observation["observation_id"], observation["note"],
"body_observations", "id=?", (existing[0],))
report["body_skipped"] += 1
continue
@ -1946,22 +1809,26 @@ def import_body(
values,
)
if payload["version"] == 4:
row_id = connection.execute(
"SELECT id FROM body_observations WHERE observation_id=?",
(observation["observation_id"],)).fetchone()[0]
if parent_row_id is not None:
connection.execute("UPDATE body_observations SET session_row_id=? WHERE id=?",
(parent_row_id, row_id))
apply_note(connection, "observation", observation["observation_id"], observation["note"],
"body_observations", "id=?", (row_id,))
report["body_imported"] += 1
def apply_payload(connection, payload, trace_exercises=False):
"""Apply a validated artifact without owning commit or rollback."""
def run_import(
payload,
database_path,
dry_run,
trace_exercises=False,
):
if not database_path.exists():
raise ImportFailure(
f"base desktop introuvable: {database_path}"
)
connection = connect_database(
database_path
)
connection.row_factory = sqlite3.Row
report = {
"exercises_imported": 0,
"exercises_reconciled": 0,
@ -1973,43 +1840,58 @@ def apply_payload(connection, payload, trace_exercises=False):
"body_skipped": 0,
}
connection.execute("PRAGMA foreign_keys = ON;")
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21):
raise ImportFailure("mobile V4 exige le schéma desktop v19, v20 ou v21")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
incoming = ({("session", value["session_id"]) for value in payload["sessions"]} |
{("body_observation", value["observation_id"]) for value in payload["body_observations"]} |
{("exercise", value["exercise_id"]) for value in payload["exercises"]})
collision = protected & incoming
if collision:
kind, identity = sorted(collision)[0]
raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}")
has_explicit_max = any("max_weight_kg" in entry for session in payload["sessions"] for entry in session["exercises"])
if has_explicit_max and schema_version < 9:
raise ImportFailure("max_weight_kg exige le schéma desktop v9")
has_explicit_zero_set_weight = any(set_item.get("weight_kg") == 0 for session in payload["sessions"] for entry in session["exercises"] for set_item in entry.get("sets", []) if "weight_kg" in set_item)
if has_explicit_zero_set_weight and schema_version < 10:
raise ImportFailure("weight_kg=0 exige le schéma desktop v10; import annulé")
mapping = import_exercises(connection, payload, report, trace_exercises)
import_sessions(connection, payload, mapping, report)
import_body(connection, payload, report)
return report
def run_import(payload, database_path, dry_run, trace_exercises=False):
if not database_path.exists():
raise ImportFailure(f"base desktop introuvable: {database_path}")
connection = connect_database(database_path)
try:
connection.execute("BEGIN IMMEDIATE;")
report = apply_payload(connection, payload, trace_exercises)
connection.execute(
"PRAGMA foreign_keys = ON;"
)
require_supported_schema(
connection
)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
has_explicit_max = any(
"max_weight_kg" in entry
for session in payload["sessions"]
for entry in session["exercises"]
)
if has_explicit_max and schema_version < 9:
raise ImportFailure("max_weight_kg exige le schéma desktop v9")
has_explicit_zero_set_weight = any(
set_item.get("weight_kg") == 0
for session in payload["sessions"]
for entry in session["exercises"]
for set_item in entry.get("sets", [])
if "weight_kg" in set_item
)
if has_explicit_zero_set_weight and schema_version < 10:
raise ImportFailure(
"weight_kg=0 exige le schéma desktop v10; import annulé"
)
connection.execute(
"BEGIN IMMEDIATE;"
)
mapping = import_exercises(
connection,
payload,
report,
trace_exercises,
)
import_sessions(
connection,
payload,
mapping,
report,
)
import_body(
connection,
payload,
report,
)
if dry_run:
connection.rollback()

View file

@ -42,20 +42,18 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
for revision in revisions:
old=db.execute(f"SELECT {parent_column},created_at,raw_text FROM {table} WHERE revision_id=?",(revision[0],)).fetchone(); expected=(parent_id,revision[1],revision[2])
if old is None:db.execute(f"INSERT INTO {table}(revision_id,{parent_column},created_at,raw_text) VALUES(?,?,?,?)",(revision[0],*expected));added+=1
# INVARIANT: generation workers reuse the history importer connection,
# which installs sqlite3.Row. Idempotence compares persisted values,
# never the process-local row representation selected by a prior step.
elif tuple(old)==expected:skipped+=1
elif old==expected:skipped+=1
else:fail("HARD CONFLICT "+revision[0])
current=max(db.execute(f"SELECT revision_id,created_at,raw_text FROM {table} WHERE {parent_column}=?",(parent_id,)).fetchall(),key=lambda r:(instant(r[1]),r[0].encode()))
return added,skipped,current[2]
def apply_feedback(db,root,complete_causal_envelope=False):
def main():
parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=connect_database(args.database)
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21):fail("schema desktop v15-v21 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):
try:
db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18):fail("schema desktop v15-v18 requis")
db.execute("BEGIN IMMEDIATE");seen=set()
for index,item in enumerate(root["exercise_feedback"]):
keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"}
if not isinstance(item,dict) or set(item)!=keys:fail(f"exercise_feedback[{index}] invalide")
fid=item["feedback_id"]
@ -66,11 +64,11 @@ def apply_feedback(db,root,complete_causal_envelope=False):
if not canonical or canonical[0]!=occurrence[1]:fail("exercise_id incompatible avec l'occurrence")
old=db.execute("SELECT session_exercise_row_id,observed_at FROM exercise_feedback WHERE feedback_id=?",(fid,)).fetchone();expected=(occurrence[0],item["observed_at"]);revisions=revision_list(item,fid,root["version"])
if old is None:db.execute("INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) VALUES(?,?,?,?)",(fid,*expected,revisions[0][2]));roots_added+=1
elif tuple(old)==expected:roots_skipped+=1
elif old==expected:roots_skipped+=1
else:fail("HARD CONFLICT "+fid)
added,skipped,current=merge_revisions(db,"exercise_feedback_revisions","feedback_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE exercise_feedback SET raw_text=? WHERE feedback_id=?",(current,fid))
seen=set()
for index,item in enumerate(root["session_followups"]):
seen=set()
for index,item in enumerate(root["session_followups"]):
keys={"followup_id","session_id","observed_at","raw_text"} if root["version"]==1 else {"followup_id","session_id","observed_at","revisions"}
if not isinstance(item,dict) or set(item)!=keys:fail(f"session_followups[{index}] invalide")
fid=item["followup_id"]
@ -79,16 +77,9 @@ def apply_feedback(db,root,complete_causal_envelope=False):
if not session:fail("session introuvable")
old=db.execute("SELECT session_row_id,observed_at FROM session_followups WHERE followup_id=?",(fid,)).fetchone();expected=(session[0],item["observed_at"]);revisions=revision_list(item,fid,root["version"])
if old is None:db.execute("INSERT INTO session_followups(followup_id,session_row_id,observed_at,raw_text) VALUES(?,?,?,?)",(fid,*expected,revisions[0][2]));roots_added+=1
elif tuple(old)==expected:roots_skipped+=1
elif old==expected:roots_skipped+=1
else:fail("HARD CONFLICT "+fid)
added,skipped,current=merge_revisions(db,"session_followup_revisions","followup_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE session_followups SET raw_text=? WHERE followup_id=?",(current,fid))
return roots_added,roots_skipped,revisions_added,revisions_skipped
def main():
parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=connect_database(args.database)
try:
db.execute("BEGIN IMMEDIATE")
roots_added,roots_skipped,revisions_added,revisions_skipped=apply_feedback(db,root)
db.commit()
except Exception:db.rollback();raise
finally:db.close()

View file

@ -1,147 +0,0 @@
#!/usr/bin/env python3
"""Stage a reproducible, non-secret Trainlog desktop synchronization candidate."""
import argparse
import hashlib
import json
import os
import shutil
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNTIME_TOOLS = [
"trainlog_syncd.py",
"sync_orchestrator.py",
"sync_peer_worker.py",
"sync_generation_exchange.py",
"trainlog_sqlite.py",
"validate_json.py",
"validate_training_knowledge.py",
"exercise_names.py",
"import_mobile_export.py",
"import_exercise_aliases.py",
"import_equipment_definitions.py",
"import_equipment_associations.py",
"import_exercise_body_zones.py",
"import_training_feedback.py",
"execution_draft_exchange.py",
"causal_delete_exchange.py",
"import_exercise_profile_state.py",
"export_exercise_profile_state.py",
"export_pc_catalog.py",
"export_pc_mobile.py",
"export_exercise_aliases.py",
"export_equipment_definitions.py",
"export_equipment_associations.py",
"export_exercise_body_zones.py",
"export_training_feedback.py",
"export_ai_session_drafts.py",
]
def digest(path: Path) -> str:
value = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--build", default=ROOT / "build", type=Path)
args = parser.parse_args()
if args.output.exists():
raise RuntimeError("candidate output already exists")
binary = args.build / "tui/trainlog"
sync_once = args.build / "tui/trainlog-sync-once"
mtp_adapter = args.build / "tui/trainlog-generation-mtp-adapter"
if not binary.is_file():
raise RuntimeError("compiled trainlog executable is missing")
if not mtp_adapter.is_file():
raise RuntimeError("compiled generation MTP adapter is missing")
if not sync_once.is_file():
raise RuntimeError("compiled legacy synchronization helper is missing")
(args.output / "bin").mkdir(parents=True)
(args.output / "libexec").mkdir(parents=True)
(args.output / "tools").mkdir(parents=True)
(args.output / "catalog").mkdir(parents=True)
shutil.copy2(binary, args.output / "libexec/trainlog")
shutil.copy2(sync_once, args.output / "libexec/trainlog-sync-once")
shutil.copy2(mtp_adapter, args.output / "libexec/trainlog-generation-mtp-adapter")
for name in RUNTIME_TOOLS:
shutil.copy2(ROOT / "tools" / name, args.output / "tools" / name)
for source in sorted((ROOT / "catalog").glob("*.json")):
shutil.copy2(source, args.output / "catalog" / source.name)
launcher = args.output / "bin/trainlog"
launcher.write_text(
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR="$root/tools"\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec "$root/libexec/trainlog" "$@"\n'
)
sync_once_launcher = args.output / "bin/trainlog-sync-once"
sync_once_launcher.write_text(
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexec "$root/libexec/trainlog-sync-once" "$@"\n'
)
syncd_launcher = args.output / "bin/trainlog-syncd"
syncd_launcher.write_text(
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n'
)
files = sorted(path for path in args.output.rglob("*") if path.is_file())
inventory = {
"format": "trainlog-sync-candidate-inventory",
"version": 1,
"source_commit": subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.2",
"desktop_schema": 21,
"android_schema": 20,
"protocols": [
"mobile-export-v3",
"mobile-history-v4",
"causal-delete-v1",
"execution-draft-v1",
"generation-manifest-v1",
"generation-ack-v1",
],
"entry_points": [
"bin/trainlog",
"bin/trainlog-sync-once",
"bin/trainlog-syncd",
"libexec/trainlog-generation-mtp-adapter",
"tools/sync_orchestrator.py",
],
"runtime_dependencies": [
"python>=3.11",
"sqlite3",
"utf8proc",
"libuuid",
"libudev",
"libmtp",
"notcurses",
"coreutils-readlink",
],
"files": [
{
"path": str(path.relative_to(args.output)),
"sha256": digest(path),
"size": path.stat().st_size,
}
for path in files
],
}
(args.output / "candidate-inventory.json").write_text(
json.dumps(inventory, indent=2, sort_keys=True) + "\n"
)
os.chmod(args.output / "bin/trainlog", 0o755)
os.chmod(args.output / "bin/trainlog-sync-once", 0o755)
os.chmod(args.output / "bin/trainlog-syncd", 0o755)
os.chmod(args.output / "libexec/trainlog", 0o755)
os.chmod(args.output / "libexec/trainlog-sync-once", 0o755)
os.chmod(args.output / "libexec/trainlog-generation-mtp-adapter", 0o755)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,589 +0,0 @@
#!/usr/bin/env python3
"""Staged coherent-generation, manifest, consumption and ACK service.
The active V3 transport does not call this module. Filesystem publication is a
bounded object-store adapter used by explicit staged callers and tests.
"""
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import uuid
from contextlib import closing
from pathlib import Path, PurePosixPath
from trainlog_sqlite import connect_database
import causal_delete_exchange
import execution_draft_exchange
import import_equipment_associations
import import_equipment_definitions
import import_exercise_aliases
import import_exercise_body_zones
import import_exercise_profile_state
import import_mobile_export
import import_training_feedback
import export_equipment_definitions
import export_exercise_body_zones
import export_pc_mobile
import export_training_feedback
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = "trainlog-sync-manifest"
ACK = "trainlog-sync-ack"
MAX_MANIFEST = 64 * 1024
MAX_ARTIFACTS = 32
MAX_ARTIFACT = 64 * 1024 * 1024
MAX_GENERATION = 256 * 1024 * 1024
MAX_NAME = 64
MAX_PATH = 240
MAX_DIAGNOSTIC = 1024
MAX_RETAINED_PER_PEER = 8
ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
HEX = re.compile(r"^[0-9a-f]{64}$")
ARTIFACTS = (
("catalog", "trainlog-pc-catalog", 1, "catalog-v1.json", True,
"export_pc_catalog.py", ()),
("history", "trainlog-mobile-export", 4, "history-v4.json", True,
"export_pc_mobile.py", ("--version", "4")),
("execution-drafts", "trainlog-execution-drafts", 1, "execution-drafts-v1.json", True,
"execution_draft_exchange.py", ("export",)),
("exercise-aliases", "trainlog-exercise-aliases", 1, "exercise-aliases-v1.json", True,
"export_exercise_aliases.py", ()),
("exercise-profile-state", "trainlog-exercise-profile-state", 1, "exercise-profile-state-v1.json", True,
"export_exercise_profile_state.py", ()),
("equipment-definitions", "trainlog-equipment-definitions", 1, "equipment-definitions-v1.json", True,
"export_equipment_definitions.py", ()),
("equipment-associations", "trainlog-equipment-associations", 2, "equipment-associations-v2.json", True,
"export_equipment_associations.py", ()),
("body-zones", "trainlog-exercise-body-zones", 1, "body-zones-v1.json", True,
"export_exercise_body_zones.py", ()),
("feedback", "trainlog-training-feedback", 2, "feedback-v2.json", True,
"export_training_feedback.py", ()),
("causal-deletions", "trainlog-causal-deletions", 1, "causal-deletions-v1.json", True,
"causal_delete_exchange.py", ("export",)),
("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False,
"export_ai_session_drafts.py", ()),
)
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS}
class GenerationError(RuntimeError):
pass
def now() -> str:
return dt.datetime.now().astimezone().isoformat()
def canonical(value) -> bytes:
return json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":")).encode("utf-8")
def digest_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def digest_file(path: Path, declared: int | None = None) -> tuple[int, str]:
total = 0
checksum = hashlib.sha256()
with path.open("rb") as stream:
while True:
block = stream.read(1024 * 1024)
if not block:
break
total += len(block)
if total > MAX_ARTIFACT or declared is not None and total > declared:
raise GenerationError("artifact exceeds declared or 64 MiB bound")
checksum.update(block)
return total, checksum.hexdigest()
def strict_json(raw: bytes, maximum: int):
if len(raw) > maximum:
raise GenerationError("JSON exceeds byte bound")
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise GenerationError("duplicate JSON key: " + key)
result[key] = value
return result
try:
root = json.loads(raw.decode("utf-8"), object_pairs_hook=unique)
stack = [(root, 1)]; nodes = 0
while stack:
value, depth = stack.pop(); nodes += 1
if depth > 8 or nodes > 8192:
raise GenerationError("JSON nesting/node bound exceeded")
if isinstance(value, dict): stack.extend((child, depth + 1) for child in value.values())
elif isinstance(value, list): stack.extend((child, depth + 1) for child in value)
return root
except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as error:
raise GenerationError("invalid UTF-8/JSON") from error
def valid_id(value: object, prefix: str) -> bool:
return isinstance(value, str) and value.startswith(prefix + "_") and ID.fullmatch(value) is not None
def validate_relative(value: object, generation_id: str) -> str:
if not isinstance(value, str) or not value or len(value.encode()) > MAX_PATH or "\\" in value or "\x00" in value:
raise GenerationError("invalid artifact path")
path = PurePosixPath(value)
parts = path.parts
if path.is_absolute() or len(parts) > 3 or any(part in ("", ".", "..") for part in parts):
raise GenerationError("unsafe artifact path")
if len(parts) != 3 or parts[:2] != ("generations", generation_id):
raise GenerationError("artifact outside generation namespace")
return value
def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
root = strict_json(raw, MAX_MANIFEST)
keys = {"format", "version", "generation_id", "run_id", "producer",
"consumer_peer_id", "generated_at", "parent_generation_id", "artifacts"}
if not isinstance(root, dict) or set(root) != keys or root["format"] != MANIFEST or type(root["version"]) is not int or root["version"] != 1:
raise GenerationError("unsupported manifest shape/version")
generation = root["generation_id"]
if not valid_id(generation, "gen") or not valid_id(root["run_id"], "sy"):
raise GenerationError("invalid generation/run identity")
producer = root["producer"]
if not isinstance(producer, dict) or set(producer) != {"peer_id", "kind"} or not valid_id(producer["peer_id"], "peer") or producer["kind"] not in ("android", "desktop"):
raise GenerationError("invalid producer")
if not valid_id(root["consumer_peer_id"], "peer") or expected_consumer and root["consumer_peer_id"] != expected_consumer:
raise GenerationError("wrong consumer")
if root["consumer_peer_id"] == producer["peer_id"]:
raise GenerationError("producer and consumer must differ")
if root["parent_generation_id"] is not None and (not valid_id(root["parent_generation_id"], "gen") or root["parent_generation_id"] == generation):
raise GenerationError("invalid parent generation")
try:
parsed = dt.datetime.fromisoformat(root["generated_at"].replace("Z", "+00:00"))
if parsed.utcoffset() is None:
raise ValueError
except Exception as error:
raise GenerationError("invalid generated_at") from error
values = root["artifacts"]
if not isinstance(values, list) or not 1 <= len(values) <= MAX_ARTIFACTS:
raise GenerationError("artifact descriptor bound")
names, paths, total = set(), set(), 0
for item in values:
if not isinstance(item, dict) or set(item) != {"logical_name", "format", "version", "filename", "size", "sha256", "required"}:
raise GenerationError("invalid artifact descriptor")
name = item["logical_name"]
if not isinstance(name, str) or not name.isascii() or not 0 < len(name.encode()) <= MAX_NAME or name in names:
raise GenerationError("invalid/duplicate logical name")
names.add(name)
filename = validate_relative(item["filename"], generation)
if filename in paths:
raise GenerationError("duplicate artifact path")
paths.add(filename)
if type(item["version"]) is not int or item["version"] < 1 or type(item["size"]) is not int or not 0 <= item["size"] <= MAX_ARTIFACT or type(item["required"]) is not bool or not isinstance(item["format"], str) or not HEX.fullmatch(item["sha256"] or ""):
raise GenerationError("invalid artifact metadata")
if item["required"] and (item["format"], item["version"]) not in SUPPORTED:
raise GenerationError("unsupported required capability")
total += item["size"]
if total > MAX_GENERATION:
raise GenerationError("generation exceeds 256 MiB")
required = {row[0] for row in ARTIFACTS if row[4]}
if not required.issubset(names):
raise GenerationError("missing required domain")
return root
def require_schema(db: sqlite3.Connection) -> None:
if db.execute("PRAGMA user_version").fetchone()[0] != 21:
raise GenerationError("desktop schema v21 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
require_schema(db)
row = db.execute("SELECT peer_id,kind FROM sync_peer_identity WHERE singleton=1").fetchone()
if row:
if row[1] != kind:
raise GenerationError("peer kind conflict")
return row[0]
identity = "peer_" + str(uuid.uuid4())
db.execute("INSERT INTO sync_peer_identity VALUES(1,?,?)", (identity, kind))
return identity
def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None:
command = [sys.executable, str(ROOT / "tools" / tool)]
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"):
command.extend(extra); command.append(str(output))
else:
command.append(str(output)); command.extend(extra)
command.extend(("--database", str(snapshot)))
generation_exporters = {
"export_pc_mobile.py": export_pc_mobile,
"export_equipment_definitions.py": export_equipment_definitions,
"export_exercise_body_zones.py": export_exercise_body_zones,
"export_training_feedback.py": export_training_feedback,
}
if tool in generation_exporters:
previous = sys.argv
try:
sys.argv = command[1:]
generation_exporters[tool].main(complete_causal_envelope=True)
return
finally:
sys.argv = previous
result = subprocess.run(command, text=True, capture_output=True, check=False)
if result.returncode != 0:
raise GenerationError(f"export {tool} failed: {result.stdout}{result.stderr}")
def capture_desktop(database: Path, owned_root: Path, consumer: str,
run_id: str | None = None, generation_id: str | None = None):
if not valid_id(consumer, "peer"):
raise GenerationError("invalid expected consumer")
generation = generation_id or "gen_" + str(uuid.uuid4())
run = run_id or "sy_" + str(uuid.uuid4())
if not valid_id(generation, "gen") or not valid_id(run, "sy"):
raise GenerationError("invalid generation/run")
with closing(connect_database(database)) as admission:
require_schema(admission)
retained = admission.execute("SELECT COUNT(*) FROM sync_generations WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged')", (consumer,)).fetchone()[0]
if retained >= MAX_RETAINED_PER_PEER:
raise GenerationError("generation retention capacity exhausted")
stage = owned_root / "staging" / generation
if stage.exists():
raise GenerationError("generation staging already exists")
stage.mkdir(parents=True, mode=0o700)
snapshot = stage / ".snapshot.sqlite"
try:
with closing(connect_database(database)) as source, closing(sqlite3.connect(snapshot)) as target:
require_schema(source)
producer = peer_identity(source, "desktop")
source.commit()
source.backup(target)
descriptors = []
total = 0
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
output = stage / filename
run_export(tool, extra, output, snapshot)
size, checksum = digest_file(output)
total += size
if total > MAX_GENERATION:
raise GenerationError("generation exceeds 256 MiB")
descriptors.append({"logical_name": logical, "format": fmt,
"version": version, "filename": f"generations/{generation}/{filename}",
"size": size, "sha256": checksum, "required": required})
parent = None
with closing(connect_database(database)) as db:
parent_rows = db.execute("SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2", (producer, consumer)).fetchall()
if len(parent_rows) > 1: raise GenerationError("outgoing generation lineage has multiple tips")
parent = parent_rows[0][0] if parent_rows else None
manifest = {"format": MANIFEST, "version": 1, "generation_id": generation,
"run_id": run, "producer": {"peer_id": producer, "kind": "desktop"},
"consumer_peer_id": consumer, "generated_at": now(),
"parent_generation_id": parent, "artifacts": descriptors}
raw = canonical(manifest)
validate_manifest_bytes(raw, consumer)
checksum = digest_bytes(raw)
(stage / "manifest.json").write_bytes(raw)
os.chmod(stage / "manifest.json", 0o600)
snapshot.unlink()
with closing(connect_database(database)) as db:
db.execute("BEGIN IMMEDIATE")
peer_identity(db, "desktop")
db.execute("INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,NULL)",
(generation, run, producer, consumer, "desktop", manifest["generated_at"],
parent, checksum, "captured", raw.decode(), str(stage)))
db.executemany("INSERT INTO sync_generation_artifacts VALUES(?,?,?,?,?,?,?,?)",
[(generation, value["logical_name"], value["format"], value["version"],
value["filename"], value["size"], value["sha256"], int(value["required"]))
for value in descriptors])
causal_path = stage / "causal-deletions-v1.json"
if causal_path.is_file():
for operation in json.loads(causal_path.read_text())["operations"]:
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
(operation["operation_id"], generation, 0 if existed else 1))
db.commit()
return stage, manifest, checksum
except Exception:
if stage.exists():
shutil.rmtree(stage)
raise
def publish(database: Path, generation: str, object_root: Path) -> Path:
with closing(connect_database(database)) as db:
require_schema(db)
row = db.execute("SELECT manifest_json,manifest_sha256,staging_path,status FROM sync_generations WHERE generation_id=?", (generation,)).fetchone()
if not row:
raise GenerationError("unknown generation")
if row[3] not in ("captured", "published", "waiting_acknowledgement"):
raise GenerationError("generation is not publishable")
manifest = validate_manifest_bytes(row[0].encode())
destination = object_root / "generations" / generation
source = Path(row[2])
marker = destination / "manifest.json"
if marker.is_file():
existing = marker.read_bytes()
if digest_bytes(existing) != row[1]: raise GenerationError("same generation has different published bytes")
validate_published(destination, manifest["consumer_peer_id"])
else:
if destination.exists() and (not destination.is_dir() or destination.is_symlink()):
raise GenerationError("unsafe generation namespace")
destination.mkdir(parents=True, mode=0o700)
for item in manifest["artifacts"]:
source_file = source / PurePosixPath(item["filename"]).name
target = destination / source_file.name
if not source_file.is_file() or source_file.is_symlink() or source_file.resolve().parent != source.resolve():
raise GenerationError("unsafe staging substitution")
size, checksum = digest_file(source_file, item["size"])
if size != item["size"] or checksum != item["sha256"]:
raise GenerationError("staging artifact changed after capture")
if target.exists():
if target.is_symlink() or digest_file(target, item["size"]) != (item["size"], item["sha256"]):
raise GenerationError("conflicting partial publication")
else:
with source_file.open("rb") as incoming, target.open("xb") as outgoing:
shutil.copyfileobj(incoming, outgoing, 1024 * 1024)
os.chmod(target, 0o600)
# Commit marker is deliberately last.
with marker.open("xb") as stream:
stream.write(row[0].encode())
os.chmod(marker, 0o600)
db.execute("UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=?", (generation,))
db.commit()
return destination
def validate_published(directory: Path, consumer: str):
marker = directory / "manifest.json"
if not marker.is_file() or marker.is_symlink():
raise GenerationError("complete manifest marker missing")
raw = marker.read_bytes() if marker.stat().st_size <= MAX_MANIFEST else b"x" * (MAX_MANIFEST + 1)
manifest = validate_manifest_bytes(raw, consumer)
total = 0
for item in manifest["artifacts"]:
path = directory / PurePosixPath(item["filename"]).name
if not path.is_file() or path.is_symlink() or path.resolve().parent != directory.resolve():
raise GenerationError("missing or unsafe listed artifact")
size, checksum = digest_file(path, item["size"])
if size != item["size"] or checksum != item["sha256"]:
raise GenerationError("artifact size/digest mismatch")
total += size
if total > MAX_GENERATION:
raise GenerationError("generation exceeds bound")
return manifest, digest_bytes(raw)
def ack_document(run: str, generation: str, producer: str, consumer: str,
manifest_digest: str, result: str, consumed_at: str,
diagnostic: str = ""):
if len(diagnostic.encode()) > MAX_DIAGNOSTIC:
raise GenerationError("diagnostic exceeds bound")
value = {"format": ACK, "version": 1, "ack_id": "ack_" + generation[4:],
"run_id": run, "generation_id": generation, "producer_peer_id": producer,
"consumer_peer_id": consumer, "manifest_sha256": manifest_digest,
"result": result, "durability": "sqlite-commit-full" if result == "consumed" else "sqlite-commit-rejection", "consumed_at": consumed_at,
"diagnostic": diagnostic}
value["payload_sha256"] = digest_bytes(canonical(value))
return value
def record_consumed(db: sqlite3.Connection, manifest: dict, manifest_digest: str,
diagnostic: str = "") -> dict:
"""Record consumption inside the caller-owned business transaction."""
require_schema(db)
generation = manifest["generation_id"]
known = db.execute("SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?", (generation,)).fetchone()
if known:
if known[0] != manifest_digest:
raise GenerationError("generation identity reused with different manifest")
return json.loads(known[1])
consumer = peer_identity(db, "desktop")
if consumer != manifest["consumer_peer_id"]:
raise GenerationError("wrong local consumer")
consumed_at = now()
ack = ack_document(manifest["run_id"], generation, manifest["producer"]["peer_id"],
consumer, manifest_digest, "consumed", consumed_at, diagnostic)
db.execute("INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(generation, manifest["run_id"], manifest["producer"]["peer_id"], consumer,
manifest["parent_generation_id"], manifest_digest, consumed_at, "consumed", "sqlite-commit-full", diagnostic,
canonical(ack).decode()))
db.execute("INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(ack["ack_id"], generation, ack["run_id"], ack["producer_peer_id"], consumer,
manifest_digest, "consumed", ack["durability"], consumed_at, diagnostic,
ack["payload_sha256"]))
return ack
def record_rejected(database: Path, manifest: dict, manifest_digest: str, diagnostic: str) -> dict:
diagnostic = diagnostic.encode("utf-8")[:MAX_DIAGNOSTIC].decode("utf-8", "ignore")
with closing(connect_database(database)) as db:
require_schema(db); db.execute("BEGIN IMMEDIATE")
consumer = peer_identity(db, "desktop"); rejected_at = now()
ack = ack_document(manifest["run_id"], manifest["generation_id"], manifest["producer"]["peer_id"], consumer, manifest_digest, "rejected", rejected_at, diagnostic)
db.execute("INSERT OR IGNORE INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)", (manifest["generation_id"],manifest["run_id"],manifest["producer"]["peer_id"],consumer,manifest["parent_generation_id"],manifest_digest,rejected_at,"rejected",ack["durability"],diagnostic,canonical(ack).decode()))
db.execute("INSERT OR IGNORE INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)", (ack["ack_id"],manifest["generation_id"],manifest["run_id"],manifest["producer"]["peer_id"],consumer,manifest_digest,"rejected",ack["durability"],rejected_at,diagnostic,ack["payload_sha256"]))
db.commit(); return ack
def consume_desktop(database: Path, directory: Path) -> dict:
"""Consume one complete Android generation in one desktop transaction."""
with closing(connect_database(database)) as identity_db:
consumer = peer_identity(identity_db, "desktop")
identity_db.commit()
manifest, manifest_digest = validate_published(directory, consumer)
listed = {item["logical_name"]: directory / PurePosixPath(item["filename"]).name
for item in manifest["artifacts"]}
def required(name):
try:
return listed[name]
except KeyError as error:
raise GenerationError("missing required domain: " + name) from error
# Parse and domain-validate bounded bytes before opening the write scope.
causal = causal_delete_exchange.load(required("causal-deletions"))
drafts = execution_draft_exchange.load(required("execution-drafts"))
aliases = import_exercise_aliases.load(required("exercise-aliases"))
profile = strict_json(required("exercise-profile-state").read_bytes(), MAX_ARTIFACT)
equipment_payload = strict_json(required("equipment-definitions").read_bytes(), MAX_ARTIFACT)
reserved = import_equipment_definitions.supplied_ids(ROOT / "catalog/equipment-v1.json")
definitions = import_equipment_definitions.validate(equipment_payload, reserved)
history = import_mobile_export.load_payload(required("history"))
known_equipment = set(reserved) | {item[0] for item in definitions}
import_mobile_export.validate_payload(history, known_equipment)
association_payload = strict_json(required("equipment-associations").read_bytes(), MAX_ARTIFACT)
zones_payload = strict_json(required("body-zones").read_bytes(), MAX_ARTIFACT)
parsed_zones = import_exercise_body_zones.parse_payload(zones_payload)
feedback = import_training_feedback.load(required("feedback"))
mobile_occurrences = import_equipment_associations.load_mobile_occurrences(required("history"))
with closing(connect_database(database)) as db:
require_schema(db)
db.execute("PRAGMA foreign_keys=ON")
db.execute("BEGIN IMMEDIATE")
try:
known = db.execute("SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?", (manifest["generation_id"],)).fetchone()
if known:
if known[0] != manifest_digest:
raise GenerationError("generation identity reused with different manifest")
db.rollback()
return json.loads(known[1])
predecessors = db.execute("SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2", (manifest["producer"]["peer_id"],)).fetchall()
if len(predecessors) > 1: raise GenerationError("consumed generation lineage has multiple tips")
expected_parent = predecessors[0][0] if predecessors else None
if manifest["parent_generation_id"] != expected_parent:
raise GenerationError("generation lineage is stale or unrelated")
# Causal state is applied first. Complete-envelope helpers retain
# item-level conflict rules while avoiding the legacy global guard.
for operation in causal["operations"]:
causal_delete_exchange.apply_operation(db, operation)
import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True)
import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=True)
import_mobile_export.apply_payload(db, history)
import_exercise_profile_state.apply_profile_state(db, profile)
import_exercise_aliases.apply_aliases(db, aliases)
import_equipment_associations.apply_associations(db, association_payload, reserved, mobile_occurrences)
import_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True)
import_training_feedback.apply_feedback(db, feedback, complete_causal_envelope=True)
execution_draft_exchange.import_document(db, drafts, own_transaction=False)
ack = record_consumed(db, manifest, manifest_digest)
db.commit()
return ack
except Exception as error:
db.rollback()
if isinstance(error, sqlite3.Error):
raise
return record_rejected(database, manifest, manifest_digest, str(error))
def validate_ack(raw: bytes, pending: sqlite3.Row | tuple) -> dict:
value = strict_json(raw, MAX_MANIFEST)
keys = {"format", "version", "ack_id", "run_id", "generation_id", "producer_peer_id",
"consumer_peer_id", "manifest_sha256", "result", "durability", "consumed_at",
"diagnostic", "payload_sha256"}
if not isinstance(value, dict) or set(value) != keys or value["format"] != ACK or type(value["version"]) is not int or value["version"] != 1:
raise GenerationError("invalid ACK")
if not valid_id(value["generation_id"], "gen") or value["ack_id"] != "ack_" + value["generation_id"][4:] or not valid_id(value["run_id"], "sy") or not valid_id(value["producer_peer_id"], "peer") or not valid_id(value["consumer_peer_id"], "peer") or not isinstance(value["manifest_sha256"], str) or not HEX.fullmatch(value["manifest_sha256"]) or not isinstance(value["diagnostic"], str):
raise GenerationError("invalid ACK identity/field")
try:
parsed = dt.datetime.fromisoformat(value["consumed_at"].replace("Z", "+00:00"))
if parsed.utcoffset() is None: raise ValueError
except Exception as error:
raise GenerationError("invalid ACK timestamp") from error
checksum = value["payload_sha256"]
payload = dict(value); del payload["payload_sha256"]
if not HEX.fullmatch(checksum or "") or digest_bytes(canonical(payload)) != checksum:
raise GenerationError("ACK digest mismatch")
expected = (pending[1], pending[0], pending[2], pending[3], pending[4])
actual = (value["run_id"], value["generation_id"], value["producer_peer_id"],
value["consumer_peer_id"], value["manifest_sha256"])
expected_durability = "sqlite-commit-full" if value["result"] == "consumed" else "sqlite-commit-rejection"
if actual != expected or value["result"] not in ("consumed", "rejected") or value["durability"] != expected_durability or len(value["diagnostic"].encode()) > MAX_DIAGNOSTIC:
raise GenerationError("ACK does not match pending generation")
return value
def accept_ack(database: Path, path: Path) -> str:
raw = path.read_bytes()
with closing(connect_database(database)) as db:
require_schema(db); db.execute("BEGIN IMMEDIATE")
value = strict_json(raw, MAX_MANIFEST)
row = db.execute("SELECT generation_id,run_id,producer_peer_id,consumer_peer_id,manifest_sha256,status FROM sync_generations WHERE generation_id=?", (value.get("generation_id"),)).fetchone()
if not row:
raise GenerationError("ACK has no pending generation")
ack = validate_ack(raw, row)
status = "acknowledged" if ack["result"] == "consumed" else "rejected"
if row[5] in ("acknowledged", "rejected"):
if row[5] != status:
raise GenerationError("conflicting ACK replay")
db.rollback(); return "unchanged"
if row[5] != "waiting_acknowledgement":
raise GenerationError("generation is not awaiting ACK")
db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",
(status, ack["consumed_at"], ack["generation_id"]))
db.commit(); return status
def main() -> None:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="mode", required=True)
capture = sub.add_parser("capture-desktop"); capture.add_argument("--database", type=Path, required=True); capture.add_argument("--owned-root", type=Path, required=True); capture.add_argument("--consumer-peer", required=True); capture.add_argument("--run-id"); capture.add_argument("--generation-id")
publication = sub.add_parser("publish"); publication.add_argument("--database", type=Path, required=True); publication.add_argument("--generation-id", required=True); publication.add_argument("--object-root", type=Path, required=True)
validate = sub.add_parser("validate"); validate.add_argument("directory", type=Path); validate.add_argument("--consumer-peer", required=True)
consume = sub.add_parser("consume-desktop"); consume.add_argument("directory", type=Path); consume.add_argument("--database", type=Path, required=True); consume.add_argument("--ack-output", type=Path, required=True)
peer = sub.add_parser("peer-id"); peer.add_argument("--database", type=Path, required=True); peer.add_argument("--kind", choices=("desktop",), default="desktop")
accept = sub.add_parser("accept-ack"); accept.add_argument("ack", type=Path); accept.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
if args.mode == "capture-desktop":
stage, manifest, checksum = capture_desktop(args.database, args.owned_root,
args.consumer_peer, args.run_id, args.generation_id)
print(f"GENERATION_CAPTURE=PASS generation_id={manifest['generation_id']} manifest_sha256={checksum} path={stage}")
elif args.mode == "publish":
print("GENERATION_PUBLISH=PASS path=" + str(publish(args.database, args.generation_id, args.object_root)))
elif args.mode == "validate":
manifest, checksum = validate_published(args.directory, args.consumer_peer)
print(f"GENERATION_VALIDATE=PASS generation_id={manifest['generation_id']} manifest_sha256={checksum}")
elif args.mode == "consume-desktop":
ack = consume_desktop(args.database, args.directory)
args.ack_output.write_bytes(canonical(ack))
print("GENERATION_CONSUME=PASS generation_id=" + ack["generation_id"])
elif args.mode == "peer-id":
with closing(connect_database(args.database)) as db:
value = peer_identity(db, args.kind); db.commit()
print("PEER_ID=" + value)
else:
print("ACK_ACCEPT=" + accept_ack(args.database, args.ack))
if __name__ == "__main__":
try:
main()
except Exception as error:
print("SYNC_GENERATION=FAIL " + str(error), file=sys.stderr)
raise SystemExit(1)

View file

@ -1,430 +0,0 @@
#!/usr/bin/env python3
"""Durable full-generation synchronization run coordinator.
The HTTP adapter starts this executable with fixed arguments. Peer transport is
selected only by a trusted local configuration file; request JSON can never
choose commands, paths, databases, capabilities, or causal policy.
"""
from __future__ import annotations
import argparse
import fcntl
import json
import os
import subprocess
import signal
import selectors
import re
import sys
import tempfile
import time
import ctypes
from datetime import datetime, timezone
from pathlib import Path
MAX_CONFIG = 16 * 1024
MAX_REPORT = 64 * 1024
MAX_DIAGNOSTIC = 1024
PHASES = {
"requested",
"waiting_android_publication",
"running",
"local_import_committed",
"published",
"waiting_acknowledgement",
"peer_consumed",
"completed",
"failed",
"interrupted",
"explicitly_degraded",
}
class OrchestratorInterrupted(RuntimeError):
pass
_owned_process_group: int | None = None
def _forward_termination(signum: int, _frame: object) -> None:
"""Forward shutdown only to the exactly-owned helper process group."""
if _owned_process_group is not None:
try:
os.killpg(_owned_process_group, signal.SIGTERM)
except ProcessLookupError:
pass
raise OrchestratorInterrupted(f"orchestrator received signal {signum}")
def _child_setup() -> None:
"""Create an owned group and ask Linux to terminate it if this parent dies."""
os.setsid()
libc = ctypes.CDLL(None)
if libc.prctl(1, signal.SIGTERM) != 0: # PR_SET_PDEATHSIG
raise OSError("cannot establish parent-death signal")
def canonical(value: object) -> bytes:
return (
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
).encode()
def atomic_write(path: Path, value: dict) -> None:
raw = canonical(value)
if len(raw) > MAX_REPORT:
raise RuntimeError("run report exceeds 64 KiB")
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, temporary = tempfile.mkstemp(prefix=".sync-run-", dir=path.parent)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "wb") as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def load_json(path: Path, limit: int) -> dict:
with path.open("rb") as stream:
raw = stream.read(limit + 1)
if len(raw) > limit:
raise RuntimeError(f"{path.name} exceeds its bound")
value = json.loads(raw.decode("utf-8", "strict"))
if not isinstance(value, dict):
raise RuntimeError(f"{path.name} must contain an object")
return value
def update(state_path: Path, state: dict, phase: str, **fields: object) -> None:
if phase not in PHASES:
raise RuntimeError("invalid run phase")
state.update(fields)
state["phase"] = phase
state["progress_revision"] = int(state.get("progress_revision", 0)) + 1
state["updated_at"] = datetime.now(timezone.utc).isoformat()
atomic_write(state_path, state)
def validate_result(value: dict, run_id: str) -> dict:
required = {
"format",
"version",
"run_id",
"producer_peer_id",
"consumer_peer_id",
"inbound_generation_id",
"outbound_generation_id",
"manifest_sha256",
"result",
"sessions_reconciled",
"domains",
"drafts",
"ai_midpoint",
"ai_post_sync",
}
if (
set(value) != required
or value.get("format") != "trainlog-sync-worker-report"
or value.get("version") != 1
):
raise RuntimeError("malformed peer worker report")
if value.get("run_id") != run_id or value.get("result") != "completed":
raise RuntimeError("peer worker did not complete the correlated run")
for identity, prefix in (
(value["producer_peer_id"], "peer_"),
(value["consumer_peer_id"], "peer_"),
(value["inbound_generation_id"], "gen_"),
(value["outbound_generation_id"], "gen_"),
):
if not isinstance(identity, str) or not re.fullmatch(
re.escape(prefix)
+ r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
identity,
):
raise RuntimeError("invalid worker identity")
if type(value["sessions_reconciled"]) is not int or value["sessions_reconciled"] < 0:
raise RuntimeError("invalid sessions_reconciled")
if not isinstance(value["domains"], dict) or not all(
isinstance(k, str) and isinstance(v, bool) for k, v in value["domains"].items()
):
raise RuntimeError("invalid domain coverage")
if not isinstance(value["drafts"], list) or len(value["drafts"]) > 32:
raise RuntimeError("invalid draft summary")
return value
def read_worker(
child: subprocess.Popen[bytes], state_path: Path, state: dict, run_id: str, timeout: int
) -> tuple[dict, bytes]:
selector = selectors.DefaultSelector()
assert child.stdout is not None and child.stderr is not None
selector.register(child.stdout, selectors.EVENT_READ, "stdout")
selector.register(child.stderr, selectors.EVENT_READ, "stderr")
buffers = {"stdout": bytearray(), "stderr": bytearray()}
line_buffer = bytearray()
final: dict | None = None
deadline = time.monotonic() + timeout
allowed = [
"waiting_android_publication",
"running",
"local_import_committed",
"published",
"waiting_acknowledgement",
"peer_consumed",
]
last_index = -1
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RuntimeError("peer worker timed out")
for key, _ in selector.select(min(remaining, 0.25)):
chunk = os.read(key.fileobj.fileno(), 4096)
if not chunk:
selector.unregister(key.fileobj)
continue
target = buffers[key.data]
if len(target) + len(chunk) > MAX_REPORT:
raise RuntimeError(f"peer worker {key.data} exceeds 64 KiB")
target.extend(chunk)
if key.data == "stdout":
line_buffer.extend(chunk)
while b"\n" in line_buffer:
raw, _, rest = line_buffer.partition(b"\n")
line_buffer[:] = rest
if len(raw) > 8192:
raise RuntimeError("progress frame exceeds 8 KiB")
value = json.loads(raw.decode("utf-8", "strict"))
if value.get("format") == "trainlog-sync-progress":
if (
set(value)
- {
"format",
"version",
"run_id",
"phase",
"producer_peer_id",
"consumer_peer_id",
"inbound_generation_id",
"outbound_generation_id",
"manifest_sha256",
}
or value.get("version") != 1
or value.get("run_id") != run_id
or value.get("phase") not in allowed
):
raise RuntimeError("invalid progress frame")
index = allowed.index(value["phase"])
if index < last_index:
raise RuntimeError("progress phase regressed")
last_index = index
update(
state_path,
state,
value["phase"],
**{
k: v
for k, v in value.items()
if k not in {"format", "version", "run_id", "phase"}
},
)
else:
final = value
child.wait()
if line_buffer.strip():
raise RuntimeError("unterminated worker frame")
if child.returncode != 0:
detail = bytes(buffers["stderr"]).decode("utf-8", "replace").strip()[:MAX_DIAGNOSTIC]
raise RuntimeError(f"peer worker exited {child.returncode}: {detail}")
if final is None:
raise RuntimeError("peer worker omitted final report")
return validate_result(final, run_id), bytes(buffers["stderr"])
def run(args: argparse.Namespace) -> int:
global _owned_process_group
state_path, config_path = Path(args.state), Path(args.config)
state = load_json(state_path, MAX_REPORT)
config = load_json(config_path, MAX_CONFIG)
if state.get("run_id") != args.run_id or state.get("request_id") != args.request_id:
raise RuntimeError("admission identity mismatch")
if set(config) != {
"format",
"version",
"enabled",
"mode",
"expected_peer_id",
"transport_root",
"owned_root",
"timeout_seconds",
}:
raise RuntimeError("invalid trusted sync configuration")
if (
config["format"] != "trainlog-sync-orchestrator-config"
or config["version"] != 1
or config["enabled"] is not True
):
raise RuntimeError("full-generation synchronization is disabled")
if config["mode"] not in ("directory", "mtp"):
raise RuntimeError("invalid transport mode")
for key in ("transport_root", "owned_root"):
if not isinstance(config[key], str) or not Path(config[key]).is_absolute():
raise RuntimeError("trusted paths must be absolute")
if not isinstance(config["expected_peer_id"], str) or not re.fullmatch(
r"peer_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
config["expected_peer_id"],
):
raise RuntimeError("invalid expected peer identity")
timeout = config["timeout_seconds"]
if not isinstance(timeout, int) or timeout < 1 or timeout > 900:
raise RuntimeError("invalid worker timeout")
# CONTRACT: this is the same XDG data-directory lock used by the legacy
# C engine, so Web, TUI and daemon admissions cannot overlap.
lock_path = Path(args.database).parent / "sync.lock"
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
try:
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
update(
state_path,
state,
"failed",
result="conflict",
diagnostic="Another synchronization owns the common lock",
finished_at=datetime.now(timezone.utc).isoformat(),
)
return 4
update(state_path, state, "running", result="running", diagnostic="")
environment = os.environ.copy()
environment.update(
{
"TRAINLOG_SYNC_RUN_ID": args.run_id,
"TRAINLOG_SYNC_TRIGGER": state["trigger"],
"TRAINLOG_SYNC_DATABASE": args.database,
}
)
command = [
sys.executable,
str(Path(__file__).with_name("sync_peer_worker.py")),
"--database",
args.database,
"--transport-root",
config["transport_root"],
"--owned-root",
config["owned_root"],
"--run-id",
args.run_id,
"--expected-peer",
config["expected_peer_id"],
"--timeout",
str(timeout),
"--mode",
config["mode"],
]
if config["mode"] == "mtp":
adapter = os.environ.get(
"TRAINLOG_SYNC_MTP_ADAPTER",
str(
Path(__file__).resolve().parents[1]
/ "build/tui/trainlog-generation-mtp-adapter"
),
)
if not Path(adapter).is_absolute() or not os.access(adapter, os.X_OK):
raise RuntimeError("fixed MTP adapter executable is unavailable")
command.extend(["--mtp-adapter", adapter])
child = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=environment,
preexec_fn=_child_setup,
)
_owned_process_group = child.pid
try:
result, _ = read_worker(child, state_path, state, args.run_id, timeout)
except Exception:
if child.poll() is None:
os.killpg(child.pid, signal.SIGTERM)
try:
child.wait(5)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
raise
finally:
_owned_process_group = None
update(
state_path,
state,
"completed",
result="completed",
diagnostic="",
inbound_generation_id=result["inbound_generation_id"],
outbound_generation_id=result["outbound_generation_id"],
producer_peer_id=result["producer_peer_id"],
consumer_peer_id=result["consumer_peer_id"],
sessions_reconciled=result["sessions_reconciled"],
domains=result["domains"],
drafts=result["drafts"],
manifest_sha256=result["manifest_sha256"],
ai_midpoint=result["ai_midpoint"],
ai_post_sync=result["ai_post_sync"],
finished_at=datetime.now(timezone.utc).isoformat(),
)
return 0
finally:
os.close(lock_fd)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--database", required=True)
parser.add_argument("--state", required=True)
parser.add_argument("--config", required=True)
parser.add_argument("--run-id", required=True)
parser.add_argument("--request-id", required=True)
args = parser.parse_args()
signal.signal(signal.SIGTERM, _forward_termination)
signal.signal(signal.SIGINT, _forward_termination)
try:
return run(args)
except (
OSError,
ValueError,
json.JSONDecodeError,
RuntimeError,
subprocess.TimeoutExpired,
) as error:
try:
path = Path(args.state)
state = load_json(path, MAX_REPORT)
phase = "interrupted" if isinstance(error, OrchestratorInterrupted) else "failed"
update(
path,
state,
phase,
result=phase,
diagnostic=str(error)[:MAX_DIAGNOSTIC],
finished_at=datetime.now(timezone.utc).isoformat(),
)
except Exception:
pass
return 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,303 +0,0 @@
#!/usr/bin/env python3
"""Production full-generation peer conversation over a transport directory.
The directory is the object-level seam implemented by the MTP adapter in a
deployed bundle and directly supplied by isolated tests. Business documents are
always produced and consumed by the existing generation services.
"""
from __future__ import annotations
import argparse, hashlib, json, os, sys, time, uuid
from contextlib import closing, redirect_stdout
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import sync_generation_exchange as generation
CAPS = {
"generation-manifest-v1",
"generation-ack-v1",
"causal-delete-v1",
"mobile-history-v4",
"execution-draft-v1",
}
def emit(run_id: str, phase: str, **values: object) -> None:
value = {
"format": "trainlog-sync-progress",
"version": 1,
"run_id": run_id,
"phase": phase,
} | values
print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True)
def bounded(path: Path, limit: int = 65536) -> bytes:
with path.open("rb") as stream:
raw = stream.read(limit + 1)
if len(raw) > limit:
raise RuntimeError(f"{path.name} exceeds bound")
return raw
def publish_json(path: Path, value: dict) -> None:
"""Publish a coordination object only after all bytes are durable."""
temporary = path.with_name("." + path.name + ".tmp-" + str(os.getpid()))
with temporary.open("wb") as stream:
stream.write(generation.canonical(value))
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
def run_adapter(
adapter: Path,
operation: str,
peer: str,
root: Path,
deadline: float,
allow_missing_peer: bool = False,
) -> bool:
remaining = max(1, int(deadline - time.monotonic()))
result = __import__("subprocess").run(
[str(adapter), operation, peer, str(root)],
stdin=__import__("subprocess").DEVNULL,
capture_output=True,
timeout=min(remaining, 30),
check=False,
)
if result.returncode != 0:
diagnostic = (
result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed"
).strip()
missing_peer = diagnostic in {
"double failed 6 expected Android peer not found",
"MTP adapter failed status=6 diagnostic=expected Android peer not found",
}
if allow_missing_peer and missing_peer:
return False
raise RuntimeError(diagnostic)
return True
def wait_file(path: Path, deadline: float, pump=None) -> None:
while not path.is_file():
if time.monotonic() >= deadline:
raise RuntimeError(f"timeout waiting for {path.name}")
if pump is not None:
pump()
time.sleep(0.05)
def wait_json(path: Path, deadline: float, predicate, pump=None) -> dict:
while True:
wait_file(path, deadline, pump)
try:
value = json.loads(bounded(path))
except (OSError, json.JSONDecodeError):
value = None
if isinstance(value, dict) and predicate(value):
return value
if time.monotonic() >= deadline:
raise RuntimeError(f"timeout waiting for correlated {path.name}")
if pump is not None:
pump()
time.sleep(0.05)
def load_peer(root: Path, expected: str) -> dict:
value = json.loads(bounded(root / "android-peer-v1.json"))
if (
set(value) != {"format", "version", "peer_id", "capabilities"}
or value["format"] != "trainlog-sync-peer"
or type(value["version"]) is not int
or value["version"] != 1
or value["peer_id"] != expected
or not isinstance(value["capabilities"], list)
or not CAPS.issubset(set(value["capabilities"]))
):
raise RuntimeError("incompatible or unpaired Android peer advertisement")
return value
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--database", required=True, type=Path)
parser.add_argument("--transport-root", required=True, type=Path)
parser.add_argument("--owned-root", required=True, type=Path)
parser.add_argument("--run-id", required=True)
parser.add_argument("--expected-peer", required=True)
parser.add_argument("--timeout", required=True, type=int)
parser.add_argument("--mode", choices=("directory", "mtp"), default="directory")
parser.add_argument("--mtp-adapter", type=Path)
args = parser.parse_args()
deadline = time.monotonic() + args.timeout
pump = None
if args.mode == "mtp":
if args.mtp_adapter is None:
raise RuntimeError("MTP adapter is required")
pump = lambda: run_adapter(
args.mtp_adapter,
"pull",
args.expected_peer,
args.transport_root,
deadline,
True,
)
# Android publishes its durable identity only while participating. A
# missing expected advertisement is therefore a bounded wait state;
# device, transport, ambiguity and malformed-object failures remain
# immediate and distinct adapter errors.
wait_file(args.transport_root / "android-peer-v1.json", deadline, pump)
peer = load_peer(args.transport_root, args.expected_peer)
with closing(generation.connect_database(args.database)) as db:
generation.require_schema(db)
desktop_peer = generation.peer_identity(db, "desktop")
db.commit()
request = {
"format": "trainlog-sync-generation-request",
"version": 1,
"run_id": args.run_id,
"desktop_peer_id": desktop_peer,
"android_peer_id": peer["peer_id"],
"capabilities": sorted(CAPS),
}
request_path = args.transport_root / "request-v1.json"
publish_json(request_path, request)
if args.mode == "mtp":
run_adapter(
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
)
emit(
args.run_id,
"waiting_android_publication",
producer_peer_id=peer["peer_id"],
consumer_peer_id=desktop_peer,
)
inbound_ref = args.transport_root / "android-generation-v1.json"
inbound = wait_json(
inbound_ref, deadline, lambda value: value.get("run_id") == args.run_id, pump
)
inbound_dir = args.transport_root / inbound["relative_path"]
ack = generation.consume_desktop(args.database, inbound_dir)
ack_path = args.transport_root / "desktop-consumption-ack-v1.json"
publish_json(ack_path, ack)
if args.mode == "mtp":
run_adapter(
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
)
if ack["result"] != "consumed":
raise RuntimeError("desktop rejected Android generation")
emit(
args.run_id,
"local_import_committed",
inbound_generation_id=ack["generation_id"],
manifest_sha256=ack["manifest_sha256"],
)
outgoing_id = "gen_" + str(uuid.uuid4())
# Export codecs retain human diagnostics on stdout when invoked as CLIs.
# The worker stdout contract is typed NDJSON only, so route those bounded
# diagnostics to the separately bounded stderr channel.
with redirect_stdout(sys.stderr):
stage, manifest, digest = generation.capture_desktop(
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id
)
del stage
published = generation.publish(
args.database, outgoing_id, args.transport_root / "desktop-objects"
)
ref = {
"format": "trainlog-sync-generation-reference",
"version": 1,
"run_id": args.run_id,
"generation_id": outgoing_id,
"manifest_sha256": digest,
"relative_path": str(published.relative_to(args.transport_root)),
}
publish_json(args.transport_root / "desktop-generation-v1.json", ref)
if args.mode == "mtp":
run_adapter(
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
)
emit(
args.run_id,
"published",
outbound_generation_id=outgoing_id,
manifest_sha256=digest,
)
emit(args.run_id, "waiting_acknowledgement", outbound_generation_id=outgoing_id)
android_ack = args.transport_root / "android-consumption-ack-v1.json"
android_ack_value = wait_json(
android_ack,
deadline,
lambda value: value.get("run_id") == args.run_id
and value.get("generation_id") == outgoing_id,
pump,
)
result = generation.accept_ack(args.database, android_ack)
if result not in ("acknowledged", "unchanged"):
raise RuntimeError(
f"Android did not durably consume desktop generation: {result}: {android_ack_value.get('diagnostic','')}"
)
with closing(generation.connect_database(args.database)) as db:
inbound_row = db.execute(
"SELECT result FROM sync_consumed_generations WHERE generation_id=?",
(ack["generation_id"],),
).fetchone()
outbound_row = db.execute(
"SELECT status FROM sync_generations WHERE generation_id=?", (outgoing_id,)
).fetchone()
drafts = [
{
"draft_id": row[0],
"state": row[1],
"session_type": json.loads(row[2]).get("session_type", "training"),
"occurrence_count": len(json.loads(row[2]).get("exercises", [])),
}
for row in db.execute(
"SELECT session_id,state,payload_json FROM execution_drafts ORDER BY session_id LIMIT 32"
)
]
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
raise RuntimeError("generation evidence did not corroborate completion")
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
report = {
"format": "trainlog-sync-worker-report",
"version": 1,
"run_id": args.run_id,
"producer_peer_id": peer["peer_id"],
"consumer_peer_id": desktop_peer,
"inbound_generation_id": ack["generation_id"],
"outbound_generation_id": outgoing_id,
"manifest_sha256": digest,
"result": "completed",
"sessions_reconciled": 0,
"domains": {
name: True
for name in (
"history-v4",
"execution-draft-v1",
"causal-delete-v1",
"equipment",
"aliases",
"profiles",
"body-zones",
"feedback",
)
},
"drafts": drafts,
"ai_midpoint": {"result": "not_configured"},
"ai_post_sync": {"result": "not_configured"},
}
print(json.dumps(report, sort_keys=True, separators=(",", ":")), flush=True)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except RuntimeError as error:
print(f"sync peer failed: {error}", file=sys.stderr)
raise SystemExit(1) from None

View file

@ -375,7 +375,7 @@ def commands_for_suite(repository: Path, suite: str) -> list[tuple[str, list[str
return smoke
if suite == "preflight":
return []
commands = [
return [
("desktop-compile", ["meson", "compile", "-C", "build"], repository),
(
"desktop-tests",
@ -423,11 +423,6 @@ def commands_for_suite(repository: Path, suite: str) -> list[tuple[str, list[str
android,
),
]
browser_python = os.environ.get("TRAINLOG_WEB_E2E_PYTHON")
if browser_python:
commands.append(("web-sync-browser", [browser_python,
str(repository / "tests/test_web_sync_browser.py"), "-v"], repository))
return commands
def write_summary(paths: RunPaths, payload: dict[str, object]) -> None:

View file

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

View file

@ -1,44 +0,0 @@
#ifndef TRAINLOG_GENERATION_MTP_H
#define TRAINLOG_GENERATION_MTP_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "trainlog/mtp.h"
#include "trainlog/status.h"
#include "trainlog/usb.h"
#define TRAINLOG_GENERATION_MTP_MAX_DEVICES 8U
#define TRAINLOG_GENERATION_MTP_MAX_STORAGES 8U
#define TRAINLOG_GENERATION_MTP_MAX_CHILDREN 512U
#define TRAINLOG_GENERATION_MTP_MAX_DEPTH 5U
#define TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES (512ULL * 1024ULL * 1024ULL)
typedef struct TrainlogGenerationMtpIo {
TrainlogStatus (*devices)(TrainlogUsbDevice *, size_t, size_t *);
TrainlogStatus (*storages)(unsigned int, unsigned int, TrainlogMtpStorage *, size_t, size_t *);
TrainlogStatus (*children)(
unsigned int, unsigned int, uint32_t, uint32_t, TrainlogMtpEntry *, size_t, size_t *);
TrainlogStatus (*ensure_folder)(
unsigned int, unsigned int, uint32_t, uint32_t, const char *, uint32_t *, bool *);
TrainlogStatus (*receive)(unsigned int, unsigned int, uint32_t, const char *);
TrainlogStatus (*send)(
unsigned int, unsigned int, uint32_t, uint32_t, const char *, const char *, uint32_t *);
TrainlogStatus (*remove)(unsigned int, unsigned int, uint32_t);
TrainlogStatus (*rename)(unsigned int, unsigned int, uint32_t, const char *);
} TrainlogGenerationMtpIo;
/** Mirror remote generation-protocol objects to or from a private local root. */
TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
const char *expected_peer_id,
const char *local_root,
char *diagnostic,
size_t diagnostic_size);
TrainlogStatus trainlog_generation_mtp_push(const TrainlogGenerationMtpIo *io,
const char *expected_peer_id,
const char *local_root,
char *diagnostic,
size_t diagnostic_size);
const TrainlogGenerationMtpIo *trainlog_generation_mtp_production_io(void);
#endif

View file

@ -59,23 +59,6 @@ TrainlogStatus trainlog_mtp_ensure_root_folder(
bool *output_created
);
/**
* @brief Find or create one exact folder below an explicit parent.
*
* Duplicate names are rejected as a conflict. The returned object identifier
* is valid only for the current connection epoch and must be resolved again
* after reconnecting.
*/
TrainlogStatus trainlog_mtp_ensure_folder(
unsigned int bus_number,
unsigned int device_number,
uint32_t storage_id,
uint32_t parent_folder_id,
const char *folder_name,
uint32_t *output_folder_id,
bool *output_created
);
/**
* @brief Upload one local text file into an exact MTP folder.
*/
@ -133,11 +116,4 @@ TrainlogStatus trainlog_mtp_delete_object(
uint32_t item_id
);
TrainlogStatus trainlog_mtp_rename_object(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id,
const char *new_name
);
#endif

View file

@ -2,25 +2,16 @@
#define TRAINLOG_WEB_SERVER_H
#include <signal.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "trainlog/database.h"
/* Serialize the exact successful sync-admission document. Both identities are
* borrowed, validated sy_<uuid-v4> values. On failure output is empty. */
bool trainlog_web_sync_accepted_serialize(
const char *request_id, const char *run_id, char *output, size_t capacity, size_t *output_size);
/* Blocking, single-threaded local HTTP adapter. Port zero is reserved for
* controlled test harnesses and is rejected by the CLI. The database remains
* borrowed and process-owned; this infrastructure route never reads it. */
int trainlog_web_server_run(TrainlogDatabase *database,
const char *database_path,
uint16_t port,
const volatile sig_atomic_t *stop_requested,
char *diagnostic,
size_t diagnostic_capacity);
int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
const volatile sig_atomic_t *stop_requested,
char *diagnostic, size_t diagnostic_capacity);
#endif

View file

@ -47,16 +47,12 @@ if web_assets_enabled
'../web/src/api/health.ts',
'../web/src/api/dashboard.ts',
'../web/src/api/dashboardLayout.ts',
'../web/src/api/sync.ts',
'../web/src/api/sync.test.ts',
'../web/src/app/App.tsx',
'../web/src/app/routes.ts',
'../web/src/app/useRoute.ts',
'../web/src/components/EmptyState.tsx',
'../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx',
'../web/src/components/SyncControl.tsx',
'../web/src/components/SyncControl.test.tsx',
'../web/src/components/Tile.tsx',
'../web/src/charts/ProgressionChart.tsx',
'../web/src/charts/trainlogChartTheme.ts',
@ -198,7 +194,6 @@ trainlog_core_sources = files(
'src/training_context.c',
'src/usb.c',
'src/mtp.c',
'src/generation_mtp.c',
'src/reps.c',
'src/session_generation.c',
'src/statistics.c',
@ -224,7 +219,6 @@ trainlog_core = static_library(
m_dep,
udev_dep,
mtp_dep,
yyjson_dep,
],
c_args: strict_c_args,
)
@ -239,16 +233,9 @@ trainlog_core_dep = declare_dependency(
m_dep,
udev_dep,
mtp_dep,
yyjson_dep,
],
)
executable(
'sync-generation-fixture',
'tests/sync_generation_fixture.c',
dependencies: trainlog_core_dep,
)
trainlog_tui_sources = files(
'src/cli.c',
'src/main.c',
@ -555,13 +542,6 @@ trainlog_mtp_probe = executable(
c_args: strict_c_args,
)
trainlog_generation_mtp_adapter = executable(
'trainlog-generation-mtp-adapter',
'tools/generation_mtp_adapter.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test_mtp = executable(
'test_mtp',
'tests/test_mtp.c',
@ -574,21 +554,6 @@ test(
test_mtp,
)
test_generation_mtp = executable(
'test_generation_mtp',
'tests/test_generation_mtp.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('generation_mtp', test_generation_mtp)
generation_mtp_adapter_double = executable(
'generation-mtp-adapter-double',
'tests/generation_mtp_adapter_double.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
trainlog_mtp_exchange_probe = executable(
'trainlog-mtp-exchange-probe',
'tools/mtp_exchange_probe.c',
@ -748,31 +713,6 @@ test(
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_validate_sync_isolated.py'],
)
test(
'causal_delete_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_causal_delete_exchange.py'],
)
test(
'sync_generation_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
timeout: 120,
)
test(
'sync_orchestrator',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_orchestrator.py'],
)
test(
'sync_deployment_tools',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_deployment_tools.py'],
)
test(
'schema_v21_migration',
executable('test_schema_v21_migration', 'tests/test_schema_v21_migration.c', dependencies: trainlog_core_dep),
)
test(
'equipment_associations_exchange',

View file

@ -27,121 +27,109 @@ static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"last-session", 3U, 7U, 3U, 6U},
{"max-records", 3U, 8U, 3U, 7U},
{"muscle-distribution", 5U, 12U, 4U, 9U},
{"cardio-recovery", 5U, 12U, 4U, 8U}};
{"cardio-recovery", 5U, 12U, 4U, 8U}
};
static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"next-session", 0U, 0U, 5U, 4U},
{"activity", 5U, 0U, 7U, 4U},
{"progression", 0U, 4U, 8U, 5U},
{"last-session", 8U, 4U, 4U, 3U},
{"next-session", 0U, 0U, 5U, 4U}, {"activity", 5U, 0U, 7U, 4U},
{"progression", 0U, 4U, 8U, 5U}, {"last-session", 8U, 4U, 4U, 3U},
{"max-records", 8U, 7U, 4U, 3U},
{"muscle-distribution", 0U, 9U, 6U, 5U},
{"cardio-recovery", 6U, 10U, 6U, 4U}};
{"cardio-recovery", 6U, 10U, 6U, 4U}
};
static int contract_index(const char *id) {
static int contract_index(const char *id)
{
size_t index;
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
if (strcmp(id, contracts[index].id) == 0) {
return (int)index;
}
}
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index)
if (strcmp(id, contracts[index].id) == 0) return (int)index;
return -1;
}
void trainlog_dashboard_layout_default(TrainlogDashboardLayout *layout) {
if (layout == NULL) {
return;
}
void trainlog_dashboard_layout_default(TrainlogDashboardLayout *layout)
{
if (layout == NULL) return;
layout->revision = 0U;
(void)memcpy(layout->tiles, defaults, sizeof(defaults));
}
static bool overlap(const TrainlogDashboardTileLayout *a, const TrainlogDashboardTileLayout *b) {
return a->x < b->x + b->width && a->x + a->width > b->x && a->y < b->y + b->height &&
a->y + a->height > b->y;
static bool overlap(const TrainlogDashboardTileLayout *a,
const TrainlogDashboardTileLayout *b)
{
return a->x < b->x + b->width && a->x + a->width > b->x &&
a->y < b->y + b->height && a->y + a->height > b->y;
}
bool trainlog_dashboard_layout_validate(const TrainlogDashboardLayout *layout) {
bool trainlog_dashboard_layout_validate(const TrainlogDashboardLayout *layout)
{
bool seen[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {false};
size_t index;
if (layout == NULL || layout->revision > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) {
if (layout == NULL || layout->revision > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION)
return false;
}
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
const TrainlogDashboardTileLayout *tile = &layout->tiles[index];
int found = contract_index(tile->id);
const TileContract *contract;
size_t other;
if (found < 0 || seen[(size_t)found]) {
return false;
}
if (found < 0 || seen[(size_t)found]) return false;
seen[(size_t)found] = true;
contract = &contracts[(size_t)found];
if (tile->y > TRAINLOG_DASHBOARD_LAYOUT_MAX_Y || tile->width < contract->min_width ||
tile->width > contract->max_width || tile->height < contract->min_height ||
tile->height > contract->max_height ||
tile->x + tile->width > TRAINLOG_DASHBOARD_LAYOUT_COLUMNS) {
if (tile->y > TRAINLOG_DASHBOARD_LAYOUT_MAX_Y ||
tile->width < contract->min_width || tile->width > contract->max_width ||
tile->height < contract->min_height || tile->height > contract->max_height ||
tile->x + tile->width > TRAINLOG_DASHBOARD_LAYOUT_COLUMNS)
return false;
}
for (other = index + 1U; other < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++other) {
if (overlap(tile, &layout->tiles[other])) {
return false;
}
}
for (other = index + 1U; other < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++other)
if (overlap(tile, &layout->tiles[other])) return false;
}
return true;
}
static bool exact_object(const yyjson_val *value, size_t expected) {
static bool exact_object(const yyjson_val *value, size_t expected)
{
return yyjson_is_obj(value) && yyjson_obj_size(value) == expected;
}
static bool read_u16(yyjson_val *value, uint16_t *output) {
static bool read_u16(yyjson_val *value, uint16_t *output)
{
uint64_t number;
if (!yyjson_is_uint(value)) {
return false;
}
if (!yyjson_is_uint(value)) return false;
number = yyjson_get_uint(value);
if (number > UINT16_MAX) {
return false;
}
if (number > UINT16_MAX) return false;
*output = (uint16_t)number;
return true;
}
static bool equals_uint(yyjson_val *value, uint64_t expected) {
static bool equals_uint(yyjson_val *value, uint64_t expected)
{
return yyjson_is_uint(value) && yyjson_get_uint(value) == expected;
}
TrainlogDashboardLayoutResult
trainlog_dashboard_layout_parse(const char *json, size_t size, TrainlogDashboardLayout *layout) {
TrainlogDashboardLayoutResult trainlog_dashboard_layout_parse(
const char *json, size_t size, TrainlogDashboardLayout *layout)
{
yyjson_read_err error;
yyjson_doc *document;
yyjson_val *root;
yyjson_val *tiles;
size_t index;
if (json == NULL || layout == NULL || size == 0U ||
size >= TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY) {
size >= TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY)
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
document = yyjson_read_opts((char *)json, size, YYJSON_READ_NOFLAG, NULL, &error);
if (document == NULL) {
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
if (document == NULL) return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
root = yyjson_doc_get_root(document);
if (!exact_object(root, 5U) ||
!yyjson_equals_str(yyjson_obj_get(root, "format"), "trainlog-dashboard-layout") ||
!equals_uint(yyjson_obj_get(root, "version"), 1U) ||
!equals_uint(yyjson_obj_get(root, "columns"), 12U) ||
!yyjson_is_uint(yyjson_obj_get(root, "revision"))) {
yyjson_doc_free(document);
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
layout->revision = yyjson_get_uint(yyjson_obj_get(root, "revision"));
tiles = yyjson_obj_get(root, "tiles");
if (!yyjson_is_arr(tiles) || yyjson_arr_size(tiles) != TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT) {
yyjson_doc_free(document);
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
yyjson_val *tile = yyjson_arr_get(tiles, index);
@ -153,184 +141,116 @@ trainlog_dashboard_layout_parse(const char *json, size_t size, TrainlogDashboard
!read_u16(yyjson_obj_get(tile, "y"), &layout->tiles[index].y) ||
!read_u16(yyjson_obj_get(tile, "width"), &layout->tiles[index].width) ||
!read_u16(yyjson_obj_get(tile, "height"), &layout->tiles[index].height)) {
yyjson_doc_free(document);
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
text = yyjson_get_str(id);
length = yyjson_get_len(id);
text = yyjson_get_str(id); length = yyjson_get_len(id);
if (length == 0U || length >= sizeof(layout->tiles[index].id)) {
yyjson_doc_free(document);
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
(void)memcpy(layout->tiles[index].id, text, length);
layout->tiles[index].id[length] = '\0';
}
yyjson_doc_free(document);
return trainlog_dashboard_layout_validate(layout) ? TRAINLOG_DASHBOARD_LAYOUT_OK
: TRAINLOG_DASHBOARD_LAYOUT_INVALID;
return trainlog_dashboard_layout_validate(layout)
? TRAINLOG_DASHBOARD_LAYOUT_OK : TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
bool trainlog_dashboard_layout_path(char *output, size_t capacity) {
bool trainlog_dashboard_layout_path(char *output, size_t capacity)
{
const char *base = getenv("XDG_CONFIG_HOME");
const char *home;
int length;
if (output == NULL || capacity == 0U) {
return false;
}
if (base != NULL && base[0] != '\0') {
if (output == NULL || capacity == 0U) return false;
if (base != NULL && base[0] != '\0')
length = snprintf(output, capacity, "%s/trainlog/web/dashboard-layout-v1.json", base);
} else {
else {
home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return false;
}
length =
snprintf(output, capacity, "%s/.config/trainlog/web/dashboard-layout-v1.json", home);
if (home == NULL || home[0] == '\0') return false;
length = snprintf(output, capacity, "%s/.config/trainlog/web/dashboard-layout-v1.json", home);
}
return length > 0 && (size_t)length < capacity;
}
static bool ensure_private_directories(const char *path) {
static bool ensure_private_directories(const char *path)
{
char web[4096];
char trainlog[4096];
char *last;
struct stat status;
if (path == NULL || strlen(path) >= sizeof(web)) {
return false;
}
if (path == NULL || strlen(path) >= sizeof(web)) return false;
(void)snprintf(web, sizeof(web), "%s", path);
last = strrchr(web, '/');
if (last == NULL) {
return false;
}
*last = '\0';
last = strrchr(web, '/'); if (last == NULL) return false; *last = '\0';
(void)snprintf(trainlog, sizeof(trainlog), "%s", web);
last = strrchr(trainlog, '/');
if (last == NULL) {
return false;
}
*last = '\0';
last = strrchr(trainlog, '/'); if (last == NULL) return false; *last = '\0';
/* CONTRACT: only the two Trainlog-owned directories are created/chmodded;
* an existing symlink or non-directory is never followed. */
{
if (mkdir(trainlog, 0700) != 0 && errno != EEXIST) {
return false;
}
if (lstat(trainlog, &status) != 0 || !S_ISDIR(status.st_mode)) {
return false;
}
if (chmod(trainlog, 0700) != 0) {
return false;
}
}
if (mkdir(web, 0700) != 0 && errno != EEXIST) {
return false;
}
if (lstat(web, &status) != 0 || !S_ISDIR(status.st_mode)) {
return false;
if (mkdir(trainlog, 0700) != 0 && errno != EEXIST) return false;
if (lstat(trainlog, &status) != 0 || !S_ISDIR(status.st_mode)) return false;
if (chmod(trainlog, 0700) != 0) return false;
}
if (mkdir(web, 0700) != 0 && errno != EEXIST) return false;
if (lstat(web, &status) != 0 || !S_ISDIR(status.st_mode)) return false;
return chmod(web, 0700) == 0;
}
static bool sync_parent(const char *path) {
char parent[4096];
char *slash;
int fd;
bool ok;
if (strlen(path) >= sizeof(parent)) {
return false;
}
static bool sync_parent(const char *path)
{
char parent[4096]; char *slash; int fd; bool ok;
if (strlen(path) >= sizeof(parent)) return false;
(void)snprintf(parent, sizeof(parent), "%s", path);
slash = strrchr(parent, '/');
if (slash == NULL) {
return false;
}
*slash = '\0';
slash = strrchr(parent, '/'); if (slash == NULL) return false; *slash = '\0';
fd = open(parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
if (fd < 0) {
return false;
}
ok = fsync(fd) == 0;
if (close(fd) != 0) {
ok = false;
}
return ok;
if (fd < 0) return false;
ok = fsync(fd) == 0; if (close(fd) != 0) ok = false; return ok;
}
bool trainlog_dashboard_layout_serialize(const TrainlogDashboardLayout *layout,
TrainlogDashboardLayoutSource source,
bool include_source,
char *output,
size_t capacity,
size_t *size) {
size_t used = 0U;
size_t index;
int count;
const char *source_name = source == TRAINLOG_DASHBOARD_LAYOUT_PERSISTED ? "persisted"
: source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED
? "invalid_persisted"
: "default";
if (!trainlog_dashboard_layout_validate(layout) || output == NULL || size == NULL) {
TrainlogDashboardLayoutSource source, bool include_source,
char *output, size_t capacity, size_t *size)
{
size_t used = 0U; size_t index; int count;
const char *source_name = source == TRAINLOG_DASHBOARD_LAYOUT_PERSISTED
? "persisted" : source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED
? "invalid_persisted" : "default";
if (!trainlog_dashboard_layout_validate(layout) || output == NULL || size == NULL)
return false;
}
#define APPEND(...) \
do { \
count = snprintf(output + used, capacity - used, __VA_ARGS__); \
if (count < 0 || (size_t)count >= capacity - used) \
return false; \
used += (size_t)count; \
} while (0)
#define APPEND(...) do { count = snprintf(output + used, capacity - used, __VA_ARGS__); \
if (count < 0 || (size_t)count >= capacity - used) return false; used += (size_t)count; } while (0)
APPEND("{\"format\":\"trainlog-dashboard-layout\",\"version\":1,"
"\"revision\":%llu,\"columns\":12",
(unsigned long long)layout->revision);
if (include_source) {
APPEND(",\"source\":\"%s\"", source_name);
}
"\"revision\":%llu,\"columns\":12", (unsigned long long)layout->revision);
if (include_source) APPEND(",\"source\":\"%s\"", source_name);
APPEND(",\"tiles\":[");
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
const TrainlogDashboardTileLayout *tile = &layout->tiles[index];
APPEND("%s{\"id\":\"%s\",\"x\":%u,\"y\":%u,\"width\":%u,\"height\":%u}",
index == 0U ? "" : ",",
tile->id,
tile->x,
tile->y,
tile->width,
tile->height);
index == 0U ? "" : ",", tile->id, tile->x, tile->y,
tile->width, tile->height);
}
APPEND("]}\n");
#undef APPEND
*size = used;
return true;
*size = used; return true;
}
TrainlogDashboardLayoutResult
trainlog_dashboard_layout_load(TrainlogDashboardLayout *layout,
TrainlogDashboardLayoutSource *source) {
char path[4096];
char buffer[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
struct stat status;
ssize_t count;
int fd;
TrainlogDashboardLayoutResult result;
if (layout == NULL || source == NULL || !trainlog_dashboard_layout_path(path, sizeof(path))) {
TrainlogDashboardLayoutResult trainlog_dashboard_layout_load(
TrainlogDashboardLayout *layout, TrainlogDashboardLayoutSource *source)
{
char path[4096]; char buffer[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
struct stat status; ssize_t count; int fd; TrainlogDashboardLayoutResult result;
if (layout == NULL || source == NULL || !trainlog_dashboard_layout_path(path, sizeof(path)))
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
trainlog_dashboard_layout_default(layout);
*source = TRAINLOG_DASHBOARD_LAYOUT_DEFAULT;
trainlog_dashboard_layout_default(layout); *source = TRAINLOG_DASHBOARD_LAYOUT_DEFAULT;
fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
if (fd < 0) {
if (errno == ENOENT || errno == ELOOP) {
if (errno == ELOOP) {
*source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
}
if (errno == ELOOP) *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
if (fstat(fd, &status) != 0 || !S_ISREG(status.st_mode) || status.st_size <= 0 ||
status.st_size >= (off_t)sizeof(buffer)) {
(void)close(fd);
*source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
(void)close(fd); *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
count = read(fd, buffer, (size_t)status.st_size);
@ -342,67 +262,45 @@ trainlog_dashboard_layout_load(TrainlogDashboardLayout *layout,
if (result != TRAINLOG_DASHBOARD_LAYOUT_OK) {
trainlog_dashboard_layout_default(layout);
*source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
} else {
*source = TRAINLOG_DASHBOARD_LAYOUT_PERSISTED;
}
} else *source = TRAINLOG_DASHBOARD_LAYOUT_PERSISTED;
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
static bool write_all(int fd, const char *data, size_t size) {
static bool write_all(int fd, const char *data, size_t size)
{
size_t used = 0U;
while (used < size) {
ssize_t count = write(fd, data + used, size - used);
if (count < 0 && errno == EINTR) {
continue;
}
if (count <= 0) {
return false;
}
used += (size_t)count;
}
while (used < size) { ssize_t count = write(fd, data + used, size - used);
if (count < 0 && errno == EINTR) continue; if (count <= 0) return false; used += (size_t)count; }
return true;
}
TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(const TrainlogDashboardLayout *layout,
uint64_t expected_revision,
TrainlogDashboardLayout *saved) {
TrainlogDashboardLayout current;
TrainlogDashboardLayoutSource source;
TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(
const TrainlogDashboardLayout *layout, uint64_t expected_revision,
TrainlogDashboardLayout *saved)
{
TrainlogDashboardLayout current; TrainlogDashboardLayoutSource source;
char path[4096], temporary[4128], json[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
size_t size;
int fd;
bool ok;
size_t size; int fd; bool ok;
if (layout == NULL || saved == NULL || !trainlog_dashboard_layout_validate(layout) ||
layout->revision != expected_revision ||
expected_revision >= TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) {
layout->revision != expected_revision || expected_revision >= TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION)
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
if (trainlog_dashboard_layout_load(&current, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK) {
if (trainlog_dashboard_layout_load(&current, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK)
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
if (current.revision != expected_revision) {
return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
}
*saved = *layout;
saved->revision = expected_revision + 1U;
if (!trainlog_dashboard_layout_path(path, sizeof(path)) || !ensure_private_directories(path) ||
!trainlog_dashboard_layout_serialize(
saved, TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, false, json, sizeof(json), &size) ||
snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path) >= (int)sizeof(temporary)) {
if (current.revision != expected_revision) return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
*saved = *layout; saved->revision = expected_revision + 1U;
if (!trainlog_dashboard_layout_path(path, sizeof(path)) ||
!ensure_private_directories(path) ||
!trainlog_dashboard_layout_serialize(saved, TRAINLOG_DASHBOARD_LAYOUT_PERSISTED,
false, json, sizeof(json), &size) ||
snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path) >= (int)sizeof(temporary))
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
fd = mkstemp(temporary);
if (fd < 0) {
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
if (fd < 0) return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
ok = fchmod(fd, 0600) == 0 && fcntl(fd, F_SETFD, FD_CLOEXEC) == 0 &&
write_all(fd, json, size) && fsync(fd) == 0;
if (close(fd) != 0) {
ok = false;
}
write_all(fd, json, size) && fsync(fd) == 0;
if (close(fd) != 0) ok = false;
if (!ok || rename(temporary, path) != 0) {
(void)unlink(temporary);
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
(void)unlink(temporary); return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
/* INVARIANT: after rename the valid file is committed. Directory fsync is
* attempted for crash durability, but its failure cannot roll back or make
@ -411,21 +309,16 @@ TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(const TrainlogDashb
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
TrainlogDashboardLayoutResult trainlog_dashboard_layout_delete(uint64_t expected_revision) {
TrainlogDashboardLayout current;
TrainlogDashboardLayoutSource source;
char path[4096];
if (trainlog_dashboard_layout_load(&current, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK) {
TrainlogDashboardLayoutResult trainlog_dashboard_layout_delete(uint64_t expected_revision)
{
TrainlogDashboardLayout current; TrainlogDashboardLayoutSource source; char path[4096];
if (trainlog_dashboard_layout_load(&current, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK)
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
if (current.revision != expected_revision) {
return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
}
if (!trainlog_dashboard_layout_path(path, sizeof(path))) {
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
if (current.revision != expected_revision) return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
if (!trainlog_dashboard_layout_path(path, sizeof(path))) return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
if (unlink(path) != 0) {
return errno == ENOENT ? TRAINLOG_DASHBOARD_LAYOUT_OK : TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
return errno == ENOENT ? TRAINLOG_DASHBOARD_LAYOUT_OK
: TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
(void)sync_parent(path);
return TRAINLOG_DASHBOARD_LAYOUT_OK;

View file

@ -804,72 +804,6 @@ static const char *const MIGRATE_V17_TO_V18_SQL =
"BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;"
"PRAGMA user_version=18;COMMIT;";
/* WHY: V3 cannot carry lifecycle-owned notes, observation parents, or
* resumable execution drafts. CONTRACT: v19 is additive and provides only the
* durable state required by the explicitly selected V4/lifecycle codecs.
* INVARIANT: completed history, AI proposals, feedback, aliases, and every
* existing stable identity remain untouched; NULL legacy notes keep unknown
* ancestry and no timestamp or causal parent is fabricated. */
static const char *const MIGRATE_V18_TO_V19_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE sync_note_revisions("
"owner_kind TEXT NOT NULL CHECK(owner_kind IN('session','occurrence','observation')),"
"owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,note TEXT,"
"CHECK(note IS NULL OR length(CAST(note AS BLOB))<=4096),"
"PRIMARY KEY(owner_kind,owner_id,revision_id));"
"CREATE TABLE sync_note_state("
"owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,"
"PRIMARY KEY(owner_kind,owner_id),"
"FOREIGN KEY(owner_kind,owner_id,revision_id) REFERENCES sync_note_revisions(owner_kind,owner_id,revision_id));"
"CREATE TABLE execution_drafts("
"session_id TEXT PRIMARY KEY,session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),"
"source_session_id TEXT,started_at TEXT,revision_id TEXT NOT NULL,parent_revision_id TEXT,"
"state TEXT NOT NULL CHECK(state IN('active','pending')),payload_json TEXT NOT NULL);"
"CREATE TABLE execution_draft_revisions("
"session_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,payload_json TEXT NOT NULL,"
"PRIMARY KEY(session_id,revision_id));"
"CREATE TABLE execution_draft_finalizations("
"session_id TEXT PRIMARY KEY,final_revision_id TEXT NOT NULL,finalized_at TEXT NOT NULL);"
"PRAGMA user_version=19;COMMIT;";
/* WHY: offline peers can replay live snapshots after an intentional deletion.
* CONTRACT: v20 records immutable causal deletion operations separately from
* future publication generations. INVARIANT: no legacy row is marked deleted
* and no ancestry, peer acknowledgement, or emission context is fabricated. */
static const char *const MIGRATE_V19_TO_V20_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE sync_causal_operations("
"operation_id TEXT PRIMARY KEY,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,"
"creator_id TEXT NOT NULL,predecessor_revision_id TEXT NOT NULL,"
"created_at TEXT NOT NULL,payload_sha256 TEXT NOT NULL,publication_context TEXT,"
"CHECK(length(operation_id) BETWEEN 1 AND 128),"
"CHECK(length(target_id) BETWEEN 1 AND 512),"
"CHECK(length(creator_id) BETWEEN 1 AND 128),"
"CHECK(length(predecessor_revision_id) BETWEEN 1 AND 128),"
"CHECK(length(payload_sha256)=64),"
"UNIQUE(target_kind,target_id,operation_id));"
"CREATE TABLE sync_causal_state("
"target_kind TEXT NOT NULL,target_id TEXT NOT NULL,current_revision_id TEXT NOT NULL,"
"deleted INTEGER NOT NULL CHECK(deleted IN(0,1)),operation_id TEXT,"
"PRIMARY KEY(target_kind,target_id),"
"FOREIGN KEY(operation_id) REFERENCES sync_causal_operations(operation_id));"
"CREATE INDEX sync_causal_operations_target ON sync_causal_operations(target_kind,target_id);"
"PRAGMA user_version=20;COMMIT;";
/* WHY: publication and durable consumption are distinct from immutable domain
* operations. CONTRACT: v21 stores peer/generation/manifest/ACK identity and a
* separate operation-to-generation ledger. INVARIANT: migration invents no
* generation, acknowledgement, consumption, ancestry, or causal operation. */
static const char *const MIGRATE_V20_TO_V21_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE sync_peer_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1),peer_id TEXT NOT NULL UNIQUE,kind TEXT NOT NULL CHECK(kind IN('desktop','android')));"
"CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,producer_kind TEXT NOT NULL,generated_at TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('captured','published','waiting_acknowledgement','acknowledged','rejected')),manifest_json TEXT NOT NULL,staging_path TEXT NOT NULL,acknowledged_at TEXT);"
"CREATE TABLE sync_generation_artifacts(generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE CASCADE,logical_name TEXT NOT NULL,format TEXT NOT NULL,version INTEGER NOT NULL,filename TEXT NOT NULL,size_bytes INTEGER NOT NULL,sha256 TEXT NOT NULL,required INTEGER NOT NULL CHECK(required IN(0,1)),PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename));"
"CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT NULL,consumed_at TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,diagnostic TEXT NOT NULL,ack_json TEXT NOT NULL,UNIQUE(producer_peer_id,generation_id));"
"CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT NOT NULL,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,manifest_sha256 TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,created_at TEXT NOT NULL,diagnostic TEXT NOT NULL,payload_sha256 TEXT NOT NULL);"
"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;";
/* CONTRACT: v18 was already deployed before bounded draft publication was
* added. Keep its version number and add only the nullable lifecycle cursor;
* NULL deliberately means retryable/not yet published. */
@ -1458,9 +1392,7 @@ TrainlogStatus trainlog_database_list_training_feedback(
TrainlogStatus status = list_feedback_query(database,
"SELECT f.feedback_id,se.entry_id,f.observed_at,f.raw_text 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 "
"WHERE s.session_id=?1 AND NOT EXISTS(SELECT 1 FROM sync_causal_state cs "
"WHERE cs.target_kind='feedback' AND cs.target_id=f.feedback_id AND cs.deleted=1) "
"ORDER BY f.observed_at COLLATE BINARY,f.feedback_id COLLATE BINARY",
"WHERE s.session_id=?1 ORDER BY f.observed_at COLLATE BINARY,f.feedback_id COLLATE BINARY",
session_id, exercise_feedback, exercise_capacity, exercise_count, true);
if (status != TRAINLOG_STATUS_OK) return status;
qsort(exercise_feedback, *exercise_count, sizeof(*exercise_feedback), compare_feedback_view);
@ -1564,8 +1496,7 @@ static TrainlogStatus initialize_or_validate_schema(
} else if (version == 10) {
status = TRAINLOG_STATUS_OK;
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
version == 15 || version == 16 || version == 17 || version == 18 ||
version == 19 || version == 20 || version == 21) {
version == 15 || version == 16 || version == 17 || version == 18) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -1729,15 +1660,6 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK && version < 18) {
status = execute_sql(database, MIGRATE_V17_TO_V18_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 19) {
status = execute_sql(database, MIGRATE_V18_TO_V19_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 20) {
status = execute_sql(database, MIGRATE_V19_TO_V20_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 21) {
status = execute_sql(database, MIGRATE_V20_TO_V21_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -1753,7 +1675,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v21" : "migrate database to schema v21",
version == 0 ? "create schema v18" : "migrate database to schema v18",
database->connection,
SQLITE_ERROR
);
@ -1984,9 +1906,7 @@ TrainlogStatus trainlog_database_list_custom_equipment(
return TRAINLOG_STATUS_INVALID_ARGUMENT;
*output_count = 0U;
rc = sqlite3_prepare_v2(database->connection,
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment ce "
"WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' AND s.target_id=ce.equipment_id AND s.deleted=1) "
"ORDER BY display_name COLLATE NOCASE,equipment_id;",
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment ORDER BY display_name COLLATE NOCASE,equipment_id;",
-1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
@ -2032,7 +1952,7 @@ TrainlogStatus trainlog_database_list_custom_equipment_page(
{
static const char *const SQL =
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ce WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' AND s.target_id=ce.equipment_id AND s.deleted=1) ORDER BY display_name COLLATE NOCASE,equipment_id "
"FROM custom_equipment ORDER BY display_name COLLATE NOCASE,equipment_id "
"LIMIT ?1 OFFSET ?2;";
sqlite3_stmt *statement = NULL;
size_t count = 0U;
@ -3175,7 +3095,7 @@ TrainlogStatus trainlog_database_list_exercises_filtered(
{
static const char *const SQL =
"SELECT id,exercise_id,name,tracking_mode,recording_mode,data_fields,normalized_name "
"FROM exercises e WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='exercise' AND s.target_id=e.exercise_id AND s.deleted=1) ORDER BY name COLLATE NOCASE,exercise_id;";
"FROM exercises ORDER BY name COLLATE NOCASE,exercise_id;";
sqlite3_stmt *statement = NULL;
size_t count = 0U;
size_t prefix_length;
@ -4373,26 +4293,6 @@ TrainlogStatus trainlog_database_replace_session_exercises(
return TRAINLOG_STATUS_DATABASE_ERROR;
}
/* WHY: content can return to its earlier bytes after a real correction.
* CONTRACT: a successful replacement advances a durable non-deleted
* causal revision in the same transaction. INVARIANT: a deleted session
* cannot be modified or silently reactivated by this writer. */
{
sqlite3_stmt *causal = NULL;
rc = sqlite3_prepare_v2(database->connection,
"INSERT INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted,operation_id) "
"VALUES('session',?1,'mu_'||lower(hex(randomblob(16))),0,NULL) "
"ON CONFLICT(target_kind,target_id) DO UPDATE SET current_revision_id=excluded.current_revision_id "
"WHERE sync_causal_state.deleted=0;", -1, &causal, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(causal, 1, session_id, -1, SQLITE_TRANSIENT);
if (rc == SQLITE_OK) rc = sqlite3_step(causal);
if (causal != NULL && sqlite3_finalize(causal) != SQLITE_OK && rc == SQLITE_DONE) rc = SQLITE_ERROR;
if (rc != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
(void)trainlog_database_rollback(database);
return rc == SQLITE_DONE ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
}
}
status = trainlog_database_commit(database);
if (status != TRAINLOG_STATUS_OK) {

View file

@ -1,515 +0,0 @@
#include "trainlog/generation_mtp.h"
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <yyjson.h>
typedef struct Selection {
unsigned int bus;
unsigned int device;
uint32_t storage;
uint32_t root;
} Selection;
static void diag(char *output, size_t size, const char *value) {
if (output != NULL && size > 0U) {
(void)snprintf(output, size, "%s", value);
}
}
static bool safe_leaf(const char *name) {
return name != NULL && name[0] != '\0' && strcmp(name, ".") != 0 && strcmp(name, "..") != 0 &&
strchr(name, '/') == NULL && strchr(name, '\\') == NULL;
}
static TrainlogStatus children(const TrainlogGenerationMtpIo *io,
const Selection *selection,
uint32_t parent,
TrainlogMtpEntry *entries,
size_t *count) {
return io->children(selection->bus,
selection->device,
selection->storage,
parent,
entries,
TRAINLOG_GENERATION_MTP_MAX_CHILDREN,
count);
}
static TrainlogStatus one_named(const TrainlogGenerationMtpIo *io,
const Selection *selection,
uint32_t parent,
const char *name,
bool folder,
TrainlogMtpEntry *result) {
TrainlogMtpEntry entries[TRAINLOG_GENERATION_MTP_MAX_CHILDREN];
size_t count = 0U, index, matches = 0U;
TrainlogStatus status = children(io, selection, parent, entries, &count);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
for (index = 0U; index < count; ++index) {
if (entries[index].folder == folder && strcmp(entries[index].name, name) == 0) {
*result = entries[index];
++matches;
}
}
if (matches > 1U) {
return TRAINLOG_STATUS_CONFLICT;
}
return matches == 1U ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_NOT_FOUND;
}
static bool peer_document_matches(const char *path, const char *expected) {
int fd = open(path, O_RDONLY | O_CLOEXEC);
char buffer[16385];
ssize_t count;
yyjson_doc *document;
yyjson_val *root, *capabilities, *item;
size_t index, maximum;
bool manifest = false, acknowledgement = false, valid;
if (fd < 0) {
return false;
}
count = read(fd, buffer, sizeof(buffer));
(void)close(fd);
if (count <= 0 || (size_t)count >= sizeof(buffer)) {
return false;
}
buffer[count] = '\0';
document = yyjson_read(buffer, (size_t)count, YYJSON_READ_NOFLAG);
if (document == NULL) {
return false;
}
root = yyjson_doc_get_root(document);
capabilities = yyjson_obj_get(root, "capabilities");
valid = yyjson_is_obj(root) && yyjson_obj_size(root) == 4U &&
yyjson_equals_str(yyjson_obj_get(root, "format"), "trainlog-sync-peer") &&
yyjson_is_uint(yyjson_obj_get(root, "version")) &&
yyjson_get_uint(yyjson_obj_get(root, "version")) == 1U &&
yyjson_equals_str(yyjson_obj_get(root, "peer_id"), expected) &&
yyjson_is_arr(capabilities);
if (valid) {
yyjson_arr_foreach(capabilities, index, maximum, item) {
if (!yyjson_is_str(item)) {
valid = false;
break;
}
if (yyjson_equals_str(item, "generation-manifest-v1")) {
manifest = true;
}
if (yyjson_equals_str(item, "generation-ack-v1")) {
acknowledgement = true;
}
}
}
yyjson_doc_free(document);
return valid && manifest && acknowledgement;
}
static TrainlogStatus select_peer(const TrainlogGenerationMtpIo *io,
const char *expected,
const char *local_root,
Selection *output,
char *diagnostic,
size_t diagnostic_size) {
TrainlogUsbDevice devices[TRAINLOG_GENERATION_MTP_MAX_DEVICES];
TrainlogMtpStorage storages[TRAINLOG_GENERATION_MTP_MAX_STORAGES];
size_t device_count = 0U, storage_count, di, si, matches = 0U;
char peer_path[1024];
TrainlogStatus status;
if (snprintf(peer_path, sizeof(peer_path), "%s/.peer-candidate", local_root) < 0 ||
strlen(local_root) + strlen("/.peer-candidate") >= sizeof(peer_path)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = io->devices(devices, TRAINLOG_GENERATION_MTP_MAX_DEVICES, &device_count);
if (status != TRAINLOG_STATUS_OK || device_count == 0U) {
diag(diagnostic, diagnostic_size, "no MTP device available");
return TRAINLOG_STATUS_NOT_FOUND;
}
for (di = 0U; di < device_count; ++di) {
status = io->storages(devices[di].bus_number,
devices[di].device_number,
storages,
TRAINLOG_GENERATION_MTP_MAX_STORAGES,
&storage_count);
if (status != TRAINLOG_STATUS_OK) {
continue;
}
for (si = 0U; si < storage_count; ++si) {
Selection candidate = {
devices[di].bus_number, devices[di].device_number, storages[si].storage_id, 0U};
TrainlogMtpEntry documents, root, peer;
if (one_named(io, &candidate, UINT32_MAX, "Documents", true, &documents) !=
TRAINLOG_STATUS_OK ||
one_named(io, &candidate, documents.item_id, "Trainlog", true, &root) !=
TRAINLOG_STATUS_OK) {
continue;
}
candidate.root = root.item_id;
if (one_named(io, &candidate, candidate.root, "android-peer-v1.json", false, &peer) !=
TRAINLOG_STATUS_OK) {
continue;
}
(void)unlink(peer_path);
if (peer.size_bytes > 16384U ||
io->receive(candidate.bus, candidate.device, peer.item_id, peer_path) !=
TRAINLOG_STATUS_OK ||
!peer_document_matches(peer_path, expected)) {
continue;
}
*output = candidate;
++matches;
}
}
(void)unlink(peer_path);
if (matches == 0U) {
diag(diagnostic, diagnostic_size, "expected Android peer not found");
return TRAINLOG_STATUS_NOT_FOUND;
}
if (matches > 1U) {
diag(diagnostic, diagnostic_size, "ambiguous compatible Android peers");
return TRAINLOG_STATUS_CONFLICT;
}
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus mkdir_private(const char *path) {
if (mkdir(path, 0700) == 0 || errno == EEXIST) {
return TRAINLOG_STATUS_OK;
}
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
static TrainlogStatus pull_folder(const TrainlogGenerationMtpIo *io,
const Selection *selection,
uint32_t parent,
const char *local,
unsigned int depth,
uint64_t *total,
size_t *objects) {
TrainlogMtpEntry entries[TRAINLOG_GENERATION_MTP_MAX_CHILDREN];
size_t count = 0U, index;
TrainlogStatus status;
if (depth > TRAINLOG_GENERATION_MTP_MAX_DEPTH) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = mkdir_private(local);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = children(io, selection, parent, entries, &count);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
if (*objects > TRAINLOG_GENERATION_MTP_MAX_CHILDREN - count) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*objects += count;
for (index = 0U; index < count; ++index) {
char path[1024], temporary[1060];
int written;
written = snprintf(path, sizeof(path), "%s/%s", local, entries[index].name);
if (!safe_leaf(entries[index].name) || written < 0 || (size_t)written >= sizeof(path)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (entries[index].folder) {
status = pull_folder(
io, selection, entries[index].item_id, path, depth + 1U, total, objects);
} else {
struct stat value;
if (entries[index].size_bytes > TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES ||
*total > TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES - entries[index].size_bytes) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*total += entries[index].size_bytes;
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, entries[index].item_id, temporary);
if (status == TRAINLOG_STATUS_OK &&
(stat(temporary, &value) != 0 || value.st_size < 0 ||
(uint64_t)value.st_size != entries[index].size_bytes ||
rename(temporary, path) != 0)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (status != TRAINLOG_STATUS_OK) {
(void)unlink(temporary);
}
}
if (status != TRAINLOG_STATUS_OK) {
return status;
}
}
return TRAINLOG_STATUS_OK;
}
static bool same_files(const char *left, const char *right) {
int left_fd = open(left, O_RDONLY | O_CLOEXEC);
int right_fd = open(right, O_RDONLY | O_CLOEXEC);
char left_buffer[8192];
char right_buffer[8192];
ssize_t left_count = 0;
ssize_t right_count;
bool same = true;
if (left_fd < 0 || right_fd < 0) {
same = false;
}
while (same && (left_count = read(left_fd, left_buffer, sizeof(left_buffer))) > 0) {
right_count = read(right_fd, right_buffer, (size_t)left_count);
if (right_count != left_count ||
memcmp(left_buffer, right_buffer, (size_t)left_count) != 0) {
same = false;
}
}
if (same && (left_count < 0 || read(right_fd, right_buffer, 1U) != 0)) {
same = false;
}
if (left_fd >= 0) {
(void)close(left_fd);
}
if (right_fd >= 0) {
(void)close(right_fd);
}
return same;
}
static TrainlogStatus push_file(const TrainlogGenerationMtpIo *io,
const Selection *selection,
uint32_t parent,
const char *local,
const char *name) {
TrainlogMtpEntry existing;
TrainlogStatus status;
char comparison[1060], temporary_name[256];
uint32_t uploaded = 0U;
status = one_named(io, selection, parent, name, false, &existing);
if (status == TRAINLOG_STATUS_OK) {
(void)snprintf(comparison, sizeof(comparison), "%s.compare.%ld", local, (long)getpid());
(void)unlink(comparison);
status = io->receive(selection->bus, selection->device, existing.item_id, comparison);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
if (same_files(local, comparison)) {
(void)unlink(comparison);
return TRAINLOG_STATUS_OK;
}
(void)unlink(comparison);
(void)snprintf(
temporary_name, sizeof(temporary_name), ".trainlog-%ld-%s", (long)getpid(), name);
status = io->send(selection->bus,
selection->device,
selection->storage,
parent,
local,
temporary_name,
&uploaded);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
(void)snprintf(comparison, sizeof(comparison), "%s.verify.%ld", local, (long)getpid());
(void)unlink(comparison);
status = io->receive(selection->bus, selection->device, uploaded, comparison);
if (status != TRAINLOG_STATUS_OK || !same_files(local, comparison)) {
(void)unlink(comparison);
(void)io->remove(selection->bus, selection->device, uploaded);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_CONFLICT : status;
}
(void)unlink(comparison);
status = io->remove(selection->bus, selection->device, existing.item_id);
if (status == TRAINLOG_STATUS_OK) {
status = io->rename(selection->bus, selection->device, uploaded, name);
}
return status;
}
if (status != TRAINLOG_STATUS_NOT_FOUND) {
return status;
}
status = io->send(
selection->bus, selection->device, selection->storage, parent, local, name, &uploaded);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
(void)snprintf(comparison, sizeof(comparison), "%s.verify.%ld", local, (long)getpid());
(void)unlink(comparison);
status = io->receive(selection->bus, selection->device, uploaded, comparison);
if (status != TRAINLOG_STATUS_OK || !same_files(local, comparison)) {
(void)unlink(comparison);
(void)io->remove(selection->bus, selection->device, uploaded);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_CONFLICT : status;
}
(void)unlink(comparison);
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus push_folder(const TrainlogGenerationMtpIo *io,
const Selection *selection,
uint32_t parent,
const char *local,
unsigned int depth,
bool manifests) {
DIR *directory;
struct dirent *entry;
TrainlogStatus status = TRAINLOG_STATUS_OK;
if (depth > TRAINLOG_GENERATION_MTP_MAX_DEPTH || (directory = opendir(local)) == NULL) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
while ((entry = readdir(directory)) != NULL && status == TRAINLOG_STATUS_OK) {
char path[1024];
struct stat value;
uint32_t child;
bool created;
if (!safe_leaf(entry->d_name) || entry->d_name[0] == '.') {
continue;
}
if ((strcmp(entry->d_name, "manifest.json") == 0) != manifests) {
continue;
}
int written = snprintf(path, sizeof(path), "%s/%s", local, entry->d_name);
if (written < 0 || (size_t)written >= sizeof(path) || lstat(path, &value) != 0 ||
S_ISLNK(value.st_mode)) {
status = TRAINLOG_STATUS_INVALID_ARGUMENT;
break;
}
if (S_ISDIR(value.st_mode)) {
if (manifests) {
continue;
}
status = io->ensure_folder(selection->bus,
selection->device,
selection->storage,
parent,
entry->d_name,
&child,
&created);
if (status == TRAINLOG_STATUS_OK) {
status = push_folder(io, selection, child, path, depth + 1U, false);
}
if (status == TRAINLOG_STATUS_OK) {
status = push_folder(io, selection, child, path, depth + 1U, true);
}
} else if (S_ISREG(value.st_mode)) {
status = push_file(io, selection, parent, path, entry->d_name);
}
}
(void)closedir(directory);
return status;
}
static TrainlogStatus
validate_local_folder(const char *local, unsigned int depth, uint64_t *total, size_t *objects) {
DIR *directory;
struct dirent *entry;
TrainlogStatus status = TRAINLOG_STATUS_OK;
if (depth > TRAINLOG_GENERATION_MTP_MAX_DEPTH || (directory = opendir(local)) == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
while ((entry = readdir(directory)) != NULL && status == TRAINLOG_STATUS_OK) {
char path[1024];
struct stat value;
int written;
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
continue;
}
if (!safe_leaf(entry->d_name) || entry->d_name[0] == '.') {
status = TRAINLOG_STATUS_INVALID_ARGUMENT;
break;
}
written = snprintf(path, sizeof(path), "%s/%s", local, entry->d_name);
if (written < 0 || (size_t)written >= sizeof(path) || lstat(path, &value) != 0 ||
S_ISLNK(value.st_mode)) {
status = TRAINLOG_STATUS_INVALID_ARGUMENT;
break;
}
if (*objects == TRAINLOG_GENERATION_MTP_MAX_CHILDREN) {
status = TRAINLOG_STATUS_INVALID_ARGUMENT;
break;
}
++*objects;
if (S_ISDIR(value.st_mode)) {
status = validate_local_folder(path, depth + 1U, total, objects);
} else if (S_ISREG(value.st_mode)) {
if (value.st_size < 0 ||
(uint64_t)value.st_size > TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES - *total) {
status = TRAINLOG_STATUS_INVALID_ARGUMENT;
} else {
*total += (uint64_t)value.st_size;
}
} else {
status = TRAINLOG_STATUS_INVALID_ARGUMENT;
}
}
(void)closedir(directory);
return status;
}
TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
const char *expected,
const char *local,
char *diagnostic,
size_t diagnostic_size) {
Selection selection;
uint64_t total = 0U;
size_t objects = 0U;
TrainlogStatus status;
if (io == NULL || expected == NULL || local == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = mkdir_private(local);
if (status == TRAINLOG_STATUS_OK) {
status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size);
}
if (status == TRAINLOG_STATUS_OK) {
status = pull_folder(io, &selection, selection.root, local, 0U, &total, &objects);
}
return status;
}
TrainlogStatus trainlog_generation_mtp_push(const TrainlogGenerationMtpIo *io,
const char *expected,
const char *local,
char *diagnostic,
size_t diagnostic_size) {
Selection selection;
TrainlogStatus status;
uint64_t total = 0U;
size_t objects = 0U;
if (io == NULL || expected == NULL || local == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = validate_local_folder(local, 0U, &total, &objects);
if (status == TRAINLOG_STATUS_OK) {
status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size);
}
if (status == TRAINLOG_STATUS_OK) {
status = push_folder(io, &selection, selection.root, local, 0U, false);
}
if (status == TRAINLOG_STATUS_OK) {
status = push_folder(io, &selection, selection.root, local, 0U, true);
}
return status;
}
static const TrainlogGenerationMtpIo production = {
.devices = trainlog_usb_list_mtp_devices,
.storages = trainlog_mtp_list_storages,
.children = trainlog_mtp_list_folder,
.ensure_folder = trainlog_mtp_ensure_folder,
.receive = trainlog_mtp_receive_file,
.send = trainlog_mtp_send_text_file,
.remove = trainlog_mtp_delete_object,
.rename = trainlog_mtp_rename_object,
};
const TrainlogGenerationMtpIo *trainlog_generation_mtp_production_io(void) {
return &production;
}

View file

@ -5,95 +5,33 @@
#include <utf8proc.h>
static bool append(TrainlogJsonWriter *w, const char *s, size_t n) {
if (w == NULL || s == NULL || w->failed ||
n > w->capacity - (w->size < w->capacity ? w->size : w->capacity) - 1U) {
if (w != NULL) {
w->failed = true;
}
return false;
if (w == NULL || s == NULL || w->failed || n > w->capacity - (w->size < w->capacity ? w->size : w->capacity) - 1U) {
if (w != NULL) w->failed = true; return false;
}
(void)memcpy(w->data + w->size, s, n);
w->size += n;
w->data[w->size] = '\0';
return true;
(void)memcpy(w->data + w->size, s, n); w->size += n; w->data[w->size] = '\0'; return true;
}
void trainlog_json_init(TrainlogJsonWriter *w, char *data, size_t capacity) {
if (w == NULL) {
return;
}
w->data = data;
w->capacity = capacity;
w->size = 0U;
w->failed = data == NULL || capacity == 0U;
if (!w->failed) {
data[0] = '\0';
}
if (w == NULL) return; w->data=data; w->capacity=capacity; w->size=0U; w->failed=data==NULL||capacity==0U; if (!w->failed) data[0]='\0';
}
bool trainlog_json_raw(TrainlogJsonWriter *w, const char *v) {
return v != NULL && append(w, v, strlen(v));
}
bool trainlog_json_string(TrainlogJsonWriter *w, const char *v) {
const unsigned char *p = (const unsigned char *)v;
char escape[7];
if (v == NULL || !append(w, "\"", 1U)) {
return false;
}
while (*p != '\0') {
bool trainlog_json_raw(TrainlogJsonWriter *w,const char *v){return v!=NULL&&append(w,v,strlen(v));}
bool trainlog_json_string(TrainlogJsonWriter *w,const char *v){
const unsigned char *p=(const unsigned char *)v; char escape[7];
if(v==NULL||!append(w,"\"",1U))return false;
while(*p!='\0'){
utf8proc_int32_t codepoint;
utf8proc_ssize_t sequence = utf8proc_iterate(p, -1, &codepoint);
utf8proc_ssize_t sequence=utf8proc_iterate(p,-1,&codepoint);
/* CONTRACT: valid UTF-8 is preserved byte-for-byte; corrupt storage
* must fail serialization instead of producing invalid JSON. */
(void)codepoint;
if (sequence <= 0) {
w->failed = true;
return false;
}
if (*p == '\"' || *p == '\\') {
escape[0] = '\\';
escape[1] = (char)*p;
if (!append(w, escape, 2U)) {
return false;
}
} else if (*p == '\b' || *p == '\f' || *p == '\n' || *p == '\r' || *p == '\t') {
const char codes[] = "bfnrt";
const char chars[] = "\b\f\n\r\t";
size_t i;
escape[0] = '\\';
for (i = 0U; i < 5U && chars[i] != (char)*p; ++i) {
}
escape[1] = codes[i];
if (!append(w, escape, 2U)) {
return false;
}
} else if (*p < 0x20U) {
int n = snprintf(escape, sizeof(escape), "\\u%04x", (unsigned int)*p);
if (n != 6 || !append(w, escape, 6U)) {
return false;
}
} else if (!append(w, (const char *)p, (size_t)sequence)) {
return false;
}
p += (size_t)sequence;
if(sequence<=0){w->failed=true;return false;}
if(*p=='\"'||*p=='\\'){escape[0]='\\';escape[1]=(char)*p;if(!append(w,escape,2U))return false;}
else if(*p=='\b'||*p=='\f'||*p=='\n'||*p=='\r'||*p=='\t'){const char codes[]="bfnrt";const char chars[]="\b\f\n\r\t";size_t i;escape[0]='\\';for(i=0U;i<5U&&chars[i]!=(char)*p;++i){}escape[1]=codes[i];if(!append(w,escape,2U))return false;}
else if(*p<0x20U){int n=snprintf(escape,sizeof(escape),"\\u%04x",(unsigned int)*p);if(n!=6||!append(w,escape,6U))return false;}
else if(!append(w,(const char *)p,(size_t)sequence))return false;
p+=(size_t)sequence;
}
return append(w, "\"", 1U);
}
bool trainlog_json_size(TrainlogJsonWriter *w, size_t v) {
char b[32];
int n = snprintf(b, sizeof(b), "%zu", v);
return n > 0 && (size_t)n < sizeof(b) && append(w, b, (size_t)n);
}
bool trainlog_json_i64(TrainlogJsonWriter *w, long long v) {
char b[32];
int n = snprintf(b, sizeof(b), "%lld", v);
return n > 0 && (size_t)n < sizeof(b) && append(w, b, (size_t)n);
}
bool trainlog_json_double(TrainlogJsonWriter *w, double v) {
char b[64];
int n;
if (!isfinite(v)) {
w->failed = true;
return false;
}
n = snprintf(b, sizeof(b), "%.17g", v);
return n > 0 && (size_t)n < sizeof(b) && append(w, b, (size_t)n);
return append(w,"\"",1U);
}
bool trainlog_json_size(TrainlogJsonWriter*w,size_t v){char b[32];int n=snprintf(b,sizeof(b),"%zu",v);return n>0&&(size_t)n<sizeof(b)&&append(w,b,(size_t)n);}
bool trainlog_json_i64(TrainlogJsonWriter*w,long long v){char b[32];int n=snprintf(b,sizeof(b),"%lld",v);return n>0&&(size_t)n<sizeof(b)&&append(w,b,(size_t)n);}
bool trainlog_json_double(TrainlogJsonWriter*w,double v){char b[64];int n;if(!isfinite(v)){w->failed=true;return false;}n=snprintf(b,sizeof(b),"%.17g",v);return n>0&&(size_t)n<sizeof(b)&&append(w,b,(size_t)n);}

View file

@ -20,8 +20,7 @@ static void request_web_stop(int signal_number)
web_stop_requested = 1;
}
static int run_web(TrainlogDatabase *database, const char *database_path,
uint16_t port)
static int run_web(TrainlogDatabase *database, uint16_t port)
{
struct sigaction action;
struct sigaction previous_interrupt;
@ -43,7 +42,7 @@ static int run_web(TrainlogDatabase *database, const char *database_path,
"trainlog: impossible d'installer les gestionnaires de signaux\n");
return 1;
}
result = trainlog_web_server_run(database, database_path, port, &web_stop_requested,
result = trainlog_web_server_run(database, port, &web_stop_requested,
diagnostic, sizeof(diagnostic));
(void)sigaction(SIGINT, &previous_interrupt, NULL);
(void)sigaction(SIGTERM, &previous_terminate, NULL);
@ -106,7 +105,7 @@ int main(int argc, char *argv[])
return 1;
}
result = options.mode == TRAINLOG_RUN_WEB
? run_web(database, database_path, options.port)
? run_web(database, options.port)
: trainlog_tui_run(database);
trainlog_database_close(database);
return result;

View file

@ -613,58 +613,6 @@ TrainlogStatus trainlog_mtp_send_text_file(
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_ensure_folder(
unsigned int bus_number,
unsigned int device_number,
uint32_t storage_id,
uint32_t parent_folder_id,
const char *folder_name,
uint32_t *output_folder_id,
bool *output_created
)
{
TrainlogMtpEntry entries[256];
size_t count = 0U;
size_t index;
size_t matches = 0U;
LIBMTP_mtpdevice_t *device = NULL;
TrainlogStatus status;
uint32_t created;
char mutable_name[TRAINLOG_MTP_ENTRY_NAME_MAX + 1U];
if (folder_name == NULL || folder_name[0] == '\0' ||
strlen(folder_name) > TRAINLOG_MTP_ENTRY_NAME_MAX ||
output_folder_id == NULL || output_created == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_folder_id = 0U;
*output_created = false;
status = trainlog_mtp_list_folder(bus_number, device_number, storage_id,
parent_folder_id, entries, 256U, &count);
if (status != TRAINLOG_STATUS_OK) return status;
for (index = 0U; index < count; ++index) {
if (entries[index].folder && strcmp(entries[index].name, folder_name) == 0) {
*output_folder_id = entries[index].item_id;
++matches;
}
}
if (matches > 1U) return TRAINLOG_STATUS_CONFLICT;
if (matches == 1U) return TRAINLOG_STATUS_OK;
status = mtp_open_exact_device(bus_number, device_number, &device);
if (status != TRAINLOG_STATUS_OK) return status;
(void)snprintf(mutable_name, sizeof(mutable_name), "%s", folder_name);
created = LIBMTP_Create_Folder(device, mutable_name, parent_folder_id, storage_id);
if (created == 0U) {
LIBMTP_Clear_Errorstack(device);
LIBMTP_Release_Device(device);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
LIBMTP_Release_Device(device);
*output_folder_id = created;
*output_created = true;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_list_folder(
unsigned int bus_number,
unsigned int device_number,
@ -882,35 +830,3 @@ TrainlogStatus trainlog_mtp_delete_object(
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_rename_object(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id,
const char *new_name
)
{
LIBMTP_mtpdevice_t *device = NULL;
LIBMTP_file_t *metadata;
TrainlogStatus status;
int rc;
if (item_id == 0U || new_name == NULL || new_name[0] == '\0' ||
strlen(new_name) > TRAINLOG_MTP_ENTRY_NAME_MAX) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = mtp_open_exact_device(bus_number, device_number, &device);
if (status != TRAINLOG_STATUS_OK) return status;
metadata = LIBMTP_Get_Filemetadata(device, item_id);
if (metadata == NULL) {
LIBMTP_Clear_Errorstack(device); LIBMTP_Release_Device(device);
return TRAINLOG_STATUS_NOT_FOUND;
}
rc = LIBMTP_Set_File_Name(device, metadata, new_name);
LIBMTP_destroy_file_t(metadata);
if (rc != 0) {
LIBMTP_Clear_Errorstack(device); LIBMTP_Release_Device(device);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
LIBMTP_Release_Device(device);
return TRAINLOG_STATUS_OK;
}

View file

@ -16,410 +16,92 @@
#define SECONDS_PER_DAY INT64_C(86400)
static bool copy_column(sqlite3_stmt *s, int column, char *out, size_t cap) {
const unsigned char *text;
int bytes;
if (sqlite3_column_type(s, column) != SQLITE_TEXT) {
return false;
}
text = sqlite3_column_text(s, column);
bytes = sqlite3_column_bytes(s, column);
if (text == NULL || bytes < 0 || (size_t)bytes >= cap) {
return false;
}
(void)memcpy(out, text, (size_t)bytes);
out[bytes] = '\0';
return true;
const unsigned char *text; int bytes;
if (sqlite3_column_type(s,column)!=SQLITE_TEXT) return false;
text=sqlite3_column_text(s,column); bytes=sqlite3_column_bytes(s,column);
if(text==NULL||bytes<0||(size_t)bytes>=cap)return false;
(void)memcpy(out,text,(size_t)bytes);out[bytes]='\0';return true;
}
static bool read_size(sqlite3_stmt *s, int c, size_t *out) {
sqlite3_int64 v;
if (sqlite3_column_type(s, c) != SQLITE_INTEGER) {
return false;
}
v = sqlite3_column_int64(s, c);
if (v < 0 || (uint64_t)v > (uint64_t)SIZE_MAX) {
return false;
}
*out = (size_t)v;
return true;
static bool read_size(sqlite3_stmt*s,int c,size_t*out){sqlite3_int64 v;if(sqlite3_column_type(s,c)!=SQLITE_INTEGER)return false;v=sqlite3_column_int64(s,c);if(v<0||(uint64_t)v>(uint64_t)SIZE_MAX)return false;*out=(size_t)v;return true;}
static bool prepare(TrainlogDatabase*d,const char*sql,sqlite3_stmt**s){return sqlite3_prepare_v2(d->connection,sql,-1,s,NULL)==SQLITE_OK;}
static bool format_generated(int64_t now,char out[TRAINLOG_TIMESTAMP_MAX+1U]){
time_t value=(time_t)now;struct tm utc;if((int64_t)value!=now||gmtime_r(&value,&utc)==NULL)return false;
return strftime(out,TRAINLOG_TIMESTAMP_MAX+1U,"%Y-%m-%dT%H:%M:%SZ",&utc)>0U;
}
static bool prepare(TrainlogDatabase *d, const char *sql, sqlite3_stmt **s) {
return sqlite3_prepare_v2(d->connection, sql, -1, s, NULL) == SQLITE_OK;
static bool format_local_date(time_t value,char out[11]){struct tm local;return localtime_r(&value,&local)!=NULL&&strftime(out,11U,"%Y-%m-%d",&local)==10U;}
static bool load_user(char out[TRAINLOG_NAME_MAX+1U]){
struct passwd value;struct passwd *result=NULL;char buffer[4096];int rc=getpwuid_r(geteuid(),&value,buffer,sizeof(buffer),&result);
if(rc!=0||result==NULL||result->pw_name==NULL||result->pw_name[0]=='\0'||strlen(result->pw_name)>TRAINLOG_NAME_MAX)return false;
(void)snprintf(out,TRAINLOG_NAME_MAX+1U,"%s",result->pw_name);return true;
}
static bool format_generated(int64_t now, char out[TRAINLOG_TIMESTAMP_MAX + 1U]) {
time_t value = (time_t)now;
struct tm utc;
if ((int64_t)value != now || gmtime_r(&value, &utc) == NULL) {
return false;
}
return strftime(out, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
}
static bool format_local_date(time_t value, char out[11]) {
struct tm local;
return localtime_r(&value, &local) != NULL && strftime(out, 11U, "%Y-%m-%d", &local) == 10U;
}
static bool load_user(char out[TRAINLOG_NAME_MAX + 1U]) {
struct passwd value;
struct passwd *result = NULL;
char buffer[4096];
int rc = getpwuid_r(geteuid(), &value, buffer, sizeof(buffer), &result);
if (rc != 0 || result == NULL || result->pw_name == NULL || result->pw_name[0] == '\0' ||
strlen(result->pw_name) > TRAINLOG_NAME_MAX) {
return false;
}
(void)snprintf(out, TRAINLOG_NAME_MAX + 1U, "%s", result->pw_name);
return true;
}
static TrainlogStatus
load_activity(TrainlogDatabase *d, int64_t now, TrainlogWebDashboardSnapshot *out) {
static const char SQL[] =
static TrainlogStatus load_activity(TrainlogDatabase*d,int64_t now,TrainlogWebDashboardSnapshot*out){
static const char SQL[]=
"SELECT substr(s.started_at,1,10),COUNT(DISTINCT s.id),COUNT(ps.id) FROM sessions s "
"LEFT JOIN session_exercises se ON se.session_row_id=s.id LEFT JOIN performed_sets ps ON "
"ps.session_exercise_row_id=se.id "
"WHERE unixepoch(s.started_at)<=?1 AND substr(s.started_at,1,10)>=?2 AND (EXISTS(SELECT 1 "
"FROM session_exercises x JOIN performed_sets p ON p.session_exercise_row_id=x.id WHERE "
"x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN "
"continuous_activity c ON c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR "
"EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON "
"m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id)) GROUP BY "
"substr(s.started_at,1,10) ORDER BY 1;";
sqlite3_stmt *s = NULL;
int step;
size_t i;
time_t reference = (time_t)now;
struct tm current;
if ((int64_t)reference != now) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (localtime_r(&reference, &current) == NULL) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
out->activity_count = TRAINLOG_WEB_ACTIVITY_DAYS;
for (i = 0U; i < TRAINLOG_WEB_ACTIVITY_DAYS; ++i) {
struct tm day = current;
time_t normalized;
day.tm_mday -= (int)(TRAINLOG_WEB_ACTIVITY_DAYS - 1U - i);
day.tm_isdst = -1;
normalized = mktime(&day);
if (normalized == (time_t)-1 || !format_local_date(normalized, out->activity[i].date)) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
}
if (!prepare(d, SQL, &s) || sqlite3_bind_int64(s, 1, now) != SQLITE_OK ||
sqlite3_bind_text(s, 2, out->activity[0].date, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
char date[11];
size_t sessions, sets;
if (!copy_column(s, 0, date, sizeof(date)) || !read_size(s, 1, &sessions) ||
!read_size(s, 2, &sets)) {
goto fail;
}
for (i = 0U; i < out->activity_count; ++i) {
if (strcmp(date, out->activity[i].date) == 0) {
out->activity[i].active = true;
out->activity[i].session_count = sessions;
out->activity[i].set_count = sets;
break;
}
}
}
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
"LEFT JOIN session_exercises se ON se.session_row_id=s.id LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
"WHERE unixepoch(s.started_at)<=?1 AND substr(s.started_at,1,10)>=?2 AND (EXISTS(SELECT 1 FROM session_exercises x JOIN performed_sets p ON p.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN continuous_activity c ON c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id)) GROUP BY substr(s.started_at,1,10) ORDER BY 1;";
sqlite3_stmt*s=NULL;int step;size_t i;time_t reference=(time_t)now;struct tm current;
if((int64_t)reference!=now)return TRAINLOG_STATUS_DATABASE_ERROR;
if(localtime_r(&reference,&current)==NULL)return TRAINLOG_STATUS_DATABASE_ERROR;
out->activity_count=TRAINLOG_WEB_ACTIVITY_DAYS;
for(i=0U;i<TRAINLOG_WEB_ACTIVITY_DAYS;++i){struct tm day=current;time_t normalized;day.tm_mday-=(int)(TRAINLOG_WEB_ACTIVITY_DAYS-1U-i);day.tm_isdst=-1;normalized=mktime(&day);if(normalized==(time_t)-1||!format_local_date(normalized,out->activity[i].date))return TRAINLOG_STATUS_DATABASE_ERROR;}
if(!prepare(d,SQL,&s)||sqlite3_bind_int64(s,1,now)!=SQLITE_OK||sqlite3_bind_text(s,2,out->activity[0].date,-1,SQLITE_TRANSIENT)!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){char date[11];size_t sessions,sets;if(!copy_column(s,0,date,sizeof(date))||!read_size(s,1,&sessions)||!read_size(s,2,&sets))goto fail;for(i=0U;i<out->activity_count;++i)if(strcmp(date,out->activity[i].date)==0){out->activity[i].active=true;out->activity[i].session_count=sessions;out->activity[i].set_count=sets;break;}}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_last(TrainlogDatabase *d, int64_t now, TrainlogWebLastSession *out) {
static const char SESSION_SQL[] =
"SELECT s.session_id,s.started_at,COALESCE(s.ended_at,''),COUNT(DISTINCT CASE WHEN ps.id "
"IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL THEN se.id "
"END),COUNT(DISTINCT ps.id),COUNT(DISTINCT ca.id),COUNT(DISTINCT "
"mr.session_exercise_row_id) FROM sessions s JOIN session_exercises se ON "
"se.session_row_id=s.id LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
"LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id LEFT JOIN "
"max_results mr ON mr.session_exercise_row_id=se.id WHERE unixepoch(s.started_at)<=?1 AND "
"(ps.id IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL) GROUP "
"BY s.id ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC LIMIT 1;";
static const char ZONE_SQL[] =
"SELECT z.zone_id,COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM sessions s JOIN "
"session_exercises se ON se.session_row_id=s.id JOIN exercises e ON "
"e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND "
"z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE "
"s.session_id=?1 AND (EXISTS(SELECT 1 FROM performed_sets p WHERE "
"p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity c WHERE "
"c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results m WHERE "
"m.session_exercise_row_id=se.id)) GROUP BY z.zone_id ORDER BY z.zone_id;";
sqlite3_stmt *s = NULL;
int step;
TrainlogTimestampKey start, end;
if (!prepare(d, SESSION_SQL, &s) || sqlite3_bind_int64(s, 1, now) != SQLITE_OK) {
goto fail;
}
step = sqlite3_step(s);
if (step == SQLITE_DONE) {
(void)sqlite3_finalize(s);
return TRAINLOG_STATUS_OK;
}
if (step != SQLITE_ROW || !copy_column(s, 0, out->session_id, sizeof(out->session_id)) ||
!copy_column(s, 1, out->started_at, sizeof(out->started_at)) ||
!copy_column(s, 2, out->ended_at, sizeof(out->ended_at)) ||
!read_size(s, 3, &out->exercise_count) || !read_size(s, 4, &out->set_count) ||
!read_size(s, 5, &out->continuous_count) || !read_size(s, 6, &out->max_count)) {
goto fail;
}
out->available = true;
out->has_ended_at = out->ended_at[0] != '\0';
if (!trainlog_timestamp_parse(out->started_at, strlen(out->started_at), &start)) {
out->available = false;
goto invalid;
}
if (out->has_ended_at) {
if (!trainlog_timestamp_parse(out->ended_at, strlen(out->ended_at), &end) ||
end.utc_second < start.utc_second) {
goto invalid;
}
out->has_duration = true;
out->duration_seconds = (uint64_t)(end.utc_second - start.utc_second);
}
if (sqlite3_step(s) != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
s = NULL;
if (!prepare(d, ZONE_SQL, &s) ||
sqlite3_bind_text(s, 1, out->session_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
TrainlogWebWorkedZone *z;
const TrainlogBodyZone *catalog;
if (out->zone_count == TRAINLOG_WEB_ZONE_CAPACITY) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
z = &out->zones[out->zone_count];
if (!copy_column(s, 0, z->zone_id, sizeof(z->zone_id)) ||
!read_size(s, 1, &z->occurrence_count) || !read_size(s, 2, &z->set_count)) {
goto fail;
}
z->session_count = 1U;
catalog = trainlog_body_zone_catalog_lookup(z->zone_id);
if (catalog == NULL || strlen(catalog->display_name) > TRAINLOG_NAME_MAX) {
goto invalid;
}
(void)snprintf(z->label, sizeof(z->label), "%s", catalog->display_name);
++out->zone_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
invalid:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_INVALID_ARGUMENT;
fail:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
static TrainlogStatus load_last(TrainlogDatabase*d,int64_t now,TrainlogWebLastSession*out){
static const char SESSION_SQL[]=
"SELECT s.session_id,s.started_at,COALESCE(s.ended_at,''),COUNT(DISTINCT CASE WHEN ps.id IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL THEN se.id END),COUNT(DISTINCT ps.id),COUNT(DISTINCT ca.id),COUNT(DISTINCT mr.session_exercise_row_id) FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE unixepoch(s.started_at)<=?1 AND (ps.id IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL) GROUP BY s.id ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC LIMIT 1;";
static const char ZONE_SQL[]=
"SELECT z.zone_id,COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id JOIN exercises e ON e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE s.session_id=?1 AND (EXISTS(SELECT 1 FROM performed_sets p WHERE p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity c WHERE c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results m WHERE m.session_exercise_row_id=se.id)) GROUP BY z.zone_id ORDER BY z.zone_id;";
sqlite3_stmt*s=NULL;int step;TrainlogTimestampKey start,end;
if(!prepare(d,SESSION_SQL,&s)||sqlite3_bind_int64(s,1,now)!=SQLITE_OK)goto fail;step=sqlite3_step(s);if(step==SQLITE_DONE){(void)sqlite3_finalize(s);return TRAINLOG_STATUS_OK;}if(step!=SQLITE_ROW||!copy_column(s,0,out->session_id,sizeof(out->session_id))||!copy_column(s,1,out->started_at,sizeof(out->started_at))||!copy_column(s,2,out->ended_at,sizeof(out->ended_at))||!read_size(s,3,&out->exercise_count)||!read_size(s,4,&out->set_count)||!read_size(s,5,&out->continuous_count)||!read_size(s,6,&out->max_count))goto fail;
out->available=true;out->has_ended_at=out->ended_at[0]!='\0';
if(!trainlog_timestamp_parse(out->started_at,strlen(out->started_at),&start)){out->available=false;goto invalid;}
if(out->has_ended_at){if(!trainlog_timestamp_parse(out->ended_at,strlen(out->ended_at),&end)||end.utc_second<start.utc_second)goto invalid;out->has_duration=true;out->duration_seconds=(uint64_t)(end.utc_second-start.utc_second);}
if(sqlite3_step(s)!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;s=NULL;
if(!prepare(d,ZONE_SQL,&s)||sqlite3_bind_text(s,1,out->session_id,-1,SQLITE_TRANSIENT)!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){TrainlogWebWorkedZone*z;const TrainlogBodyZone*catalog;if(out->zone_count==TRAINLOG_WEB_ZONE_CAPACITY){return TRAINLOG_STATUS_DATABASE_ERROR;}z=&out->zones[out->zone_count];if(!copy_column(s,0,z->zone_id,sizeof(z->zone_id))||!read_size(s,1,&z->occurrence_count)||!read_size(s,2,&z->set_count))goto fail;z->session_count=1U;catalog=trainlog_body_zone_catalog_lookup(z->zone_id);if(catalog==NULL||strlen(catalog->display_name)>TRAINLOG_NAME_MAX)goto invalid;(void)snprintf(z->label,sizeof(z->label),"%s",catalog->display_name);++out->zone_count;}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
invalid:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_INVALID_ARGUMENT;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus
load_maxima(TrainlogDatabase *d, int64_t now, TrainlogWebDashboardSnapshot *out) {
static const char SQL[] =
"SELECT "
"s.session_id,se.entry_id,e.exercise_id,e.name,COALESCE(se.equipment_id,''),m.max_weight_"
"kg,s.started_at FROM max_results m JOIN session_exercises se ON "
"se.id=m.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises "
"e ON e.id=se.exercise_row_id WHERE unixepoch(s.started_at)<=?1 ORDER BY "
"unixepoch(s.started_at) DESC,s.session_id DESC,se.entry_id DESC LIMIT ?2;";
sqlite3_stmt *s = NULL;
int step;
if (!prepare(d, SQL, &s) || sqlite3_bind_int64(s, 1, now) != SQLITE_OK ||
sqlite3_bind_int64(s, 2, (sqlite3_int64)(TRAINLOG_WEB_MAX_RECORDS + 1U)) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
TrainlogWebMaxRecord *r;
if (out->max_record_count == TRAINLOG_WEB_MAX_RECORDS) {
out->partial = true;
continue;
}
r = &out->max_records[out->max_record_count];
if (!copy_column(s, 0, r->session_id, sizeof(r->session_id)) ||
!copy_column(s, 1, r->entry_id, sizeof(r->entry_id)) ||
!copy_column(s, 2, r->exercise_id, sizeof(r->exercise_id)) ||
!copy_column(s, 3, r->exercise_name, sizeof(r->exercise_name)) ||
!copy_column(s, 4, r->equipment_id, sizeof(r->equipment_id)) ||
!copy_column(s, 6, r->timestamp, sizeof(r->timestamp)) ||
(sqlite3_column_type(s, 5) != SQLITE_FLOAT &&
sqlite3_column_type(s, 5) != SQLITE_INTEGER) ||
!isfinite(sqlite3_column_double(s, 5)) || sqlite3_column_double(s, 5) <= 0.0) {
out->invalid_data = true;
continue;
}
r->weight_kg = sqlite3_column_double(s, 5);
++out->max_record_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
static TrainlogStatus load_maxima(TrainlogDatabase*d,int64_t now,TrainlogWebDashboardSnapshot*out){
static const char SQL[]="SELECT s.session_id,se.entry_id,e.exercise_id,e.name,COALESCE(se.equipment_id,''),m.max_weight_kg,s.started_at FROM max_results m JOIN session_exercises se ON se.id=m.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE unixepoch(s.started_at)<=?1 ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC,se.entry_id DESC LIMIT ?2;";
sqlite3_stmt*s=NULL;int step;if(!prepare(d,SQL,&s)||sqlite3_bind_int64(s,1,now)!=SQLITE_OK||sqlite3_bind_int64(s,2,(sqlite3_int64)(TRAINLOG_WEB_MAX_RECORDS+1U))!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){TrainlogWebMaxRecord*r;if(out->max_record_count==TRAINLOG_WEB_MAX_RECORDS){out->partial=true;continue;}r=&out->max_records[out->max_record_count];if(!copy_column(s,0,r->session_id,sizeof(r->session_id))||!copy_column(s,1,r->entry_id,sizeof(r->entry_id))||!copy_column(s,2,r->exercise_id,sizeof(r->exercise_id))||!copy_column(s,3,r->exercise_name,sizeof(r->exercise_name))||!copy_column(s,4,r->equipment_id,sizeof(r->equipment_id))||!copy_column(s,6,r->timestamp,sizeof(r->timestamp))||(sqlite3_column_type(s,5)!=SQLITE_FLOAT&&sqlite3_column_type(s,5)!=SQLITE_INTEGER)||!isfinite(sqlite3_column_double(s,5))||sqlite3_column_double(s,5)<=0.0){out->invalid_data=true;continue;}r->weight_kg=sqlite3_column_double(s,5);++out->max_record_count;}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus
load_zones(TrainlogDatabase *d, int64_t now, TrainlogWebDashboardSnapshot *out) {
static const char SQL[] =
"SELECT z.zone_id,COUNT(DISTINCT s.id),COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM "
"sessions s JOIN session_exercises se ON se.session_row_id=s.id JOIN exercises e ON "
"e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND "
"z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE "
"substr(s.started_at,1,10)>=?1 AND unixepoch(s.started_at)<=?2 AND (EXISTS(SELECT 1 FROM "
"performed_sets p WHERE p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM "
"continuous_activity c WHERE c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM "
"max_results m WHERE m.session_exercise_row_id=se.id)) GROUP BY z.zone_id ORDER BY "
"z.zone_id;";
sqlite3_stmt *s = NULL;
int step;
if (!prepare(d, SQL, &s) ||
sqlite3_bind_text(
s, 1, out->activity[TRAINLOG_WEB_ACTIVITY_DAYS - 30U].date, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_bind_int64(s, 2, now) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
TrainlogWebWorkedZone *z;
const TrainlogBodyZone *catalog;
if (out->muscle_zone_count == TRAINLOG_WEB_ZONE_CAPACITY) {
out->partial = true;
continue;
}
z = &out->muscle_zones[out->muscle_zone_count];
if (!copy_column(s, 0, z->zone_id, sizeof(z->zone_id)) ||
!read_size(s, 1, &z->session_count) || !read_size(s, 2, &z->occurrence_count) ||
!read_size(s, 3, &z->set_count)) {
goto fail;
}
catalog = trainlog_body_zone_catalog_lookup(z->zone_id);
if (catalog == NULL || strlen(catalog->display_name) > TRAINLOG_NAME_MAX) {
out->invalid_data = true;
continue;
}
(void)snprintf(z->label, sizeof(z->label), "%s", catalog->display_name);
++out->muscle_zone_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
static TrainlogStatus load_zones(TrainlogDatabase*d,int64_t now,TrainlogWebDashboardSnapshot*out){
static const char SQL[]="SELECT z.zone_id,COUNT(DISTINCT s.id),COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id JOIN exercises e ON e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE substr(s.started_at,1,10)>=?1 AND unixepoch(s.started_at)<=?2 AND (EXISTS(SELECT 1 FROM performed_sets p WHERE p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity c WHERE c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results m WHERE m.session_exercise_row_id=se.id)) GROUP BY z.zone_id ORDER BY z.zone_id;";
sqlite3_stmt*s=NULL;int step;if(!prepare(d,SQL,&s)||sqlite3_bind_text(s,1,out->activity[TRAINLOG_WEB_ACTIVITY_DAYS-30U].date,-1,SQLITE_TRANSIENT)!=SQLITE_OK||sqlite3_bind_int64(s,2,now)!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){TrainlogWebWorkedZone*z;const TrainlogBodyZone*catalog;if(out->muscle_zone_count==TRAINLOG_WEB_ZONE_CAPACITY){out->partial=true;continue;}z=&out->muscle_zones[out->muscle_zone_count];if(!copy_column(s,0,z->zone_id,sizeof(z->zone_id))||!read_size(s,1,&z->session_count)||!read_size(s,2,&z->occurrence_count)||!read_size(s,3,&z->set_count))goto fail;catalog=trainlog_body_zone_catalog_lookup(z->zone_id);if(catalog==NULL||strlen(catalog->display_name)>TRAINLOG_NAME_MAX){out->invalid_data=true;continue;}(void)snprintf(z->label,sizeof(z->label),"%s",catalog->display_name);++out->muscle_zone_count;}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus scan_quality(TrainlogDatabase *d, TrainlogWebDashboardSnapshot *out) {
static const char SQL[] =
"SELECT s.started_at,COALESCE(s.ended_at,'') FROM sessions s WHERE EXISTS(SELECT 1 FROM "
"session_exercises x JOIN performed_sets p ON p.session_exercise_row_id=x.id WHERE "
"x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN "
"continuous_activity c ON c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR "
"EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON "
"m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) ORDER BY s.id LIMIT 4097;";
sqlite3_stmt *s = NULL;
int step;
size_t count = 0U;
if (!prepare(d, SQL, &s)) {
goto fail;
}
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
char started[TRAINLOG_TIMESTAMP_MAX + 1U], ended[TRAINLOG_TIMESTAMP_MAX + 1U];
TrainlogTimestampKey start_key, end_key;
++count;
if (count > 4096U) {
out->partial = true;
continue;
}
if (!copy_column(s, 0, started, sizeof(started)) ||
!copy_column(s, 1, ended, sizeof(ended)) ||
!trainlog_timestamp_parse(started, strlen(started), &start_key) ||
(ended[0] != '\0' && (!trainlog_timestamp_parse(ended, strlen(ended), &end_key) ||
trainlog_timestamp_compare(&end_key, &start_key) < 0))) {
out->invalid_data = true;
}
}
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
static TrainlogStatus scan_quality(TrainlogDatabase*d,TrainlogWebDashboardSnapshot*out){
static const char SQL[]="SELECT s.started_at,COALESCE(s.ended_at,'') FROM sessions s WHERE EXISTS(SELECT 1 FROM session_exercises x JOIN performed_sets p ON p.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN continuous_activity c ON c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) ORDER BY s.id LIMIT 4097;";
sqlite3_stmt*s=NULL;int step;size_t count=0U;if(!prepare(d,SQL,&s))goto fail;while((step=sqlite3_step(s))==SQLITE_ROW){char started[TRAINLOG_TIMESTAMP_MAX+1U],ended[TRAINLOG_TIMESTAMP_MAX+1U];TrainlogTimestampKey start_key,end_key;++count;if(count>4096U){out->partial=true;continue;}if(!copy_column(s,0,started,sizeof(started))||!copy_column(s,1,ended,sizeof(ended))||!trainlog_timestamp_parse(started,strlen(started),&start_key)||(ended[0]!='\0'&&(!trainlog_timestamp_parse(ended,strlen(ended),&end_key)||trainlog_timestamp_compare(&end_key,&start_key)<0)))out->invalid_data=true;}if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_web_dashboard_load(TrainlogDatabase *d,
const TrainlogWebDashboardQuery *q,
TrainlogWebDashboardSnapshot *out) {
TrainlogStatus status, end_status;
TrainlogDashboardQuery progression_query;
if (d == NULL || q == NULL || out == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(out, 0, sizeof(*out));
out->next_session_reason = "no_persisted_executable_plan";
out->cardio_reason = "no_cardio_data_source";
if (!format_generated(q->reference_unix_second, out->generated_at) ||
!load_user(out->user_display_name)) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
status = trainlog_database_read_snapshot_begin(d);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = load_activity(d, q->reference_unix_second, out);
if (status == TRAINLOG_STATUS_OK) {
status = scan_quality(d, out);
}
if (status == TRAINLOG_STATUS_OK) {
progression_query.period = TRAINLOG_DASHBOARD_90_DAYS;
progression_query.reference_unix_second = q->reference_unix_second;
status = trainlog_dashboard_load(d, &progression_query, &out->progression);
out->partial = out->partial || out->progression.partial;
out->invalid_data = out->invalid_data || out->progression.invalid_data;
}
if (status == TRAINLOG_STATUS_OK) {
status = load_last(d, q->reference_unix_second, &out->last_session);
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
out->invalid_data = true;
status = TRAINLOG_STATUS_OK;
}
}
if (status == TRAINLOG_STATUS_OK) {
status = load_maxima(d, q->reference_unix_second, out);
}
if (status == TRAINLOG_STATUS_OK) {
status = load_zones(d, q->reference_unix_second, out);
}
end_status = trainlog_database_read_snapshot_end(d, status == TRAINLOG_STATUS_OK);
if (end_status != TRAINLOG_STATUS_OK) {
status = end_status;
}
return status;
TrainlogStatus trainlog_web_dashboard_load(TrainlogDatabase*d,const TrainlogWebDashboardQuery*q,TrainlogWebDashboardSnapshot*out){
TrainlogStatus status,end_status;TrainlogDashboardQuery progression_query;
if(d==NULL||q==NULL||out==NULL)return TRAINLOG_STATUS_INVALID_ARGUMENT;(void)memset(out,0,sizeof(*out));
out->next_session_reason="no_persisted_executable_plan";out->cardio_reason="no_cardio_data_source";
if(!format_generated(q->reference_unix_second,out->generated_at)||!load_user(out->user_display_name))return TRAINLOG_STATUS_DATABASE_ERROR;
status=trainlog_database_read_snapshot_begin(d);if(status!=TRAINLOG_STATUS_OK)return status;
status=load_activity(d,q->reference_unix_second,out);
if(status==TRAINLOG_STATUS_OK)status=scan_quality(d,out);
if(status==TRAINLOG_STATUS_OK){progression_query.period=TRAINLOG_DASHBOARD_90_DAYS;progression_query.reference_unix_second=q->reference_unix_second;status=trainlog_dashboard_load(d,&progression_query,&out->progression);out->partial=out->partial||out->progression.partial;out->invalid_data=out->invalid_data||out->progression.invalid_data;}
if(status==TRAINLOG_STATUS_OK){status=load_last(d,q->reference_unix_second,&out->last_session);if(status==TRAINLOG_STATUS_INVALID_ARGUMENT){out->invalid_data=true;status=TRAINLOG_STATUS_OK;}}
if(status==TRAINLOG_STATUS_OK)status=load_maxima(d,q->reference_unix_second,out);
if(status==TRAINLOG_STATUS_OK)status=load_zones(d,q->reference_unix_second,out);
end_status=trainlog_database_read_snapshot_end(d,status==TRAINLOG_STATUS_OK);if(end_status!=TRAINLOG_STATUS_OK)status=end_status;return status;
}

View file

@ -3,279 +3,26 @@
#include <string.h>
#define RAW(value) \
do { \
if (!trainlog_json_raw(&w, (value))) \
goto overflow; \
} while (0)
#define STR(value) \
do { \
if (!trainlog_json_string(&w, (value))) \
goto overflow; \
} while (0)
#define NUM(value) \
do { \
if (!trainlog_json_size(&w, (value))) \
goto overflow; \
} while (0)
#define RAW(value) do { if (!trainlog_json_raw(&w,(value))) goto overflow; } while (0)
#define STR(value) do { if (!trainlog_json_string(&w,(value))) goto overflow; } while (0)
#define NUM(value) do { if (!trainlog_json_size(&w,(value))) goto overflow; } while (0)
static const char *tracking_name(TrainlogTrackingMode value) {
return value == TRAINLOG_TRACKING_DURATION ? "duration" : "reps";
}
static const char *load_name(TrainlogLoadMode value) {
if (value == TRAINLOG_LOAD_EXTERNAL) {
return "external";
}
if (value == TRAINLOG_LOAD_ASSISTANCE) {
return "assistance";
}
return "none";
}
static bool point_improved(const TrainlogDashboardSnapshot *p, size_t i) {
const TrainlogExercisePerformancePoint *a, *b;
if (i == 0U) {
return false;
}
a = &p->performance[i - 1U];
b = &p->performance[i];
if (!a->has_weight && !b->has_weight) {
return b->metric_value > a->metric_value;
}
if (a->has_weight != b->has_weight) {
return false;
}
if (p->performance_load_mode == TRAINLOG_LOAD_ASSISTANCE) {
return b->weight_kg < a->weight_kg ||
(b->weight_kg == a->weight_kg && b->metric_value > a->metric_value);
}
return b->weight_kg > a->weight_kg ||
(b->weight_kg == a->weight_kg && b->metric_value > a->metric_value);
}
static const char *tracking_name(TrainlogTrackingMode value){return value==TRAINLOG_TRACKING_DURATION?"duration":"reps";}
static const char *load_name(TrainlogLoadMode value){if(value==TRAINLOG_LOAD_EXTERNAL)return "external";if(value==TRAINLOG_LOAD_ASSISTANCE)return "assistance";return "none";}
static bool point_improved(const TrainlogDashboardSnapshot*p,size_t i){const TrainlogExercisePerformancePoint*a,*b;if(i==0U)return false;a=&p->performance[i-1U];b=&p->performance[i];if(!a->has_weight&&!b->has_weight)return b->metric_value>a->metric_value;if(a->has_weight!=b->has_weight)return false;if(p->performance_load_mode==TRAINLOG_LOAD_ASSISTANCE)return b->weight_kg<a->weight_kg||(b->weight_kg==a->weight_kg&&b->metric_value>a->metric_value);return b->weight_kg>a->weight_kg||(b->weight_kg==a->weight_kg&&b->metric_value>a->metric_value);}
static bool write_zone(TrainlogJsonWriter *w0, const TrainlogWebWorkedZone *z) {
TrainlogJsonWriter w = *w0;
RAW("{\"zone_id\":");
STR(z->zone_id);
RAW(",\"label\":");
STR(z->label);
RAW(",\"session_count\":");
NUM(z->session_count);
RAW(",\"occurrence_count\":");
NUM(z->occurrence_count);
RAW(",\"set_count\":");
NUM(z->set_count);
RAW("}");
*w0 = w;
return true;
overflow:
*w0 = w;
return false;
}
static bool write_zone(TrainlogJsonWriter*w0,const TrainlogWebWorkedZone*z){TrainlogJsonWriter w=*w0;RAW("{\"zone_id\":");STR(z->zone_id);RAW(",\"label\":");STR(z->label);RAW(",\"session_count\":");NUM(z->session_count);RAW(",\"occurrence_count\":");NUM(z->occurrence_count);RAW(",\"set_count\":");NUM(z->set_count);RAW("}");*w0=w;return true;overflow:*w0=w;return false;}
TrainlogStatus trainlog_web_dashboard_serialize(const TrainlogWebDashboardSnapshot *s,
char *out,
size_t cap,
size_t *out_size) {
TrainlogJsonWriter w;
size_t i;
if (s == NULL || out == NULL || out_size == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*out_size = 0U;
trainlog_json_init(&w, out, cap);
RAW("{\"api_version\":1,\"data\":{\"footer\":{\"user\":");
STR(s->user_display_name);
RAW(",\"last_session_date\":");
if (s->last_session.available) {
char date[11];
(void)memcpy(date, s->last_session.started_at, 10U);
date[10] = '\0';
STR(date);
} else {
RAW("null");
}
RAW(",\"last_zones\":[");
for (i = 0U; i < s->last_session.zone_count; ++i) {
if (i > 0U) {
RAW(",");
}
STR(s->last_session.zones[i].label);
}
RAW("]},");
RAW("\"next_session\":{\"available\":false,\"reason\":");
STR(s->next_session_reason);
RAW("},");
RAW("\"activity\":{\"available\":true,\"window_days\":90,\"days\":[");
for (i = 0U; i < s->activity_count; ++i) {
const TrainlogWebActivityDay *d = &s->activity[i];
if (i > 0U) {
RAW(",");
}
RAW("{\"date\":");
STR(d->date);
RAW(",\"active\":");
RAW(d->active ? "true" : "false");
RAW(",\"session_count\":");
NUM(d->session_count);
RAW(",\"set_count\":");
NUM(d->set_count);
RAW("}");
}
RAW("]},");
RAW("\"progression\":{");
if (!s->progression.has_performance) {
RAW("\"available\":false,\"reason\":\"no_comparable_performance\"");
} else {
RAW("\"available\":true,\"identity\":{\"exercise_id\":");
STR(s->progression.exercise.exercise_id);
RAW(",\"exercise_name\":");
STR(s->progression.exercise.name);
RAW(",\"equipment_id\":");
STR(s->progression.performance_equipment_id);
RAW(",\"equipment_label\":");
STR(s->progression.performance_equipment_label);
RAW(",\"tracking_mode\":");
STR(tracking_name(s->progression.performance_tracking_mode));
RAW(",\"load_mode\":");
STR(load_name(s->progression.performance_load_mode));
RAW(",\"dose\":");
if (!trainlog_json_i64(&w, (long long)s->progression.performance_dose)) {
goto overflow;
}
RAW("},\"points\":[");
for (i = 0U; i < s->progression.performance_count; ++i) {
const TrainlogExercisePerformancePoint *p = &s->progression.performance[i];
if (i > 0U) {
RAW(",");
}
RAW("{\"session_id\":");
STR(p->session_id);
RAW(",\"timestamp\":");
STR(p->started_at);
RAW(",\"metric_value\":");
if (!trainlog_json_i64(&w, (long long)p->metric_value)) {
goto overflow;
}
RAW(",\"weight_kg\":");
if (p->has_weight) {
if (!trainlog_json_double(&w, p->weight_kg)) {
goto overflow;
}
} else {
RAW("null");
}
RAW(",\"improved\":");
RAW(point_improved(&s->progression, i) ? "true" : "false");
RAW("}");
}
RAW("]");
}
RAW("},");
RAW("\"last_session\":{");
if (!s->last_session.available) {
RAW("\"available\":false,\"reason\":\"no_observable_session\"");
} else {
const TrainlogWebLastSession *l = &s->last_session;
RAW("\"available\":true,\"session_id\":");
STR(l->session_id);
RAW(",\"started_at\":");
STR(l->started_at);
RAW(",\"ended_at\":");
if (l->has_ended_at) {
STR(l->ended_at);
} else {
RAW("null");
}
RAW(",\"duration_seconds\":");
if (l->has_duration) {
if (!trainlog_json_i64(&w, (long long)l->duration_seconds)) {
goto overflow;
}
} else {
RAW("null");
}
RAW(",\"exercise_count\":");
NUM(l->exercise_count);
RAW(",\"set_count\":");
NUM(l->set_count);
RAW(",\"continuous_count\":");
NUM(l->continuous_count);
RAW(",\"max_count\":");
NUM(l->max_count);
RAW(",\"primary_zones\":[");
for (i = 0U; i < l->zone_count; ++i) {
if (i > 0U) {
RAW(",");
}
if (!write_zone(&w, &l->zones[i])) {
goto overflow;
}
}
RAW("]");
}
RAW("},");
RAW("\"max_records\":{\"available\":");
RAW(s->max_record_count > 0U ? "true" : "false");
if (s->max_record_count == 0U) {
RAW(",\"reason\":\"no_explicit_max_results\"");
}
RAW(",\"records\":[");
for (i = 0U; i < s->max_record_count; ++i) {
const TrainlogWebMaxRecord *r = &s->max_records[i];
if (i > 0U) {
RAW(",");
}
RAW("{\"session_id\":");
STR(r->session_id);
RAW(",\"entry_id\":");
STR(r->entry_id);
RAW(",\"exercise_id\":");
STR(r->exercise_id);
RAW(",\"exercise_name\":");
STR(r->exercise_name);
RAW(",\"equipment_id\":");
STR(r->equipment_id);
RAW(",\"weight_kg\":");
if (!trainlog_json_double(&w, r->weight_kg)) {
goto overflow;
}
RAW(",\"timestamp\":");
STR(r->timestamp);
RAW("}");
}
RAW("]},");
RAW("\"muscle_distribution\":{\"available\":");
RAW(s->muscle_zone_count > 0U ? "true" : "false");
if (s->muscle_zone_count == 0U) {
RAW(",\"reason\":\"no_worked_primary_zones\"");
}
RAW(",\"window_days\":30,\"primary_zones\":[");
for (i = 0U; i < s->muscle_zone_count; ++i) {
if (i > 0U) {
RAW(",");
}
if (!write_zone(&w, &s->muscle_zones[i])) {
goto overflow;
}
}
RAW("]},");
RAW("\"cardio\":{\"available\":false,\"reason\":");
STR(s->cardio_reason);
RAW("}},\"meta\":{\"partial\":");
RAW(s->partial ? "true" : "false");
RAW(",\"invalid_data\":");
RAW(s->invalid_data ? "true" : "false");
RAW(",\"generated_at\":");
STR(s->generated_at);
RAW("}}\n");
if (w.failed) {
goto overflow;
}
*out_size = w.size;
return TRAINLOG_STATUS_OK;
overflow:
if (cap > 0U) {
out[0] = '\0';
}
return TRAINLOG_STATUS_SYSTEM_ERROR;
TrainlogStatus trainlog_web_dashboard_serialize(const TrainlogWebDashboardSnapshot*s,char*out,size_t cap,size_t*out_size){
TrainlogJsonWriter w;size_t i;if(s==NULL||out==NULL||out_size==NULL)return TRAINLOG_STATUS_INVALID_ARGUMENT;*out_size=0U;trainlog_json_init(&w,out,cap);
RAW("{\"api_version\":1,\"data\":{\"footer\":{\"user\":");STR(s->user_display_name);RAW(",\"last_session_date\":");if(s->last_session.available){char date[11];(void)memcpy(date,s->last_session.started_at,10U);date[10]='\0';STR(date);}else RAW("null");RAW(",\"last_zones\":[");for(i=0U;i<s->last_session.zone_count;++i){if(i>0U)RAW(",");STR(s->last_session.zones[i].label);}RAW("]},");
RAW("\"next_session\":{\"available\":false,\"reason\":");STR(s->next_session_reason);RAW("},");
RAW("\"activity\":{\"available\":true,\"window_days\":90,\"days\":[");for(i=0U;i<s->activity_count;++i){const TrainlogWebActivityDay*d=&s->activity[i];if(i>0U)RAW(",");RAW("{\"date\":");STR(d->date);RAW(",\"active\":");RAW(d->active?"true":"false");RAW(",\"session_count\":");NUM(d->session_count);RAW(",\"set_count\":");NUM(d->set_count);RAW("}");}RAW("]},");
RAW("\"progression\":{");if(!s->progression.has_performance){RAW("\"available\":false,\"reason\":\"no_comparable_performance\"");}else{RAW("\"available\":true,\"identity\":{\"exercise_id\":");STR(s->progression.exercise.exercise_id);RAW(",\"exercise_name\":");STR(s->progression.exercise.name);RAW(",\"equipment_id\":");STR(s->progression.performance_equipment_id);RAW(",\"equipment_label\":");STR(s->progression.performance_equipment_label);RAW(",\"tracking_mode\":");STR(tracking_name(s->progression.performance_tracking_mode));RAW(",\"load_mode\":");STR(load_name(s->progression.performance_load_mode));RAW(",\"dose\":");if(!trainlog_json_i64(&w,(long long)s->progression.performance_dose))goto overflow;RAW("},\"points\":[");for(i=0U;i<s->progression.performance_count;++i){const TrainlogExercisePerformancePoint*p=&s->progression.performance[i];if(i>0U)RAW(",");RAW("{\"session_id\":");STR(p->session_id);RAW(",\"timestamp\":");STR(p->started_at);RAW(",\"metric_value\":");if(!trainlog_json_i64(&w,(long long)p->metric_value))goto overflow;RAW(",\"weight_kg\":");if(p->has_weight){if(!trainlog_json_double(&w,p->weight_kg))goto overflow;}else RAW("null");RAW(",\"improved\":");RAW(point_improved(&s->progression,i)?"true":"false");RAW("}");}RAW("]");}RAW("},");
RAW("\"last_session\":{");if(!s->last_session.available){RAW("\"available\":false,\"reason\":\"no_observable_session\"");}else{const TrainlogWebLastSession*l=&s->last_session;RAW("\"available\":true,\"session_id\":");STR(l->session_id);RAW(",\"started_at\":");STR(l->started_at);RAW(",\"ended_at\":");if(l->has_ended_at)STR(l->ended_at);else RAW("null");RAW(",\"duration_seconds\":");if(l->has_duration){if(!trainlog_json_i64(&w,(long long)l->duration_seconds))goto overflow;}else RAW("null");RAW(",\"exercise_count\":");NUM(l->exercise_count);RAW(",\"set_count\":");NUM(l->set_count);RAW(",\"continuous_count\":");NUM(l->continuous_count);RAW(",\"max_count\":");NUM(l->max_count);RAW(",\"primary_zones\":[");for(i=0U;i<l->zone_count;++i){if(i>0U)RAW(",");if(!write_zone(&w,&l->zones[i]))goto overflow;}RAW("]");}RAW("},");
RAW("\"max_records\":{\"available\":");RAW(s->max_record_count>0U?"true":"false");if(s->max_record_count==0U)RAW(",\"reason\":\"no_explicit_max_results\"");RAW(",\"records\":[");for(i=0U;i<s->max_record_count;++i){const TrainlogWebMaxRecord*r=&s->max_records[i];if(i>0U)RAW(",");RAW("{\"session_id\":");STR(r->session_id);RAW(",\"entry_id\":");STR(r->entry_id);RAW(",\"exercise_id\":");STR(r->exercise_id);RAW(",\"exercise_name\":");STR(r->exercise_name);RAW(",\"equipment_id\":");STR(r->equipment_id);RAW(",\"weight_kg\":");if(!trainlog_json_double(&w,r->weight_kg))goto overflow;RAW(",\"timestamp\":");STR(r->timestamp);RAW("}");}RAW("]},");
RAW("\"muscle_distribution\":{\"available\":");RAW(s->muscle_zone_count>0U?"true":"false");if(s->muscle_zone_count==0U)RAW(",\"reason\":\"no_worked_primary_zones\"");RAW(",\"window_days\":30,\"primary_zones\":[");for(i=0U;i<s->muscle_zone_count;++i){if(i>0U)RAW(",");if(!write_zone(&w,&s->muscle_zones[i]))goto overflow;}RAW("]},");
RAW("\"cardio\":{\"available\":false,\"reason\":");STR(s->cardio_reason);RAW("}},\"meta\":{\"partial\":");RAW(s->partial?"true":"false");RAW(",\"invalid_data\":");RAW(s->invalid_data?"true":"false");RAW(",\"generated_at\":");STR(s->generated_at);RAW("}}\n");
if(w.failed)goto overflow;*out_size=w.size;return TRAINLOG_STATUS_OK;
overflow:if(cap>0U)out[0]='\0';return TRAINLOG_STATUS_SYSTEM_ERROR;
}

File diff suppressed because it is too large Load diff

View file

@ -1,33 +0,0 @@
#ifndef TRAINLOG_TEST_BOUNDED_PATH_H
#define TRAINLOG_TEST_BOUNDED_PATH_H
#include <stdbool.h>
#include <stddef.h>
#include <string.h>
/* CONTRACT: failure leaves output empty and no caller may perform filesystem
* work with a partial path. Lengths include the separator and trailing NUL. */
static bool
trainlog_test_join_path(char *output, size_t capacity, const char *parent, const char *leaf) {
size_t parent_length;
size_t leaf_length;
if (output == NULL || capacity == 0U || parent == NULL || leaf == NULL) {
return false;
}
output[0] = '\0';
parent_length = strlen(parent);
leaf_length = strlen(leaf);
if (leaf_length > SIZE_MAX - 2U || parent_length > SIZE_MAX - leaf_length - 2U ||
parent_length + leaf_length + 2U > capacity) {
return false;
}
(void)memcpy(output, parent, parent_length);
output[parent_length] = '/';
(void)memcpy(output + parent_length + 1U, leaf, leaf_length);
output[parent_length + leaf_length + 1U] = '\0';
return true;
}
#endif

View file

@ -1,319 +0,0 @@
#include <dirent.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "trainlog/generation_mtp.h"
#include "bounded_path.h"
typedef struct Node {
uint32_t id, parent;
bool folder;
char path[1024], name[256];
uint64_t size;
} Node;
static Node nodes[2048];
static size_t node_count;
static const char *remote;
static bool scan_failed;
static uint32_t stable_path_id(const char *path) {
const unsigned char *cursor = (const unsigned char *)path;
uint32_t value = UINT32_C(2166136261);
while (*cursor != '\0') {
value ^= (uint32_t)*cursor++;
value *= UINT32_C(16777619);
}
return value == 0U || value == UINT32_MAX ? value ^ UINT32_C(0x80000000) : value;
}
static uint32_t scan(const char *path, uint32_t parent) {
DIR *d;
struct dirent *e;
uint32_t self;
Node *n;
struct stat st;
if (lstat(path, &st) != 0) {
if (errno != ENOENT) {
scan_failed = true;
}
return 0U;
}
if (node_count >= sizeof(nodes) / sizeof(nodes[0])) {
scan_failed = true;
return 0U;
}
self = stable_path_id(path);
for (size_t index = 0U; index < node_count; index++) {
if (nodes[index].id == self && strcmp(nodes[index].path, path) != 0) {
scan_failed = true;
return 0U;
}
}
n = &nodes[node_count++];
n->id = self;
n->parent = parent;
n->folder = S_ISDIR(st.st_mode);
n->size = (uint64_t)st.st_size;
snprintf(n->path, sizeof(n->path), "%s", path);
const char *slash = strrchr(path, '/');
snprintf(n->name, sizeof(n->name), "%s", slash ? slash + 1 : path);
if (n->folder && (d = opendir(path)) != NULL) {
while ((e = readdir(d))) {
if (strcmp(e->d_name, ".") && strcmp(e->d_name, "..")) {
char child[1024];
if (!trainlog_test_join_path(child, sizeof(child), path, e->d_name)) {
scan_failed = true;
break;
}
scan(child, self);
}
}
closedir(d);
} else if (n->folder && errno != ENOENT) {
scan_failed = true;
}
return self;
}
static bool rebuild(void) {
node_count = 0U;
scan_failed = false;
return scan(remote, UINT32_MAX) != 0U && !scan_failed;
}
static Node *node(uint32_t id) {
size_t i;
for (i = 0; i < node_count; i++) {
if (nodes[i].id == id) {
return &nodes[i];
}
}
return NULL;
}
static TrainlogStatus devices(TrainlogUsbDevice *o, size_t c, size_t *n) {
if (c < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
memset(o, 0, sizeof(*o));
o->bus_number = 1;
o->device_number = 1;
*n = 1;
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus
storages(unsigned int b, unsigned int d, TrainlogMtpStorage *o, size_t c, size_t *n) {
(void)b;
(void)d;
if (c < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
memset(o, 0, sizeof(*o));
o->storage_id = 1;
*n = 1;
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus children(unsigned int b,
unsigned int d,
uint32_t s,
uint32_t p,
TrainlogMtpEntry *o,
size_t c,
size_t *n) {
size_t i, k = 0;
(void)b;
(void)d;
(void)s;
if (!rebuild()) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (p == UINT32_MAX) {
p = nodes[0].id;
}
for (i = 0; i < node_count; i++) {
if (nodes[i].parent == p) {
if (k >= c) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
memset(&o[k], 0, sizeof(o[k]));
o[k].item_id = nodes[i].id;
o[k].parent_id = p;
o[k].storage_id = 1;
o[k].folder = nodes[i].folder;
o[k].size_bytes = nodes[i].size;
snprintf(o[k].name, sizeof(o[k].name), "%s", nodes[i].name);
k++;
}
}
*n = k;
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus ensure(unsigned int b,
unsigned int d,
uint32_t s,
uint32_t p,
const char *name,
uint32_t *id,
bool *created) {
Node *parent;
char path[1024];
(void)b;
(void)d;
(void)s;
if (!rebuild()) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
parent = node(p);
if (!parent) {
return TRAINLOG_STATUS_NOT_FOUND;
}
if (!trainlog_test_join_path(path, sizeof(path), parent->path, name)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (mkdir(path, 0700) == 0) {
*created = true;
} else if (access(path, F_OK) == 0) {
*created = false;
} else {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (!rebuild()) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
size_t i;
for (i = 0; i < node_count; i++) {
if (strcmp(nodes[i].path, path) == 0) {
*id = nodes[i].id;
return TRAINLOG_STATUS_OK;
}
}
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
static int copy(const char *a, const char *b) {
FILE *in = fopen(a, "rb"), *out = fopen(b, "wb");
char buf[8192];
size_t n;
if (!in || !out) {
return -1;
}
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
if (fwrite(buf, 1, n, out) != n) {
return -1;
}
}
return fclose(in) | fclose(out);
}
static TrainlogStatus receive(unsigned int b, unsigned int d, uint32_t id, const char *path) {
Node *n;
(void)b;
(void)d;
if (!rebuild()) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
n = node(id);
return n && copy(n->path, path) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR;
}
static TrainlogStatus send_file(unsigned int b,
unsigned int d,
uint32_t s,
uint32_t p,
const char *local,
const char *name,
uint32_t *id) {
Node *parent;
char path[1024];
(void)b;
(void)d;
(void)s;
if (!rebuild()) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
parent = node(p);
if (!parent) {
return TRAINLOG_STATUS_NOT_FOUND;
}
if (!trainlog_test_join_path(path, sizeof(path), parent->path, name)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (copy(local, path) != 0) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (!rebuild()) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
size_t i;
for (i = 0; i < node_count; i++) {
if (strcmp(nodes[i].path, path) == 0) {
*id = nodes[i].id;
return TRAINLOG_STATUS_OK;
}
}
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
static TrainlogStatus remove_object(unsigned int b, unsigned int d, uint32_t id) {
Node *n;
(void)b;
(void)d;
if (!rebuild()) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
n = node(id);
return n && unlink(n->path) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR;
}
static TrainlogStatus rename_object(unsigned int b, unsigned int d, uint32_t id, const char *name) {
Node *n;
char parent[1024];
char path[1024];
char *slash;
(void)b;
(void)d;
if (!rebuild()) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
n = node(id);
if (!n) {
return TRAINLOG_STATUS_NOT_FOUND;
}
if (strlen(n->path) >= sizeof(parent)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memcpy(parent, n->path, strlen(n->path) + 1U);
slash = strrchr(parent, '/');
if (!slash) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
*slash = '\0';
if (!trainlog_test_join_path(path, sizeof(path), parent, name)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
return rename(n->path, path) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR;
}
static const TrainlogGenerationMtpIo io = {
.devices = devices,
.storages = storages,
.children = children,
.ensure_folder = ensure,
.receive = receive,
.send = send_file,
.remove = remove_object,
.rename = rename_object,
};
int main(int argc, char **argv) {
char diag[1024] = {0};
TrainlogStatus status;
remote = getenv("TRAINLOG_MTP_DOUBLE_ROOT");
if (argc != 4 || !remote) {
return 64;
}
if (strcmp(argv[1], "pull") == 0) {
status = trainlog_generation_mtp_pull(&io, argv[2], argv[3], diag, sizeof(diag));
} else if (strcmp(argv[1], "push") == 0) {
status = trainlog_generation_mtp_push(&io, argv[2], argv[3], diag, sizeof(diag));
} else {
return 64;
}
if (status != TRAINLOG_STATUS_OK) {
fprintf(stderr, "double failed %d %s\n", status, diag);
return 2;
}
return 0;
}

View file

@ -1,47 +0,0 @@
/* Test-only producer fixture through the public desktop persistence API. */
#include <stdio.h>
#include <string.h>
#include "trainlog/database.h"
int main(int argc, char **argv)
{
TrainlogDatabase *database = NULL;
TrainlogSetInput set = { .reps = 9, .has_weight = true, .weight_kg = 32.5 };
TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session;
if (argc != 2 || trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) {
return 2;
}
if (trainlog_database_insert_exercise_profiled(
database, "ex_77777777-7777-4777-8777-777777777777",
"Desktop generation fixture", "desktop generation fixture",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) != TRAINLOG_STATUS_OK) {
trainlog_database_close(database); return 3;
}
(void)memset(&exercise, 0, sizeof(exercise));
(void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s",
"sxe_88888888-8888-4888-8888-888888888888");
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s",
"ex_77777777-7777-4777-8777-777777777777");
exercise.recording_mode = TRAINLOG_RECORDING_SETS;
exercise.tracking_mode = TRAINLOG_TRACKING_REPS;
exercise.sets = &set;
exercise.set_count = 1U;
(void)memset(&session, 0, sizeof(session));
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_99999999-9999-4999-8999-999999999999");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-17T10:00:00+02:00");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s",
"2026-09-17T10:30:00+02:00");
session.exercises = &exercise;
session.exercise_count = 1U;
if (trainlog_database_insert_session(database, &session) != TRAINLOG_STATUS_OK) {
trainlog_database_close(database); return 4;
}
trainlog_database_close(database);
(void)puts("SYNC_GENERATION_FIXTURE=PASS");
return 0;
}

View file

@ -204,8 +204,7 @@ static int migration_preserves_identity_and_history(void)
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK &&
version == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 18);
CHECK(trainlog_database_list_exercise_body_zones(database,
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 2U);

View file

@ -1,328 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "trainlog/generation_mtp.h"
#include "bounded_path.h"
#define CHECK(x) \
do { \
if (!(x)) { \
fprintf(stderr, "CHECK %s:%d: %s\n", __FILE__, __LINE__, #x); \
return 1; \
} \
} while (0)
typedef struct Object {
uint32_t id, parent;
bool folder;
char name[256];
char data[4096];
size_t size;
} Object;
static Object objects[64];
static size_t object_count;
static unsigned int device_count = 1U;
static char send_order[1024];
static void add(uint32_t id, uint32_t parent, bool folder, const char *name, const char *data) {
Object *o = &objects[object_count++];
memset(o, 0, sizeof(*o));
o->id = id;
o->parent = parent;
o->folder = folder;
snprintf(o->name, sizeof(o->name), "%s", name);
if (data) {
o->size = strlen(data);
memcpy(o->data, data, o->size);
}
}
static void reset(void) {
object_count = 0U;
device_count = 1U;
send_order[0] = '\0';
add(1, UINT32_MAX, true, "Documents", NULL);
add(2, 1, true, "Trainlog", NULL);
add(3,
2,
false,
"android-peer-v1.json",
"{\"format\":\"trainlog-sync-peer\",\"version\":1,\"peer_id\":\"peer_11111111-1111-4111-"
"8111-111111111111\",\"capabilities\":[\"generation-manifest-v1\",\"generation-ack-v1\"]}");
}
static TrainlogStatus devices(TrainlogUsbDevice *out, size_t cap, size_t *count) {
size_t i;
if (cap < device_count) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*count = device_count;
for (i = 0; i < device_count; i++) {
memset(&out[i], 0, sizeof(out[i]));
out[i].bus_number = 1U;
out[i].device_number = (unsigned int)i + 1U;
}
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus
storages(unsigned int bus, unsigned int dev, TrainlogMtpStorage *out, size_t cap, size_t *count) {
(void)bus;
if (cap < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
memset(out, 0, sizeof(*out));
out->storage_id = dev;
*count = 1U;
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus children(unsigned int bus,
unsigned int dev,
uint32_t storage,
uint32_t parent,
TrainlogMtpEntry *out,
size_t cap,
size_t *count) {
size_t i, n = 0;
(void)bus;
(void)storage;
for (i = 0; i < object_count; i++) {
if (objects[i].parent == parent && (dev == 1U || objects[i].id <= 3U)) {
if (n >= cap) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
memset(&out[n], 0, sizeof(out[n]));
out[n].item_id = objects[i].id;
out[n].parent_id = parent;
out[n].storage_id = dev;
out[n].folder = objects[i].folder;
out[n].size_bytes = objects[i].size;
snprintf(out[n].name, sizeof(out[n].name), "%s", objects[i].name);
n++;
}
}
*count = n;
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus ensure_folder(unsigned int b,
unsigned int d,
uint32_t s,
uint32_t p,
const char *name,
uint32_t *id,
bool *created) {
size_t i;
(void)b;
(void)d;
(void)s;
for (i = 0; i < object_count; i++) {
if (objects[i].parent == p && objects[i].folder && strcmp(objects[i].name, name) == 0) {
*id = objects[i].id;
*created = false;
return TRAINLOG_STATUS_OK;
}
}
*id = (uint32_t)(100U + object_count);
*created = true;
add(*id, p, true, name, NULL);
return TRAINLOG_STATUS_OK;
}
static Object *find(uint32_t id) {
size_t i;
for (i = 0; i < object_count; i++) {
if (objects[i].id == id) {
return &objects[i];
}
}
return NULL;
}
static TrainlogStatus receive(unsigned int b, unsigned int d, uint32_t id, const char *path) {
FILE *f;
Object *o = find(id);
size_t bytes;
(void)b;
(void)d;
if (!o) {
return TRAINLOG_STATUS_NOT_FOUND;
}
f = fopen(path, "wb");
if (!f) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
bytes = id == 9U ? 3U : o->size;
if (fwrite(o->data, 1, bytes, f) != bytes) {
fclose(f);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return fclose(f) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR;
}
static TrainlogStatus send_file(unsigned int b,
unsigned int d,
uint32_t s,
uint32_t p,
const char *path,
const char *name,
uint32_t *id) {
FILE *f;
Object *o;
long size;
(void)b;
(void)d;
(void)s;
f = fopen(path, "rb");
if (!f) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
fseek(f, 0, SEEK_END);
size = ftell(f);
rewind(f);
if (size < 0 || size > (long)sizeof(objects[0].data)) {
fclose(f);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*id = (uint32_t)(100U + object_count);
add(*id, p, false, name, NULL);
o = &objects[object_count - 1];
o->size = (size_t)size;
if (fread(o->data, 1, o->size, f) != o->size) {
fclose(f);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
fclose(f);
strncat(send_order, name, sizeof(send_order) - strlen(send_order) - 2U);
strncat(send_order, "\n", sizeof(send_order) - strlen(send_order) - 1U);
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus remove_object(unsigned int b, unsigned int d, uint32_t id) {
Object *o = find(id);
(void)b;
(void)d;
if (!o) {
return TRAINLOG_STATUS_NOT_FOUND;
}
o->name[0] = '\0';
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus rename_object(unsigned int b, unsigned int d, uint32_t id, const char *name) {
Object *o = find(id);
(void)b;
(void)d;
if (!o) {
return TRAINLOG_STATUS_NOT_FOUND;
}
snprintf(o->name, sizeof(o->name), "%s", name);
return TRAINLOG_STATUS_OK;
}
static const TrainlogGenerationMtpIo io = {
.devices = devices,
.storages = storages,
.children = children,
.ensure_folder = ensure_folder,
.receive = receive,
.send = send_file,
.remove = remove_object,
.rename = rename_object,
};
static int write_file(const char *path, const char *value) {
FILE *f = fopen(path, "wb");
CHECK(f != NULL);
CHECK(fwrite(value, 1, strlen(value), f) == strlen(value));
CHECK(fclose(f) == 0);
return 0;
}
static int test_pull_and_manifest_last(void) {
char root[] = "/tmp/trainlog-generation-mtp-XXXXXX", path[512], diag[256] = {0};
char *manifest;
reset();
CHECK(mkdtemp(root) != NULL);
add(4, 2, true, "android-objects", NULL);
add(5, 4, true, "generations", NULL);
add(6, 5, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
add(7, 6, false, "payload.json", "payload");
add(8, 6, false, "manifest.json", "manifest");
CHECK(trainlog_generation_mtp_pull(
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_test_join_path(
path,
sizeof(path),
root,
"android-objects/generations/gen_11111111-1111-4111-8111-111111111111/payload.json"));
CHECK(access(path, R_OK) == 0);
CHECK(trainlog_test_join_path(path, sizeof(path), root, "out"));
CHECK(mkdir(path, 0700) == 0);
char gen[512];
CHECK(trainlog_test_join_path(gen, sizeof(gen), path, "generation"));
CHECK(mkdir(gen, 0700) == 0);
char artifact[512];
CHECK(trainlog_test_join_path(artifact, sizeof(artifact), gen, "artifact.json"));
CHECK(write_file(artifact, "a") == 0);
char marker[512];
CHECK(trainlog_test_join_path(marker, sizeof(marker), gen, "manifest.json"));
CHECK(write_file(marker, "m") == 0);
CHECK(trainlog_generation_mtp_push(
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
TRAINLOG_STATUS_OK);
manifest = strstr(send_order, "manifest.json\n");
CHECK(manifest != NULL);
CHECK(strstr(send_order, "artifact.json\n") < manifest);
return 0;
}
static int test_ambiguity_and_truncation(void) {
char root[] = "/tmp/trainlog-generation-mtp-error-XXXXXX", diag[256] = {0}, path[512];
TrainlogStatus status;
Object *peer;
reset();
CHECK(mkdtemp(root) != NULL);
device_count = 2U;
CHECK(trainlog_generation_mtp_pull(
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
TRAINLOG_STATUS_CONFLICT);
reset();
peer = find(3);
CHECK(peer != NULL);
snprintf(peer->data,
sizeof(peer->data),
"not-json containing peer_11111111-1111-4111-8111-111111111111 generation-manifest-v1 "
"generation-ack-v1");
peer->size = strlen(peer->data);
CHECK(trainlog_generation_mtp_pull(
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
TRAINLOG_STATUS_NOT_FOUND);
reset();
add(9, 2, false, "bad.bin", "abc");
find(9)->size = 99U;
status = trainlog_generation_mtp_pull(
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag));
CHECK(status != TRAINLOG_STATUS_OK);
CHECK(trainlog_test_join_path(path, sizeof(path), root, "bad.bin"));
CHECK(access(path, F_OK) != 0);
return 0;
}
static int test_bounded_path_join(void) {
char exact[8];
char too_small[7] = "guard";
char multibyte[16];
char private_root[] = "/tmp/trainlog-path-bound-XXXXXX";
char sentinel[128];
char refused[8] = "guard";
CHECK(trainlog_test_join_path(exact, sizeof(exact), "abc", "def"));
CHECK(strcmp(exact, "abc/def") == 0);
CHECK(!trainlog_test_join_path(too_small, sizeof(too_small), "abc", "def"));
CHECK(too_small[0] == '\0');
CHECK(trainlog_test_join_path(multibyte, sizeof(multibyte), "rép", "é"));
CHECK(strcmp(multibyte, "rép/é") == 0);
CHECK(mkdtemp(private_root) != NULL);
CHECK(trainlog_test_join_path(sentinel, sizeof(sentinel), private_root, "sentinel"));
CHECK(write_file(sentinel, "outside") == 0);
CHECK(!trainlog_test_join_path(refused, sizeof(refused), private_root, "overflow"));
CHECK(refused[0] == '\0');
CHECK(access(sentinel, R_OK) == 0);
CHECK(unlink(sentinel) == 0);
CHECK(rmdir(private_root) == 0);
return 0;
}
int main(void) {
CHECK(test_bounded_path_join() == 0);
CHECK(test_pull_and_manifest_last() == 0);
CHECK(test_ambiguity_and_truncation() == 0);
return 0;
}

View file

@ -67,7 +67,7 @@ static bool run_test(void)
char path[] = "/tmp/trainlog-v16-XXXXXX";
int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL;
bool ok = false;
TrainlogPersistedExerciseDetail old[1];
TrainlogPersistedExerciseDetail old[1] = {{0}};
TrainlogSessionSummary summary;
size_t count = 0U; int version = 0;
TrainlogExercise changed;
@ -75,7 +75,6 @@ static bool run_test(void)
TrainlogSessionExerciseInput input = {0};
TrainlogSessionInput session = {0};
char diagnostic[512] = {0};
(void)memset(old, 0, sizeof(old));
CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK);
@ -85,14 +84,9 @@ static bool run_test(void)
(void)fprintf(stderr, "open diagnostic: %s\n", diagnostic);
CHECK(false);
}
CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK &&
version == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 18);
trainlog_database_close(db); db = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK);
CHECK(scalar(raw, "SELECT user_version FROM pragma_user_version", TRAINLOG_DATABASE_SCHEMA_VERSION));
CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN('sync_note_revisions','sync_note_state','execution_drafts','execution_draft_revisions','execution_draft_finalizations')", 5));
CHECK(scalar(raw, "SELECT COUNT(*) FROM execution_drafts", 0));
CHECK(scalar(raw, "SELECT COUNT(*) FROM execution_draft_finalizations", 0));
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE s.revision_id IS NULL OR s.revision_id<>'pr_legacy_v1' OR s.legacy_seed<>1", 0));
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_revisions r ON r.exercise_row_id=e.id AND r.revision_id='pr_legacy_v1' AND r.legacy_seed=1 WHERE r.revision_id IS NULL", 0));
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode' AND \"notnull\"=1", 1));

View file

@ -1,42 +0,0 @@
#define _POSIX_C_SOURCE 200809L
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "trainlog/database.h"
#define CHECK(x) do { if (!(x)) { fprintf(stderr,"CHECK failed at %s:%d: %s\n",__FILE__,__LINE__,#x); return 1; } } while (0)
static int scalar(sqlite3 *database, const char *sql)
{
sqlite3_stmt *statement=NULL;int value=-1;
if(sqlite3_prepare_v2(database,sql,-1,&statement,NULL)==SQLITE_OK && sqlite3_step(statement)==SQLITE_ROW)value=sqlite3_column_int(statement,0);
(void)sqlite3_finalize(statement);return value;
}
int main(void)
{
char path[]="/tmp/trainlog-schema-v21-XXXXXX";
int fd=mkstemp(path);TrainlogDatabase *database=NULL;sqlite3 *raw=NULL;size_t exercises=0U;
CHECK(fd>=0);CHECK(close(fd)==0);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","Migration sentinel","migration sentinel",
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
CHECK(sqlite3_close(raw)==SQLITE_OK);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(scalar(raw,"PRAGMA user_version")==21);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);
CHECK(sqlite3_close(raw)==SQLITE_OK);CHECK(unlink(path)==0);
return 0;
}

View file

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

Some files were not shown because too many files have changed in this diff Show more