Compare commits
33 commits
77c15172e1
...
56ac1cdfd2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ac1cdfd2 | ||
|
|
e64ea836f7 | ||
|
|
bad79ed926 | ||
|
|
d94ff495e3 | ||
|
|
09ada28c6b | ||
|
|
72c30ba7e0 | ||
|
|
99e6c0b073 | ||
|
|
05c93777d6 | ||
|
|
1552dcac1f | ||
|
|
0018edff26 | ||
|
|
8aa3c20954 | ||
|
|
dd8c91a946 | ||
|
|
cc87f26b98 | ||
|
|
2c5a8be282 | ||
|
|
d29532a735 | ||
|
|
3a740eb95d | ||
|
|
7d29dc83a2 | ||
|
|
7b769076c1 | ||
|
|
6cd6219ac5 | ||
|
|
34c6c0b369 | ||
|
|
86b317c5bd | ||
|
|
dd991830ac | ||
|
|
7b34ca15d8 | ||
|
|
aa8ab7ae69 | ||
|
|
ae4e493253 | ||
|
|
ef435148f8 | ||
|
|
a6fe9be296 | ||
|
|
444508b8b9 | ||
|
|
70cb7a116b | ||
|
|
98cb40749f | ||
|
|
4b8f5b02b8 | ||
|
|
3db84bbfb0 | ||
|
|
99f3f46d13 |
111 changed files with 13081 additions and 1126 deletions
19
.clang-format
Normal file
19
.clang-format
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
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
|
||||
19
AGENTS.md
19
AGENTS.md
|
|
@ -17,6 +17,10 @@ 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.
|
||||
|
||||
|
|
@ -166,7 +170,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 v17 retains the exactly-one durable active-session
|
||||
Android local SQLite schema v20 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
|
||||
|
|
@ -180,7 +184,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 v18 and Android migrations reach v17 without changing
|
||||
desktop migrations reach v21 and Android migrations reach v20 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`
|
||||
|
|
@ -237,11 +241,12 @@ Desktop SQLite schema is versioned with:
|
|||
PRAGMA user_version;
|
||||
```
|
||||
|
||||
The current desktop schema is v18. Schema v9 added an occurrence-owned explicit
|
||||
The current desktop schema is v21. 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. None changes `TRAINLOG_FORMAT_V1`.
|
||||
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
|
||||
generation/ACK ledgers. None changes `TRAINLOG_FORMAT_V1`.
|
||||
|
||||
Every incompatible schema evolution requires an explicit migration and
|
||||
regression coverage.
|
||||
|
|
@ -282,6 +287,12 @@ 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`;
|
||||
|
|
|
|||
113
CHANGELOG.md
113
CHANGELOG.md
|
|
@ -2,6 +2,119 @@
|
|||
|
||||
## 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
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -91,6 +91,16 @@ 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:
|
||||
|
|
@ -308,11 +318,12 @@ not contain cloud credentials and does not run `rclone`.
|
|||
As of 2026-09-17:
|
||||
|
||||
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
||||
- desktop SQLite schema v18 and Android SQLite schema v17;
|
||||
- desktop SQLite schema v21 and Android SQLite schema v20;
|
||||
- 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;
|
||||
- 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;
|
||||
- `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`;
|
||||
|
|
@ -321,9 +332,10 @@ 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_GAP_CONTRACT_V1=CONTRACT_FROZEN /
|
||||
IMPLEMENTATION_NOT_STARTED` and the next cursor remains
|
||||
`TRAINLOG_SYNC_CHARACTERIZATION_V1`.
|
||||
- `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.
|
||||
|
||||
The latest executable result belongs in
|
||||
[current state](docs/current_state.md), not in multiple README narratives.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,16 @@ 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
|
||||
|
|
@ -44,7 +54,7 @@ android {
|
|||
minSdk = 26
|
||||
targetSdk = 36
|
||||
|
||||
versionCode = 3
|
||||
versionCode = trainlogVersionCode
|
||||
versionName = "0.1.2"
|
||||
testInstrumentationRunner =
|
||||
"androidx.test.runner.AndroidJUnitRunner"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,891 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,11 @@ 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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -101,6 +101,13 @@ 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. */
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ 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
|
||||
|
|
@ -275,7 +277,7 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
|
|||
).joinToString(", ")
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val context = LocalContext.current
|
||||
val language = LocalLanguagePresentation.current
|
||||
|
|
@ -298,6 +300,25 @@ fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
|||
}
|
||||
}
|
||||
}
|
||||
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)} : " +
|
||||
|
|
@ -331,5 +352,12 @@ fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
|||
})
|
||||
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")) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ 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
|
||||
|
|
@ -37,9 +41,24 @@ 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 ->
|
||||
|
|
@ -78,6 +97,7 @@ internal fun visibleReceiptStatus(
|
|||
|
||||
@Composable
|
||||
fun SyncScreen(
|
||||
repository: TrainlogRepository,
|
||||
inbox: SyncCatalogInbox,
|
||||
exporter: SyncExporter,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
|
|
@ -315,7 +335,15 @@ fun SyncScreen(
|
|||
syncRunning = true
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
when (val result = publishBundleAndRequest(exporter, requestOutbox)) {
|
||||
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)
|
||||
}
|
||||
is SyncRequestResult.Requested -> {
|
||||
success = true
|
||||
pendingRequestId = result.requestId
|
||||
|
|
|
|||
|
|
@ -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(inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(inbox) { exportSnapshot(); catalogRevision++ }
|
||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
|
||||
}}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@
|
|||
<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>
|
||||
|
|
@ -396,4 +397,13 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@
|
|||
<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>
|
||||
|
|
@ -396,4 +397,13 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(17, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(20, scalar(db, "PRAGMA user_version"))
|
||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(17, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(20, scalar(db, "PRAGMA user_version"))
|
||||
/* v13 intentionally adds the frozen machine identities and
|
||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(17, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(20, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
|||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(17, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(20, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,520 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,9 @@ 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)
|
||||
|
|
@ -507,6 +510,469 @@ 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()
|
||||
|
|
@ -1794,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(17, cursor.getInt(0))
|
||||
assertEquals(20, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -1921,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(17, cursor.getInt(0))
|
||||
assertEquals(20, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
@ -2446,6 +2912,60 @@ 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()
|
||||
|
|
@ -2529,6 +3049,125 @@ 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 ->
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ class SyncBundlePublicationTest {
|
|||
assertTrue(feedback is SaveFeedbackResult.Saved)
|
||||
|
||||
val writesBeforeFirstClick = directory.writeCounts.toMap()
|
||||
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
|
||||
assertTrue(publishBundleAndRequest(repository, 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(exporter, outbox) is SyncRequestResult.Requested)
|
||||
assertTrue(publishBundleAndRequest(repository, 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") })
|
||||
|
|
|
|||
|
|
@ -90,6 +90,15 @@ 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
|
||||
|
|
@ -901,3 +910,57 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
# 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,
|
||||
|
|
@ -414,6 +421,24 @@ 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.
|
||||
|
||||
|
|
@ -468,7 +493,9 @@ silently rendered as unclassified.
|
|||
|
||||
### Desktop
|
||||
|
||||
Desktop SQLite schema v18 is canonical long-term history. Its v15 -> v16
|
||||
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
|
||||
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
|
||||
|
|
@ -814,15 +841,20 @@ schema change.
|
|||
|
||||
## 13. Audited evolution constraints
|
||||
|
||||
Each mutating importer has strict validation and a SQLite transaction. The V2
|
||||
Each standalone 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 and is not one cross-artifact database transaction. A late association
|
||||
manifest in the active V3 path 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. A future batch protocol must be separately versioned rather than
|
||||
retrofitted into frozen formats.
|
||||
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.
|
||||
|
||||
Body-zone concurrency uses an internal canonical-state baseline. Equal states
|
||||
are idempotent; the sole changed side wins; if both local and incoming states
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ 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.
|
||||
|
|
@ -91,11 +96,33 @@ Good:
|
|||
|
||||
## 6. Formatting
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Formatting changes should not be mixed with unrelated semantic changes when avoidable.
|
||||
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.
|
||||
|
||||
## 7. Error handling
|
||||
|
||||
|
|
@ -130,3 +157,7 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,32 @@
|
|||
# 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).
|
||||
|
|
@ -25,9 +50,9 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | schema v18 |
|
||||
| Android SQLite | schema v17 |
|
||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs |
|
||||
| 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 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 |
|
||||
|
|
@ -47,10 +72,19 @@ 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_NOT_STARTED` |
|
||||
| Complete synchronization gap contract | `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS` |
|
||||
| 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.
|
||||
|
|
@ -72,6 +106,13 @@ 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.
|
||||
|
|
@ -86,18 +127,23 @@ raw exchange or protocol format changed for interface language support.
|
|||
- 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.
|
||||
Current snapshots do not provide general tombstones, a common generation or
|
||||
proof that Android durably consumed the desktop publication.
|
||||
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.
|
||||
- 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)
|
||||
documents the future target without implementing it. Mobile V3 still omits
|
||||
remains the authority for the later orchestrator. Mobile V3 still omits
|
||||
`ended_at`, session/occurrence/body-observation notes, the body-observation to
|
||||
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.
|
||||
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.
|
||||
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,
|
||||
|
|
@ -215,6 +261,17 @@ 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.
|
||||
|
|
@ -270,6 +327,33 @@ 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.
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ is a later, separately versioned migration.
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=18
|
||||
DATABASE_SCHEMA_V18=IMPLEMENTED
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=21
|
||||
DATABASE_SCHEMA_V21=IMPLEMENTED
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ PRAGMA user_version;
|
|||
Current value:
|
||||
|
||||
```text
|
||||
18
|
||||
21
|
||||
```
|
||||
|
||||
The independent actual-set loads documented in the current desktop, Android
|
||||
|
|
@ -49,6 +49,35 @@ 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
|
||||
|
|
|
|||
34
docs/design/sync_deployment_readiness_v1_evidence.md
Normal file
34
docs/design/sync_deployment_readiness_v1_evidence.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# 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.
|
||||
|
|
@ -1,5 +1,24 @@
|
|||
# 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
|
||||
|
|
@ -243,17 +262,20 @@ aucun écran de résolution ne sont définis dans ce lot.
|
|||
|
||||
## 8. Génération et manifest
|
||||
|
||||
**[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 :
|
||||
**[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:
|
||||
|
||||
```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",
|
||||
|
|
@ -400,16 +422,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 et champs perdus ;
|
||||
3. `TRAINLOG_SYNC_DATA_LIFECYCLE_V1`: mobile V4, drafts and previously lost fields — `PASS/FROZEN`;
|
||||
4. `TRAINLOG_SYNC_CAUSAL_DELETE_V1` : opérations causales et tombstones ;
|
||||
5. `TRAINLOG_SYNC_GENERATION_ACK_V1` : manifest, staging, publication,
|
||||
consommation, reprise et ack ;
|
||||
5. `TRAINLOG_SYNC_GENERATION_ACK_V1`: manifest, staging, publication,
|
||||
transactional consumption, restart, and ACK — `PASS/FROZEN`;
|
||||
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.
|
||||
|
||||
Chaque lot dépend des précédents et reste borné. Aucun n'est commencé par ce
|
||||
contrat.
|
||||
Each tranche remains bounded by its predecessors. The next implementation
|
||||
cursor is `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`.
|
||||
|
||||
## 15. Statut des preuves
|
||||
|
||||
|
|
@ -443,6 +465,14 @@ 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` ;
|
||||
|
|
|
|||
26
docs/design/sync_mtp_and_android_backup_v1_evidence.md
Normal file
26
docs/design/sync_mtp_and_android_backup_v1_evidence.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# 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.
|
||||
75
docs/design/sync_web_end_to_end_v1_evidence.md
Normal file
75
docs/design/sync_web_end_to_end_v1_evidence.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# 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.
|
||||
|
|
@ -78,6 +78,13 @@ 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.
|
||||
|
|
@ -638,3 +645,69 @@ 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.
|
||||
|
|
|
|||
67
docs/reviews/sync_rollout_v1_deployment.md
Normal file
67
docs/reviews/sync_rollout_v1_deployment.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# 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.
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
# 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).
|
||||
|
|
@ -19,7 +24,7 @@ distinction between measured values and estimates.
|
|||
|
||||
## Current baseline
|
||||
|
||||
Desktop schema v18, Android schema v17, Notcurses, direct
|
||||
Desktop schema v21, Android schema v20, 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
|
||||
|
|
@ -33,19 +38,30 @@ visualizations are implemented; the other four Web routes remain placeholders.
|
|||
## Current cursor
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_DATA_LIFECYCLE_V1
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_WEB_V1_NEXT_CONTRACT
|
||||
```
|
||||
|
||||
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
|
||||
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`
|
||||
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. 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.
|
||||
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.
|
||||
[Contract details](design/sync_gap_contract_v1.md).
|
||||
|
||||
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
|
||||
|
|
@ -123,8 +139,16 @@ 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 lots are complete; no production synchronization
|
||||
implementation lot is started.
|
||||
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.
|
||||
`WEB_DASHBOARD_V1=PASS/FROZEN` remains closed and unchanged.
|
||||
|
||||
### Gym catalog V1
|
||||
|
|
|
|||
|
|
@ -1,8 +1,24 @@
|
|||
# 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 v18 and Android schema v17 retain mobile export V3, readable V1/V2 imports,
|
||||
Desktop schema v21 and Android schema v20 retain mobile export V3, readable V1/V2 imports,
|
||||
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||
silently added to a frozen artifact: stable exercise IDs and canonical names
|
||||
|
|
@ -44,6 +60,7 @@ 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
|
||||
```
|
||||
|
|
@ -59,6 +76,13 @@ 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:
|
||||
|
|
@ -718,12 +742,79 @@ 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. Audited protocol limitations
|
||||
## 18. Staged coherent generations and durable acknowledgement
|
||||
|
||||
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.
|
||||
`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.
|
||||
|
||||
Each mutating importer validates strictly and owns a SQLite transaction. The
|
||||
V2 association companion only corroborates equipment already imported in the
|
||||
|
|
@ -732,9 +823,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. A future atomic-batch
|
||||
design requires a new versioned manifest rather than a semantic change to any
|
||||
published format.
|
||||
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.
|
||||
|
||||
Snapshots carry no exercise, session, body-observation, body-zone-relation, or equipment-definition
|
||||
tombstones. Omission therefore never deletes one of those objects. The only
|
||||
|
|
|
|||
118
docs/sync_rollout_readiness.md
Normal file
118
docs/sync_rollout_readiness.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# 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.
|
||||
226
docs/tests.md
226
docs/tests.md
|
|
@ -1,5 +1,12 @@
|
|||
# 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,
|
||||
|
|
@ -399,6 +406,31 @@ 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
|
||||
|
|
@ -491,6 +523,11 @@ 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
|
||||
|
|
@ -686,14 +723,16 @@ 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Request/receipt evidence remains deliberately bounded: the native test proves
|
||||
desktop correlation, publication, replay handling, and marking order, while
|
||||
|
|
@ -1163,3 +1202,176 @@ 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.
|
||||
|
|
|
|||
208
tests/support/roundtrip_desktop_lifecycle.py
Normal file
208
tests/support/roundtrip_desktop_lifecycle.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
#!/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()
|
||||
16
tests/sync_orchestrator_peer_fixture.py
Normal file
16
tests/sync_orchestrator_peer_fixture.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/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=(",",":")))
|
||||
172
tests/test_causal_delete_exchange.py
Normal file
172
tests/test_causal_delete_exchange.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
#!/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()
|
||||
108
tests/test_sync_deployment_tools.py
Normal file
108
tests/test_sync_deployment_tools.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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()
|
||||
169
tests/test_sync_generation_exchange.py
Normal file
169
tests/test_sync_generation_exchange.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#!/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()
|
||||
137
tests/test_sync_orchestrator.py
Normal file
137
tests/test_sync_orchestrator.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
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()
|
||||
|
|
@ -27,6 +27,13 @@ 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")
|
||||
|
|
|
|||
228
tests/test_web_sync_browser.py
Normal file
228
tests/test_web_sync_browser.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
#!/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()
|
||||
81
tools/android_backup_bridge/BackupBridgeActivity.kt
Normal file
81
tools/android_backup_bridge/BackupBridgeActivity.kt
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
93
tools/android_backup_bridge/BackupBridgeServiceTest.kt
Normal file
93
tools/android_backup_bridge/BackupBridgeServiceTest.kt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
36
tools/backup_trainlog_sqlite.py
Normal file
36
tools/backup_trainlog_sqlite.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/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())
|
||||
112
tools/build_android_backup_bridge.py
Normal file
112
tools/build_android_backup_bridge.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/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()
|
||||
284
tools/causal_delete_exchange.py
Normal file
284
tools/causal_delete_exchange.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
#!/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)
|
||||
296
tools/execution_draft_exchange.py
Normal file
296
tools/execution_draft_exchange.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
#!/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)
|
||||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21)
|
||||
MEASUREMENT_FIELDS = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] != 18:
|
||||
raise ValueError("schema desktop v18 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21):
|
||||
raise ValueError("schema desktop v18-v21 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
def main(complete_causal_envelope=False):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", type=Path, default=default_database())
|
||||
|
|
@ -24,8 +24,10 @@ def main():
|
|||
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):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
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")]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
||||
raise ValueError("schema desktop v12-v16 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
def main(complete_causal_envelope=False):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", type=Path, default=default_database())
|
||||
|
|
@ -41,8 +41,10 @@ def main():
|
|||
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):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
||||
raise ValueError("schema desktop v11-v16 requis")
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
if version not in (17, 18, 19, 20, 21):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -59,18 +59,37 @@ def validate_v3_timestamp(value, label):
|
|||
raise ValueError(f"{label}: date-heure Trainlog persistée invalide") from error
|
||||
|
||||
|
||||
def main():
|
||||
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):
|
||||
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), default=3)
|
||||
parser.add_argument("--version", type=int, choices=(2, 3, 4), 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 schema_version not in (11, 12, 13, 14, 15, 16, 17, 18) and not (args.version == 2 and schema_version == 10):
|
||||
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):
|
||||
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(
|
||||
|
|
@ -82,18 +101,25 @@ def main():
|
|||
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 FROM sessions ORDER BY started_at,id"):
|
||||
for session in con.execute("SELECT id,session_id,started_at,session_type,ended_at,notes 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 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,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"
|
||||
for entry in con.execute(sql, (session["id"],)):
|
||||
validate_plan(entry)
|
||||
if entry["recording_mode"] == "continuous" or entry["max_weight_kg"] is not None:
|
||||
|
|
@ -118,8 +144,8 @@ def main():
|
|||
"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:
|
||||
|
|
@ -134,6 +160,16 @@ def main():
|
|||
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.
|
||||
|
|
@ -155,15 +191,18 @@ def main():
|
|||
"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(("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:
|
||||
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:
|
||||
# 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")
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ from pathlib import Path
|
|||
|
||||
def instant(value): return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
def main():
|
||||
def main(complete_causal_envelope=False):
|
||||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18): raise ValueError("schema desktop v15-v18 requis")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21): raise ValueError("schema desktop v15-v21 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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] != 18:
|
||||
raise ImportFailure("schema desktop v18 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21):
|
||||
raise ImportFailure("schema desktop v18-v21 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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
or payload.get("version") not in (2, 3, 4)
|
||||
or not isinstance(payload.get("sessions"), list)
|
||||
):
|
||||
fail("snapshot mobile V2 de preuve invalide")
|
||||
|
|
@ -117,6 +117,24 @@ 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."""
|
||||
|
|
@ -167,18 +185,10 @@ 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):
|
||||
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)
|
||||
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")
|
||||
proof_path = args.mobile_export
|
||||
if proof_path is None:
|
||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||
|
|
@ -189,41 +199,9 @@ def main():
|
|||
if proof_path is not None
|
||||
else {}
|
||||
)
|
||||
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}")
|
||||
association_count = apply_associations(connection, payload, load_catalog(args.catalog), mobile_occurrences)
|
||||
print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS")
|
||||
print(f"associations={len(associations)}")
|
||||
print(f"associations={association_count}")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,23 @@ 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)
|
||||
|
|
@ -61,26 +78,8 @@ 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:
|
||||
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
|
||||
imported, skipped = apply_definitions(connection, definitions)
|
||||
print("EQUIPMENT_DEFINITIONS_IMPORT=PASS")
|
||||
print(f"definitions_imported={imported}")
|
||||
print(f"definitions_skipped={skipped}")
|
||||
|
|
|
|||
|
|
@ -54,6 +54,35 @@ 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)
|
||||
|
|
@ -63,35 +92,8 @@ 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")
|
||||
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))
|
||||
apply_aliases(con, aliases)
|
||||
con.commit()
|
||||
except Exception:
|
||||
con.rollback(); raise
|
||||
|
|
|
|||
|
|
@ -149,6 +149,57 @@ 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)
|
||||
|
|
@ -159,42 +210,8 @@ 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"))
|
||||
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)))
|
||||
parsed = parse_payload(payload)
|
||||
|
||||
# A V2 snapshot is reconciliation evidence only when the caller selected
|
||||
# and supplied that exact snapshot. Never discover a sibling implicitly:
|
||||
|
|
@ -203,68 +220,9 @@ 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")
|
||||
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),
|
||||
)
|
||||
updated,skipped,kept_local=apply_body_zones(connection,parsed,mobile_proof)
|
||||
connection.commit()
|
||||
print("EXERCISE_BODY_ZONES_IMPORT=PASS")
|
||||
print(f"zones_updated={updated}")
|
||||
|
|
|
|||
|
|
@ -46,22 +46,18 @@ 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 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"))
|
||||
def apply_profile_state(con, root, allow_pending=False):
|
||||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at")
|
||||
con=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")
|
||||
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()
|
||||
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 a.allow_pending or a.mobile_export is not None: pending+=1;continue
|
||||
if allow_pending: 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"])
|
||||
|
|
@ -90,6 +86,14 @@ def main():
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import os
|
|||
import sqlite3
|
||||
import sys
|
||||
import unicodedata
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from validate_json import TrainlogSemanticError, parse_timestamp
|
||||
|
|
@ -48,6 +49,7 @@ SESSION_KEYS = {
|
|||
"session_type",
|
||||
"exercises",
|
||||
}
|
||||
V4_SESSION_KEYS = SESSION_KEYS | {"ended_at", "note"}
|
||||
|
||||
SESSION_EXERCISE_KEYS = {
|
||||
"exercise_id",
|
||||
|
|
@ -65,11 +67,13 @@ 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",
|
||||
|
|
@ -255,14 +259,14 @@ def load_payload(path):
|
|||
"format mobile export invalide"
|
||||
)
|
||||
|
||||
if payload["version"] not in (1, 2, 3):
|
||||
if payload["version"] not in (1, 2, 3, 4):
|
||||
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")
|
||||
|
|
@ -447,6 +451,24 @@ 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,
|
||||
|
|
@ -455,7 +477,7 @@ def validate_session_exercise(
|
|||
version,
|
||||
):
|
||||
is_v2 = version >= 2
|
||||
keys = V3_SESSION_EXERCISE_KEYS if version == 3 else (
|
||||
keys = V4_SESSION_EXERCISE_KEYS if version == 4 else V3_SESSION_EXERCISE_KEYS if version == 3 else (
|
||||
V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS)
|
||||
require_exact_keys(
|
||||
item,
|
||||
|
|
@ -464,6 +486,8 @@ 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")
|
||||
|
|
@ -650,12 +674,8 @@ def validate_sessions(
|
|||
):
|
||||
label = f"sessions[{index}]"
|
||||
|
||||
require_exact_keys(
|
||||
session,
|
||||
SESSION_KEYS,
|
||||
SESSION_KEYS,
|
||||
label,
|
||||
)
|
||||
session_keys = V4_SESSION_KEYS if payload["version"] == 4 else SESSION_KEYS
|
||||
require_exact_keys(session, session_keys, session_keys, label)
|
||||
|
||||
session_id = require_nonempty_string(
|
||||
session["session_id"],
|
||||
|
|
@ -669,13 +689,21 @@ 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",
|
||||
|
|
@ -748,18 +776,16 @@ 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}]"
|
||||
|
||||
require_exact_keys(
|
||||
observation,
|
||||
allowed,
|
||||
BODY_BASE_KEYS,
|
||||
label,
|
||||
)
|
||||
required = BODY_BASE_KEYS | ({"session_id", "note"} if payload["version"] == 4 else set())
|
||||
require_exact_keys(observation, allowed, required, label)
|
||||
|
||||
observation_id = require_nonempty_string(
|
||||
observation["observation_id"],
|
||||
|
|
@ -773,10 +799,14 @@ 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())
|
||||
|
|
@ -816,9 +846,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v18 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v21 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1268,6 +1298,68 @@ 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,
|
||||
|
|
@ -1348,6 +1440,7 @@ def import_set_session_exercise(
|
|||
duration, set_item.get("weight_kg"),
|
||||
),
|
||||
)
|
||||
return session_exercise_row_id
|
||||
|
||||
def import_continuous_session_exercise(
|
||||
connection,
|
||||
|
|
@ -1413,6 +1506,7 @@ def import_continuous_session_exercise(
|
|||
continuous.get("distance_km"),
|
||||
),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def import_max_session_exercise(
|
||||
|
|
@ -1452,6 +1546,7 @@ def import_max_session_exercise(
|
|||
"VALUES(?,?);",
|
||||
(cursor.lastrowid, item["max_weight_kg"]),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def import_sessions(
|
||||
|
|
@ -1491,12 +1586,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 FROM sessions WHERE id=?;",
|
||||
"SELECT started_at,session_type,ended_at FROM sessions WHERE id=?;",
|
||||
(session_row_id,),
|
||||
).fetchone()
|
||||
resumable_max = (
|
||||
header is not None
|
||||
and tuple(header) == (session["started_at"], "max_test")
|
||||
and tuple(header[:2]) == (session["started_at"], "max_test")
|
||||
and session["session_type"] == "max_test"
|
||||
and len(incoming) >= len(current)
|
||||
and all(current[index] == incoming[index]
|
||||
|
|
@ -1504,9 +1599,9 @@ def import_sessions(
|
|||
)
|
||||
incoming_by_id = dict(incoming)
|
||||
correction = (
|
||||
payload["version"] == 3
|
||||
payload["version"] >= 3
|
||||
and header is not None
|
||||
and tuple(header) == (session["started_at"], session["session_type"])
|
||||
and tuple(header[:2]) == (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
|
||||
|
|
@ -1524,7 +1619,8 @@ 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):
|
||||
|
|
@ -1553,6 +1649,15 @@ 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(
|
||||
|
|
@ -1563,17 +1668,30 @@ def import_sessions(
|
|||
ended_at,
|
||||
session_type,
|
||||
notes
|
||||
) VALUES(?, ?, NULL, ?, NULL);
|
||||
) VALUES(?, ?, ?, ?, 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"]
|
||||
):
|
||||
|
|
@ -1616,7 +1734,7 @@ def import_sessions(
|
|||
)
|
||||
|
||||
if "max_weight_kg" in item:
|
||||
import_max_session_exercise(
|
||||
occurrence_row_id = import_max_session_exercise(
|
||||
connection,
|
||||
session_row_id,
|
||||
position,
|
||||
|
|
@ -1624,7 +1742,7 @@ def import_sessions(
|
|||
row_id,
|
||||
)
|
||||
elif item["recording_mode"] == "continuous":
|
||||
import_continuous_session_exercise(
|
||||
occurrence_row_id = import_continuous_session_exercise(
|
||||
connection,
|
||||
session_row_id,
|
||||
position,
|
||||
|
|
@ -1632,13 +1750,16 @@ def import_sessions(
|
|||
row_id,
|
||||
)
|
||||
else:
|
||||
import_set_session_exercise(
|
||||
occurrence_row_id = 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:
|
||||
|
|
@ -1784,13 +1905,29 @@ 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 observed_at,{','.join(metric_order)} FROM body_observations WHERE observation_id=?",
|
||||
f"SELECT id,observed_at,{','.join(metric_order)},session_row_id 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) != expected:
|
||||
if list(existing[1:1 + len(expected)]) != 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
|
||||
|
||||
|
|
@ -1809,26 +1946,22 @@ 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 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
|
||||
)
|
||||
|
||||
def apply_payload(connection, payload, trace_exercises=False):
|
||||
"""Apply a validated artifact without owning commit or rollback."""
|
||||
connection.row_factory = sqlite3.Row
|
||||
|
||||
report = {
|
||||
"exercises_imported": 0,
|
||||
"exercises_reconciled": 0,
|
||||
|
|
@ -1840,58 +1973,43 @@ def run_import(
|
|||
"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(
|
||||
"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,
|
||||
)
|
||||
connection.execute("BEGIN IMMEDIATE;")
|
||||
report = apply_payload(connection, payload, trace_exercises)
|
||||
|
||||
if dry_run:
|
||||
connection.rollback()
|
||||
|
|
|
|||
|
|
@ -42,18 +42,20 @@ 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
|
||||
elif old==expected:skipped+=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
|
||||
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 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)
|
||||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
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"]):
|
||||
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"]):
|
||||
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"]
|
||||
|
|
@ -64,11 +66,11 @@ def main():
|
|||
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 old==expected:roots_skipped+=1
|
||||
elif tuple(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"]
|
||||
|
|
@ -77,9 +79,16 @@ def main():
|
|||
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 old==expected:roots_skipped+=1
|
||||
elif tuple(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()
|
||||
|
|
|
|||
147
tools/package_sync_candidate.py
Normal file
147
tools/package_sync_candidate.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
#!/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())
|
||||
589
tools/sync_generation_exchange.py
Normal file
589
tools/sync_generation_exchange.py
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
#!/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)
|
||||
430
tools/sync_orchestrator.py
Normal file
430
tools/sync_orchestrator.py
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
#!/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())
|
||||
303
tools/sync_peer_worker.py
Normal file
303
tools/sync_peer_worker.py
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
#!/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
|
||||
|
|
@ -375,7 +375,7 @@ def commands_for_suite(repository: Path, suite: str) -> list[tuple[str, list[str
|
|||
return smoke
|
||||
if suite == "preflight":
|
||||
return []
|
||||
return [
|
||||
commands = [
|
||||
("desktop-compile", ["meson", "compile", "-C", "build"], repository),
|
||||
(
|
||||
"desktop-tests",
|
||||
|
|
@ -423,6 +423,11 @@ 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:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 18
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 21
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
44
tui/include/trainlog/generation_mtp.h
Normal file
44
tui/include/trainlog/generation_mtp.h
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#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
|
||||
|
|
@ -59,6 +59,23 @@ 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.
|
||||
*/
|
||||
|
|
@ -116,4 +133,11 @@ 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
|
||||
|
|
|
|||
|
|
@ -2,16 +2,25 @@
|
|||
#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, uint16_t port,
|
||||
const volatile sig_atomic_t *stop_requested,
|
||||
char *diagnostic, size_t diagnostic_capacity);
|
||||
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);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -47,12 +47,16 @@ 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',
|
||||
|
|
@ -194,6 +198,7 @@ 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',
|
||||
|
|
@ -219,6 +224,7 @@ trainlog_core = static_library(
|
|||
m_dep,
|
||||
udev_dep,
|
||||
mtp_dep,
|
||||
yyjson_dep,
|
||||
],
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
|
|
@ -233,9 +239,16 @@ 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',
|
||||
|
|
@ -542,6 +555,13 @@ 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',
|
||||
|
|
@ -554,6 +574,21 @@ 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',
|
||||
|
|
@ -713,6 +748,31 @@ 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',
|
||||
|
|
|
|||
|
|
@ -27,109 +27,121 @@ 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);
|
||||
|
|
@ -141,116 +153,184 @@ TrainlogDashboardLayoutResult trainlog_dashboard_layout_parse(
|
|||
!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(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(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);
|
||||
|
|
@ -262,45 +342,67 @@ TrainlogDashboardLayoutResult trainlog_dashboard_layout_load(
|
|||
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(¤t, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK)
|
||||
}
|
||||
if (trainlog_dashboard_layout_load(¤t, &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
|
||||
|
|
@ -309,16 +411,21 @@ TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(
|
|||
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(¤t, &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(¤t, &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;
|
||||
|
|
|
|||
|
|
@ -804,6 +804,72 @@ 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. */
|
||||
|
|
@ -1392,7 +1458,9 @@ 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 ORDER BY f.observed_at COLLATE BINARY,f.feedback_id COLLATE BINARY",
|
||||
"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",
|
||||
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);
|
||||
|
|
@ -1496,7 +1564,8 @@ 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 == 15 || version == 16 || version == 17 || version == 18 ||
|
||||
version == 19 || version == 20 || version == 21) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -1660,6 +1729,15 @@ 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);
|
||||
}
|
||||
|
|
@ -1675,7 +1753,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
set_open_diagnostic(
|
||||
output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v18" : "migrate database to schema v18",
|
||||
version == 0 ? "create schema v21" : "migrate database to schema v21",
|
||||
database->connection,
|
||||
SQLITE_ERROR
|
||||
);
|
||||
|
|
@ -1906,7 +1984,9 @@ 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 ORDER BY display_name COLLATE NOCASE,equipment_id;",
|
||||
"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;",
|
||||
-1, &statement, NULL);
|
||||
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
|
|
@ -1952,7 +2032,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 ORDER BY display_name COLLATE NOCASE,equipment_id "
|
||||
"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 "
|
||||
"LIMIT ?1 OFFSET ?2;";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
size_t count = 0U;
|
||||
|
|
@ -3095,7 +3175,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 ORDER BY name COLLATE NOCASE,exercise_id;";
|
||||
"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;";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
size_t count = 0U;
|
||||
size_t prefix_length;
|
||||
|
|
@ -4293,6 +4373,26 @@ 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) {
|
||||
|
|
|
|||
515
tui/src/generation_mtp.c
Normal file
515
tui/src/generation_mtp.c
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -5,33 +5,95 @@
|
|||
#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);
|
||||
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);
|
||||
}
|
||||
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);}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ static void request_web_stop(int signal_number)
|
|||
web_stop_requested = 1;
|
||||
}
|
||||
|
||||
static int run_web(TrainlogDatabase *database, uint16_t port)
|
||||
static int run_web(TrainlogDatabase *database, const char *database_path,
|
||||
uint16_t port)
|
||||
{
|
||||
struct sigaction action;
|
||||
struct sigaction previous_interrupt;
|
||||
|
|
@ -42,7 +43,7 @@ static int run_web(TrainlogDatabase *database, uint16_t port)
|
|||
"trainlog: impossible d'installer les gestionnaires de signaux\n");
|
||||
return 1;
|
||||
}
|
||||
result = trainlog_web_server_run(database, port, &web_stop_requested,
|
||||
result = trainlog_web_server_run(database, database_path, port, &web_stop_requested,
|
||||
diagnostic, sizeof(diagnostic));
|
||||
(void)sigaction(SIGINT, &previous_interrupt, NULL);
|
||||
(void)sigaction(SIGTERM, &previous_terminate, NULL);
|
||||
|
|
@ -105,7 +106,7 @@ int main(int argc, char *argv[])
|
|||
return 1;
|
||||
}
|
||||
result = options.mode == TRAINLOG_RUN_WEB
|
||||
? run_web(database, options.port)
|
||||
? run_web(database, database_path, options.port)
|
||||
: trainlog_tui_run(database);
|
||||
trainlog_database_close(database);
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -613,6 +613,58 @@ 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,
|
||||
|
|
@ -830,3 +882,35 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,92 +16,410 @@
|
|||
#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 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 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 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 prepare(TrainlogDatabase *d, const char *sql, sqlite3_stmt **s) {
|
||||
return sqlite3_prepare_v2(d->connection, sql, -1, s, NULL) == SQLITE_OK;
|
||||
}
|
||||
|
||||
static TrainlogStatus load_activity(TrainlogDatabase*d,int64_t now,TrainlogWebDashboardSnapshot*out){
|
||||
static const char SQL[]=
|
||||
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[] =
|
||||
"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,¤t)==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, ¤t) == 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,26 +3,279 @@
|
|||
|
||||
#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 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;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
1032
tui/src/web_server.c
1032
tui/src/web_server.c
File diff suppressed because it is too large
Load diff
33
tui/tests/bounded_path.h
Normal file
33
tui/tests/bounded_path.h
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#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
|
||||
319
tui/tests/generation_mtp_adapter_double.c
Normal file
319
tui/tests/generation_mtp_adapter_double.c
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
#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;
|
||||
}
|
||||
47
tui/tests/sync_generation_fixture.c
Normal file
47
tui/tests/sync_generation_fixture.c
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* 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;
|
||||
}
|
||||
|
|
@ -204,7 +204,8 @@ 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 == 18);
|
||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK &&
|
||||
version == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
CHECK(trainlog_database_list_exercise_body_zones(database,
|
||||
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 2U);
|
||||
|
|
|
|||
328
tui/tests/test_generation_mtp.c
Normal file
328
tui/tests/test_generation_mtp.c
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -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] = {{0}};
|
||||
TrainlogPersistedExerciseDetail old[1];
|
||||
TrainlogSessionSummary summary;
|
||||
size_t count = 0U; int version = 0;
|
||||
TrainlogExercise changed;
|
||||
|
|
@ -75,6 +75,7 @@ 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);
|
||||
|
|
@ -84,9 +85,14 @@ 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 == 18);
|
||||
CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK &&
|
||||
version == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
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));
|
||||
|
|
|
|||
42
tui/tests/test_schema_v21_migration.c
Normal file
42
tui/tests/test_schema_v21_migration.c
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -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 v18: SQLite rc=",
|
||||
strlen("migrate database to schema v18: SQLite rc=")) == 0);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v21: SQLite rc=",
|
||||
strlen("migrate database to schema v21: 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=19;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=22;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 19 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 22 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
Loading…
Reference in a new issue