Add durable program session execution flow
This commit is contained in:
parent
db722588f8
commit
949ace6301
71 changed files with 1126 additions and 86 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
## Unreleased — 0.1.2
|
||||
|
||||
- Added the Program execution flow. Android schema v25 can start or resume one
|
||||
Program session through the existing durable singleton, preserves stable
|
||||
Program provenance on completion, and presents explicit `À faire`, `En cours`
|
||||
and `Effectuée` states. Desktop schema v27 stores the corresponding unique
|
||||
execution link. The optional `trainlog-program-executions` V1 full-generation
|
||||
companion advances Web Program sessions from persisted facts without changing
|
||||
mobile export V3, `programs-v1`, or `TRAINLOG_FORMAT_V1`. Web also exposes
|
||||
`À faire`, `Préparée`, `En cours`, and `Effectuée` session states, while the
|
||||
existing logical Program deletion and confirmation semantics remain intact.
|
||||
|
||||
- Completed the private coordinated deployment of
|
||||
`TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1`. Desktop schema v26 adds
|
||||
terminal logical Program deletion with
|
||||
|
|
|
|||
|
|
@ -320,7 +320,7 @@ not contain cloud credentials and does not run `rclone`.
|
|||
As of 2026-09-18:
|
||||
|
||||
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
||||
- desktop SQLite schema v26 and Android SQLite schema v24;
|
||||
- desktop SQLite schema v27 and Android SQLite schema v25;
|
||||
- Notcurses is the only active desktop terminal backend;
|
||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||
all-files access setting;
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) {
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..24
|
||||
manifest.getInt("schema") in 17..25
|
||||
) {
|
||||
"Unsupported backup."
|
||||
}
|
||||
|
|
@ -313,7 +313,7 @@ internal class AndroidBackupService(private val context: Context) {
|
|||
}
|
||||
)
|
||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||
require(db.version in 17..24) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..25) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
"feedback" to Kind("trainlog-training-feedback", 2, "feedback-v2.json"),
|
||||
"causal-deletions" to
|
||||
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
|
||||
"program-executions" to
|
||||
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
||||
)
|
||||
private val SUPPORTED =
|
||||
(ANDROID_KINDS.values +
|
||||
|
|
@ -581,7 +583,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
if (total > MAX_GENERATION_BYTES)
|
||||
throw SyncGenerationException("generation exceeds bound")
|
||||
}
|
||||
if (!ANDROID_KINDS.keys.all { it in names })
|
||||
if (!ANDROID_KINDS.filterValues { it.required }.keys.all { it in names })
|
||||
throw SyncGenerationException("missing required domain")
|
||||
return root
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import com.labfytools.trainlog.model.SyncedProgramDetail
|
|||
import com.labfytools.trainlog.model.SyncedProgramOccurrence
|
||||
import com.labfytools.trainlog.model.SyncedProgramSession
|
||||
import com.labfytools.trainlog.model.SyncedProgramSummary
|
||||
import com.labfytools.trainlog.model.ProgramSessionExecutionState
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.Normalizer
|
||||
|
|
@ -228,6 +229,15 @@ sealed interface ProgramsImportResult {
|
|||
data object DatabaseError : ProgramsImportResult
|
||||
}
|
||||
|
||||
sealed interface StartProgramSessionResult {
|
||||
data object Started : StartProgramSessionResult
|
||||
data object AlreadyActive : StartProgramSessionResult
|
||||
data object AlreadyCompleted : StartProgramSessionResult
|
||||
data object ExistingUnrelatedDraft : StartProgramSessionResult
|
||||
data object NotAvailable : StartProgramSessionResult
|
||||
data class Error(val message: String) : StartProgramSessionResult
|
||||
}
|
||||
|
||||
private class SyncLifecycleConflict(val ownerId: String) : RuntimeException()
|
||||
private class ProgramsImportInvalid(message: String) : RuntimeException(message)
|
||||
|
||||
|
|
@ -486,16 +496,59 @@ class TrainlogRepository(
|
|||
jsonHasUniqueObjectKeys(json)
|
||||
|
||||
/** Captures Android-owned generation artifacts from one read snapshot. */
|
||||
internal fun captureSyncGenerationArtifacts(afterFirstArtifact: (() -> Unit)? = null): Map<String, String> =
|
||||
inSyncGenerationTransaction {
|
||||
val artifacts=linkedMapOf("catalog" to buildPcCatalogJson())
|
||||
afterFirstArtifact?.invoke()
|
||||
artifacts["history"]=buildMobileExport(4, true);artifacts["execution-drafts"]=buildExecutionDraftExportV1Json()
|
||||
artifacts["exercise-aliases"]=buildExerciseAliasesJson();artifacts["exercise-profile-state"]=buildExerciseProfileStateJson()
|
||||
artifacts["equipment-definitions"]=buildEquipmentDefinitionsJson(true);artifacts["equipment-associations"]=buildEquipmentAssociationsJson()
|
||||
artifacts["body-zones"]=buildExerciseBodyZonesJson(true);artifacts["feedback"]=buildTrainingFeedbackJson(true)
|
||||
artifacts["causal-deletions"]=buildCausalDeletionExportV1Json();artifacts
|
||||
internal fun captureSyncGenerationArtifacts(
|
||||
afterFirstArtifact: (() -> Unit)? = null,
|
||||
): Map<String, String> = inSyncGenerationTransaction {
|
||||
val artifacts = linkedMapOf("catalog" to buildPcCatalogJson())
|
||||
afterFirstArtifact?.invoke()
|
||||
artifacts["history"] = buildMobileExport(4, true)
|
||||
artifacts["execution-drafts"] = buildExecutionDraftExportV1Json()
|
||||
artifacts["exercise-aliases"] = buildExerciseAliasesJson()
|
||||
artifacts["exercise-profile-state"] = buildExerciseProfileStateJson()
|
||||
artifacts["equipment-definitions"] = buildEquipmentDefinitionsJson(true)
|
||||
artifacts["equipment-associations"] = buildEquipmentAssociationsJson()
|
||||
artifacts["body-zones"] = buildExerciseBodyZonesJson(true)
|
||||
artifacts["feedback"] = buildTrainingFeedbackJson(true)
|
||||
artifacts["causal-deletions"] = buildCausalDeletionExportV1Json()
|
||||
artifacts["program-executions"] = buildProgramExecutionsV1Json()
|
||||
artifacts
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes only stable provenance facts; program definitions remain
|
||||
* desktop-owned and are never reconstructed from Android session content.
|
||||
*/
|
||||
internal fun buildProgramExecutionsV1Json(): String {
|
||||
val executions = JSONArray()
|
||||
val db = database.readableDatabase
|
||||
db.rawQuery(
|
||||
"SELECT source_program_id,source_program_session_id,session_id,'completed'," +
|
||||
"COALESCE(ended_at,started_at) FROM sessions " +
|
||||
"WHERE source_program_id IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT source_program_id,source_program_session_id,session_id,'in_progress'," +
|
||||
"updated_at FROM active_session_draft WHERE source_program_id IS NOT NULL " +
|
||||
"ORDER BY 1,2",
|
||||
null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
executions.put(
|
||||
JSONObject()
|
||||
.put("program_id", cursor.getString(0))
|
||||
.put("program_session_id", cursor.getString(1))
|
||||
.put("session_id", cursor.getString(2))
|
||||
.put("state", cursor.getString(3))
|
||||
.put("observed_at", cursor.getString(4)),
|
||||
)
|
||||
}
|
||||
}
|
||||
return JSONObject()
|
||||
.put("format", "trainlog-program-executions")
|
||||
.put("version", 1)
|
||||
.put("generated_at", OffsetDateTime.now().toString())
|
||||
.put("executions", executions)
|
||||
.toString()
|
||||
}
|
||||
|
||||
/** Direction-neutral stable-identity catalogue used before generation history. */
|
||||
internal fun buildPcCatalogJson(): String {
|
||||
|
|
@ -2305,6 +2358,9 @@ class TrainlogRepository(
|
|||
} ||
|
||||
(draft.sourceSessionId != null &&
|
||||
(draft.sourceSessionId.isBlank() || draft.sessionType != SessionType.MAX_TEST)) ||
|
||||
(draft.sourceProgramId == null) != (draft.sourceProgramSessionId == null) ||
|
||||
draft.sourceProgramId?.isBlank() == true ||
|
||||
draft.sourceProgramSessionId?.isBlank() == true ||
|
||||
/* exercise_id identifies the catalogue movement. Repeated passages
|
||||
* are valid; only their stable occurrence IDs must be unique. */
|
||||
draft.exercises
|
||||
|
|
@ -2456,6 +2512,18 @@ class TrainlogRepository(
|
|||
forcedStartedAt = active.draft.startedAt,
|
||||
)
|
||||
|
||||
/* INVARIANT: completion moves the exact Program provenance from
|
||||
* the singleton draft to history in the same transaction. */
|
||||
db.execSQL(
|
||||
"UPDATE sessions SET source_program_id=?," +
|
||||
"source_program_session_id=? WHERE session_id=?",
|
||||
arrayOf(
|
||||
active.draft.sourceProgramId,
|
||||
active.draft.sourceProgramSessionId,
|
||||
sessionId,
|
||||
),
|
||||
)
|
||||
|
||||
/* CONTRACT: lifecycle transition changes table ownership, not the
|
||||
* stable note owner. Copy caches while causal state remains keyed
|
||||
* by session_id/entry_id; rollback covers every child. */
|
||||
|
|
@ -7095,7 +7163,9 @@ class TrainlogRepository(
|
|||
d.session_id,
|
||||
d.revision_id,
|
||||
d.parent_revision_id,
|
||||
d.started_at
|
||||
d.started_at,
|
||||
d.source_program_id,
|
||||
d.source_program_session_id
|
||||
FROM active_session_draft AS d
|
||||
LEFT JOIN exercises AS e
|
||||
ON e.id = d.selected_exercise_row_id
|
||||
|
|
@ -7140,6 +7210,8 @@ class TrainlogRepository(
|
|||
revisionId = cursor.getString(19),
|
||||
parentRevisionId = if (cursor.isNull(20)) null else cursor.getString(20),
|
||||
startedAt = if (cursor.isNull(21)) null else cursor.getString(21),
|
||||
sourceProgramId = if (cursor.isNull(22)) null else cursor.getString(22),
|
||||
sourceProgramSessionId = if (cursor.isNull(23)) null else cursor.getString(23),
|
||||
updatedAt =
|
||||
cursor.getString(6),
|
||||
warning =
|
||||
|
|
@ -7323,6 +7395,8 @@ class TrainlogRepository(
|
|||
exercises = exercises,
|
||||
sessionType = header.sessionType,
|
||||
sourceSessionId = header.sourceSessionId,
|
||||
sourceProgramId = header.sourceProgramId,
|
||||
sourceProgramSessionId = header.sourceProgramSessionId,
|
||||
form = header.form,
|
||||
updatedAt = header.updatedAt,
|
||||
),
|
||||
|
|
@ -7351,6 +7425,8 @@ class TrainlogRepository(
|
|||
put("revision_id", draft.revisionId)
|
||||
putOptionalString("parent_revision_id", draft.parentRevisionId)
|
||||
putOptionalString("source_session_id", draft.sourceSessionId)
|
||||
putOptionalString("source_program_id", draft.sourceProgramId)
|
||||
putOptionalString("source_program_session_id", draft.sourceProgramSessionId)
|
||||
putOptionalString("selected_equipment_id", draft.form.selectedEquipmentId)
|
||||
put("weight_text", draft.form.weightText)
|
||||
put("max_weight_text", draft.form.maxWeightText)
|
||||
|
|
@ -7852,6 +7928,8 @@ class TrainlogRepository(
|
|||
val sessionType: SessionType,
|
||||
val form: SessionDraftForm,
|
||||
val sourceSessionId: String?,
|
||||
val sourceProgramId: String?,
|
||||
val sourceProgramSessionId: String?,
|
||||
val sessionId: String?,
|
||||
val revisionId: String,
|
||||
val parentRevisionId: String?,
|
||||
|
|
@ -8339,12 +8417,15 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
}
|
||||
val execution = programSessionExecution(db, programId, sessionId)
|
||||
sessions += SyncedProgramSession(
|
||||
sessionId,
|
||||
sessionCursor.getString(1),
|
||||
sessionCursor.getString(2),
|
||||
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
|
||||
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
|
||||
execution.first,
|
||||
execution.second,
|
||||
entries,
|
||||
)
|
||||
}
|
||||
|
|
@ -8360,6 +8441,141 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
private fun programSessionExecution(
|
||||
db: SQLiteDatabase,
|
||||
programId: String,
|
||||
programSessionId: String,
|
||||
): Pair<ProgramSessionExecutionState, String?> {
|
||||
val completed = db.rawQuery(
|
||||
"SELECT session_id FROM sessions WHERE source_program_id=? AND " +
|
||||
"source_program_session_id=? LIMIT 2",
|
||||
arrayOf(programId, programSessionId),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else cursor.getString(0).also {
|
||||
check(!cursor.moveToNext()) { "Duplicate Program session execution." }
|
||||
}
|
||||
}
|
||||
if (completed != null) {
|
||||
return ProgramSessionExecutionState.COMPLETED to completed
|
||||
}
|
||||
val active = db.rawQuery(
|
||||
"SELECT session_id FROM active_session_draft WHERE id=1 AND " +
|
||||
"source_program_id=? AND source_program_session_id=?",
|
||||
arrayOf(programId, programSessionId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
return if (active == null) {
|
||||
ProgramSessionExecutionState.TODO to null
|
||||
} else {
|
||||
ProgramSessionExecutionState.IN_PROGRESS to active
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts exactly one Program session from stable provenance.
|
||||
*
|
||||
* The singleton draft and provenance lookup share one transaction. Replay
|
||||
* returns the existing draft, completed provenance is terminal, and an
|
||||
* unrelated draft is never overwritten.
|
||||
*/
|
||||
fun startSyncedProgramSession(
|
||||
programId: String,
|
||||
programSessionId: String,
|
||||
): StartProgramSessionResult {
|
||||
val db = database.writableDatabase
|
||||
return try {
|
||||
db.beginTransaction()
|
||||
try {
|
||||
val available = db.rawQuery(
|
||||
"SELECT s.session_type FROM synced_program_sessions s " +
|
||||
"JOIN synced_programs p ON p.program_id=s.program_id " +
|
||||
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active'",
|
||||
arrayOf(programId, programSessionId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
?: return StartProgramSessionResult.NotAvailable
|
||||
|
||||
val execution = programSessionExecution(db, programId, programSessionId)
|
||||
if (execution.first == ProgramSessionExecutionState.COMPLETED) {
|
||||
return StartProgramSessionResult.AlreadyCompleted
|
||||
}
|
||||
if (execution.first == ProgramSessionExecutionState.IN_PROGRESS) {
|
||||
return StartProgramSessionResult.AlreadyActive
|
||||
}
|
||||
val occupied = db.rawQuery(
|
||||
"SELECT 1 FROM active_session_draft WHERE id=1",
|
||||
null,
|
||||
).use { it.moveToFirst() }
|
||||
if (occupied) {
|
||||
return StartProgramSessionResult.ExistingUnrelatedDraft
|
||||
}
|
||||
|
||||
val occurrences = readSyncedProgramOccurrences(db, programSessionId)
|
||||
check(occurrences.isNotEmpty()) { "Program session has no executable occurrence." }
|
||||
val type = if (available == SessionType.MAX_TEST.wireValue) {
|
||||
SessionType.MAX_TEST
|
||||
} else {
|
||||
SessionType.TRAINING
|
||||
}
|
||||
persistActiveSessionDraft(
|
||||
db,
|
||||
ActiveSessionDraft(
|
||||
sessionId = "se_${UUID.randomUUID()}",
|
||||
revisionId = "dr_${UUID.randomUUID()}",
|
||||
startedAt = OffsetDateTime.now().toString(),
|
||||
exercises = occurrences,
|
||||
sessionType = type,
|
||||
sourceProgramId = programId,
|
||||
sourceProgramSessionId = programSessionId,
|
||||
),
|
||||
)
|
||||
db.setTransactionSuccessful()
|
||||
StartProgramSessionResult.Started
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
StartProgramSessionResult.Error(
|
||||
error.message ?: "Program session could not be started.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readSyncedProgramOccurrences(
|
||||
db: SQLiteDatabase,
|
||||
programSessionId: String,
|
||||
): List<SessionExerciseDraft> = buildList {
|
||||
db.rawQuery(
|
||||
"SELECT pe.entry_id,pe.exercise_id,pe.equipment_id,pe.load_mode," +
|
||||
"pe.rest_seconds,pe.target_sets,pe.target_reps," +
|
||||
"pe.target_duration_seconds,pe.target_weight_kg " +
|
||||
"FROM synced_program_entries pe WHERE pe.program_session_id=? " +
|
||||
"ORDER BY pe.position,pe.entry_id",
|
||||
arrayOf(programSessionId),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val exercise = readExerciseProfileExact(db, resolveExerciseId(db, cursor.getString(1)))
|
||||
?: error("Program exercise is unavailable: ${cursor.getString(1)}")
|
||||
val equipmentId = if (cursor.isNull(2)) null else cursor.getString(2)
|
||||
check(equipmentId == null || lookupEquipmentRowIdOrNull(db, equipmentId) != null) {
|
||||
"Program equipment is unavailable: $equipmentId"
|
||||
}
|
||||
add(
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
equipmentId = equipmentId,
|
||||
plan = SessionExercisePlan(
|
||||
sets = if (cursor.isNull(5)) 0 else cursor.getInt(5),
|
||||
reps = if (cursor.isNull(6)) null else cursor.getInt(6),
|
||||
durationSeconds = if (cursor.isNull(7)) null else cursor.getInt(7),
|
||||
weightKg = if (cursor.isNull(8)) null else cursor.getDouble(8),
|
||||
loadMode = SessionLoadMode.fromWire(cursor.getString(3)),
|
||||
restSeconds = cursor.getInt(4),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeName(
|
||||
value: String,
|
||||
): String {
|
||||
|
|
@ -8538,7 +8754,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
24,
|
||||
25,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -8587,6 +8803,7 @@ private class TrainlogDatabaseHelper(
|
|||
createSessionPreparationTables(db)
|
||||
createSessionPreparationWithdrawalTable(db)
|
||||
createSyncedProgramTables(db)
|
||||
createProgramExecutionProvenance(db)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -8784,6 +9001,14 @@ private class TrainlogDatabaseHelper(
|
|||
createSyncedProgramTables(db)
|
||||
version = 24
|
||||
}
|
||||
if (version < 25 && newVersion >= 25) {
|
||||
/* WHY: completion state must follow durable provenance, never a
|
||||
* title, date, position, or content heuristic. CONTRACT: v25 adds
|
||||
* nullable paired identities to the singleton draft and completed
|
||||
* session. INVARIANT: migration creates no draft or workout. */
|
||||
createProgramExecutionProvenance(db)
|
||||
version = 25
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
|
|
@ -8833,6 +9058,23 @@ private class TrainlogDatabaseHelper(
|
|||
)
|
||||
}
|
||||
|
||||
private fun createProgramExecutionProvenance(db: SQLiteDatabase) {
|
||||
fun addColumn(table: String, name: String) {
|
||||
if (!tableHasColumn(db, table, name)) {
|
||||
db.execSQL("ALTER TABLE $table ADD COLUMN $name TEXT")
|
||||
}
|
||||
}
|
||||
addColumn("active_session_draft", "source_program_id")
|
||||
addColumn("active_session_draft", "source_program_session_id")
|
||||
addColumn("sessions", "source_program_id")
|
||||
addColumn("sessions", "source_program_session_id")
|
||||
db.execSQL(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS sessions_program_execution_v25 " +
|
||||
"ON sessions(source_program_id,source_program_session_id) " +
|
||||
"WHERE source_program_id IS NOT NULL",
|
||||
)
|
||||
}
|
||||
|
||||
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
|
||||
fun addColumn(table: String, name: String, declaration: String) {
|
||||
if (!tableHasColumn(db, table, name)) {
|
||||
|
|
@ -9560,7 +9802,13 @@ private class TrainlogDatabaseHelper(
|
|||
'training',
|
||||
'max_test'
|
||||
)
|
||||
)
|
||||
),
|
||||
source_program_id TEXT,
|
||||
source_program_session_id TEXT,
|
||||
CHECK(
|
||||
(source_program_id IS NULL) =
|
||||
(source_program_session_id IS NULL)
|
||||
)
|
||||
);
|
||||
""".trimIndent()
|
||||
)
|
||||
|
|
@ -9794,6 +10042,8 @@ private class TrainlogDatabaseHelper(
|
|||
session_id TEXT UNIQUE,
|
||||
revision_id TEXT NOT NULL DEFAULT 'dr_legacy_v1',
|
||||
parent_revision_id TEXT,
|
||||
source_program_id TEXT,
|
||||
source_program_session_id TEXT,
|
||||
notes TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB)) <= 4096)
|
||||
);
|
||||
""".trimIndent()
|
||||
|
|
|
|||
|
|
@ -26,9 +26,17 @@ data class SyncedProgramSession(
|
|||
val sessionType: String,
|
||||
val plannedFor: String?,
|
||||
val note: String?,
|
||||
val executionState: ProgramSessionExecutionState,
|
||||
val executionSessionId: String?,
|
||||
val occurrences: List<SyncedProgramOccurrence>,
|
||||
)
|
||||
|
||||
enum class ProgramSessionExecutionState {
|
||||
TODO,
|
||||
IN_PROGRESS,
|
||||
COMPLETED,
|
||||
}
|
||||
|
||||
data class SyncedProgramOccurrence(
|
||||
val entryId: String,
|
||||
val exerciseId: String,
|
||||
|
|
|
|||
|
|
@ -112,6 +112,9 @@ data class ActiveSessionDraft(
|
|||
val sessionType: SessionType = SessionType.TRAINING,
|
||||
/** Existing max_test session updated atomically on finalization, if any. */
|
||||
val sourceSessionId: String? = null,
|
||||
/** Stable desktop-owned Program provenance; both values are present or absent. */
|
||||
val sourceProgramId: String? = null,
|
||||
val sourceProgramSessionId: String? = null,
|
||||
val form: SessionDraftForm = SessionDraftForm(),
|
||||
val updatedAt: String = "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.data.StartProgramSessionResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ProgramSessionExecutionState
|
||||
|
||||
/** Presentation-only list for the desktop-owned Programs projection. */
|
||||
@Composable
|
||||
|
|
@ -37,11 +44,36 @@ fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
/** CONTRACT: this route exposes no start, edit, archive, or delete mutation. */
|
||||
/** Program definitions remain read-only; only the local execution draft mutates. */
|
||||
@Composable
|
||||
fun ProgramDetailScreen(repository: TrainlogRepository, programId: String) {
|
||||
fun ProgramDetailScreen(
|
||||
repository: TrainlogRepository,
|
||||
programId: String,
|
||||
onOpenActiveSession: () -> Unit,
|
||||
) {
|
||||
val strings = localizedContext()
|
||||
val program = repository.getSyncedProgram(programId)
|
||||
var revision by remember { mutableIntStateOf(0) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
val program = remember(programId, revision) { repository.getSyncedProgram(programId) }
|
||||
|
||||
fun start(programSessionId: String) {
|
||||
when (val result = repository.startSyncedProgramSession(programId, programSessionId)) {
|
||||
StartProgramSessionResult.Started,
|
||||
StartProgramSessionResult.AlreadyActive -> onOpenActiveSession()
|
||||
StartProgramSessionResult.AlreadyCompleted -> {
|
||||
message = strings.getString(R.string.program_session_completed_detail)
|
||||
revision++
|
||||
}
|
||||
StartProgramSessionResult.ExistingUnrelatedDraft -> {
|
||||
message = strings.getString(R.string.existing_draft_warning)
|
||||
}
|
||||
StartProgramSessionResult.NotAvailable -> {
|
||||
message = strings.getString(R.string.program_session_unavailable)
|
||||
}
|
||||
is StartProgramSessionResult.Error -> message = result.message
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogScreen(strings.getString(R.string.route_program_detail)) {
|
||||
if (program == null) {
|
||||
TrainlogInfo(strings.getString(R.string.program_not_found))
|
||||
|
|
@ -53,8 +85,17 @@ fun ProgramDetailScreen(repository: TrainlogRepository, programId: String) {
|
|||
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
|
||||
program.note?.let { TrainlogInfo(it) }
|
||||
}
|
||||
message?.let { TrainlogInfo(it) }
|
||||
program.sessions.forEachIndexed { index, session ->
|
||||
TrainlogFrame("${index + 1}. ${session.title}") {
|
||||
var expanded by remember(session.programSessionId) { mutableStateOf(false) }
|
||||
val completed = session.executionState == ProgramSessionExecutionState.COMPLETED
|
||||
TrainlogFrame("${index + 1}. ${session.title}", active = completed) {
|
||||
val stateLabel = when (session.executionState) {
|
||||
ProgramSessionExecutionState.TODO -> R.string.program_session_todo
|
||||
ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress
|
||||
ProgramSessionExecutionState.COMPLETED -> R.string.program_session_completed
|
||||
}
|
||||
TrainlogInfo(strings.getString(stateLabel))
|
||||
TrainlogInfo(
|
||||
listOfNotNull(
|
||||
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
|
||||
|
|
@ -62,7 +103,30 @@ fun ProgramDetailScreen(repository: TrainlogRepository, programId: String) {
|
|||
).joinToString(" · "),
|
||||
)
|
||||
session.note?.let { TrainlogInfo(it) }
|
||||
session.occurrences.forEachIndexed { occurrenceIndex, occurrence ->
|
||||
TrainlogButton(
|
||||
strings.getString(
|
||||
if (expanded) R.string.program_session_hide_occurrences
|
||||
else R.string.program_session_show_occurrences,
|
||||
),
|
||||
{ expanded = !expanded },
|
||||
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
|
||||
)
|
||||
when (session.executionState) {
|
||||
ProgramSessionExecutionState.TODO -> TrainlogButton(
|
||||
strings.getString(R.string.program_session_start),
|
||||
{ start(session.programSessionId) },
|
||||
Modifier.testTag("program-session-start-${session.programSessionId}"),
|
||||
)
|
||||
ProgramSessionExecutionState.IN_PROGRESS -> TrainlogButton(
|
||||
strings.getString(R.string.program_session_resume),
|
||||
onOpenActiveSession,
|
||||
Modifier.testTag("program-session-resume-${session.programSessionId}"),
|
||||
)
|
||||
ProgramSessionExecutionState.COMPLETED -> TrainlogInfo(
|
||||
strings.getString(R.string.program_session_completed_detail),
|
||||
)
|
||||
}
|
||||
if (expanded) session.occurrences.forEachIndexed { occurrenceIndex, occurrence ->
|
||||
val target = occurrence.targetReps?.let { strings.getString(R.string.program_target_reps, it) }
|
||||
?: occurrence.targetDurationSeconds?.let { strings.getString(R.string.program_target_duration, it) }.orEmpty()
|
||||
val sets = occurrence.targetSets?.let { strings.getString(R.string.program_target_sets, it) }.orEmpty()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -68,6 +69,7 @@ fun SessionsHub(
|
|||
}
|
||||
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
|
||||
Modifier.fillMaxWidth().testTag("sessions-history-action"))
|
||||
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
|
||||
TrainlogButton(strings.getString(R.string.route_programs), onPrograms,
|
||||
Modifier.fillMaxWidth().testTag("sessions-programs-action"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
)
|
||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
||||
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
|
||||
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId)
|
||||
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
|
||||
draftRevision++
|
||||
open(AppRoute.SessionEditor)
|
||||
}
|
||||
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
|
||||
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
|
||||
if (saved) exportSnapshot()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@
|
|||
<string name="program_target_reps">%1$d repetitions</string>
|
||||
<string name="program_target_duration">%1$d s</string>
|
||||
<string name="program_target_weight">%1$.1f kg</string>
|
||||
<string name="program_session_todo">To do</string>
|
||||
<string name="program_session_in_progress">In progress</string>
|
||||
<string name="program_session_completed">Completed</string>
|
||||
<string name="program_session_start">Start</string>
|
||||
<string name="program_session_resume">Resume session</string>
|
||||
<string name="program_session_completed_detail">This session has already been completed.</string>
|
||||
<string name="program_session_unavailable">This session can no longer be started.</string>
|
||||
<string name="program_session_show_occurrences">Show exercises</string>
|
||||
<string name="program_session_hide_occurrences">Hide exercises</string>
|
||||
<plurals name="program_session_count"><item quantity="one">%d session</item><item quantity="other">%d sessions</item></plurals>
|
||||
<string name="route_session_detail">Session details</string>
|
||||
<string name="route_session_correction">Edit session</string>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@
|
|||
<string name="program_target_reps">%1$d répétitions</string>
|
||||
<string name="program_target_duration">%1$d s</string>
|
||||
<string name="program_target_weight">%1$.1f kg</string>
|
||||
<string name="program_session_todo">À faire</string>
|
||||
<string name="program_session_in_progress">En cours</string>
|
||||
<string name="program_session_completed">Effectuée</string>
|
||||
<string name="program_session_start">Démarrer</string>
|
||||
<string name="program_session_resume">Reprendre la séance</string>
|
||||
<string name="program_session_completed_detail">Cette séance a déjà été effectuée.</string>
|
||||
<string name="program_session_unavailable">Cette séance ne peut plus être démarrée.</string>
|
||||
<string name="program_session_show_occurrences">Voir les exercices</string>
|
||||
<string name="program_session_hide_occurrences">Masquer les exercices</string>
|
||||
<plurals name="program_session_count"><item quantity="one">%d séance</item><item quantity="other">%d séances</item></plurals>
|
||||
<string name="route_session_detail">Détail de la séance</string>
|
||||
<string name="route_session_correction">Modifier la séance</string>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(24, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(25, 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(24, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(25, 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(24, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(25, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.util.UUID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Additive v24 -> v25 provenance migration preserves the singleton draft. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class AndroidV25ProgramExecutionMigrationTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun v24FixturePreservesDraftAndInventsNoProgramProvenance() {
|
||||
val name = "program-execution-v24-${UUID.randomUUID()}.db"
|
||||
val first = TrainlogRepository(context, name)
|
||||
val exercise = (
|
||||
first.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Migration fixture",
|
||||
RecordingMode.SETS,
|
||||
TrackingMode.REPS,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created
|
||||
).exercise
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
first.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(
|
||||
exercises = listOf(
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
plan = SessionExercisePlan(sets = 3, reps = 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
first.close()
|
||||
|
||||
val path = context.getDatabasePath(name).path
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
/* The published-unreleased fixture may already contain additive
|
||||
* columns; lowering only user_version proves migration replay is safe. */
|
||||
db.execSQL("PRAGMA user_version=24")
|
||||
}
|
||||
|
||||
val reopened = TrainlogRepository(context, name)
|
||||
try {
|
||||
val draft = (reopened.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(null, draft.sourceProgramId)
|
||||
assertEquals(null, draft.sourceProgramSessionId)
|
||||
} finally {
|
||||
reopened.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(25, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||
assertFalse(cursor.moveToFirst())
|
||||
}
|
||||
}
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
|
||||
private fun scalar(db: SQLiteDatabase, sql: String): Int =
|
||||
db.rawQuery(sql, null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
cursor.getInt(0)
|
||||
}
|
||||
|
||||
private fun text(db: SQLiteDatabase, sql: String): String =
|
||||
db.rawQuery(sql, null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
cursor.getString(0)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(24, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(25, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
|||
null,
|
||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
.use { assertEquals(24, it.version) }
|
||||
.use { assertEquals(25, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.labfytools.trainlog.data
|
|||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.util.UUID
|
||||
import com.labfytools.trainlog.model.ProgramSessionExecutionState
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
|
|
@ -124,4 +126,72 @@ class ProgramsProjectionRepositoryTest {
|
|||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun programSessionStartsResumesCompletesAndKeepsProvenanceAcrossRestart() {
|
||||
val name = "program-execution-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val catalog = JSONObject()
|
||||
.put("format", "trainlog-pc-catalog")
|
||||
.put("version", 1)
|
||||
.put("exercises", JSONArray().put(
|
||||
JSONObject()
|
||||
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
|
||||
.put("name", "Exercise fixture")
|
||||
.put("recording_mode", "sets")
|
||||
.put("tracking_mode", "reps")
|
||||
.put("data_fields", 0),
|
||||
))
|
||||
.toString()
|
||||
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
|
||||
repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
|
||||
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
val programSessionId = "pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
||||
|
||||
assertEquals(
|
||||
StartProgramSessionResult.Started,
|
||||
repository.startSyncedProgramSession(programId, programSessionId),
|
||||
)
|
||||
assertEquals(
|
||||
StartProgramSessionResult.AlreadyActive,
|
||||
repository.startSyncedProgramSession(programId, programSessionId),
|
||||
)
|
||||
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(programId, active.sourceProgramId)
|
||||
assertEquals(programSessionId, active.sourceProgramSessionId)
|
||||
val performed = active.copy(
|
||||
exercises = active.exercises.map { occurrence ->
|
||||
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
|
||||
},
|
||||
)
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
assertEquals(
|
||||
ProgramSessionExecutionState.COMPLETED,
|
||||
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
|
||||
)
|
||||
assertEquals(
|
||||
StartProgramSessionResult.AlreadyCompleted,
|
||||
repository.startSyncedProgramSession(programId, programSessionId),
|
||||
)
|
||||
val exported = JSONObject(repository.buildProgramExecutionsV1Json())
|
||||
val execution = exported.getJSONArray("executions").getJSONObject(0)
|
||||
assertEquals("completed", execution.getString("state"))
|
||||
assertEquals(programSessionId, execution.getString("program_session_id"))
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
|
||||
repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val detail = repository.listSyncedPrograms().single()
|
||||
assertEquals(1, detail.sessionCount)
|
||||
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
||||
assertEquals(1, repository.listSessions().size)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ class SessionPreparationExchangeTest {
|
|||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(1, repository.listPreparedSessions().size)
|
||||
repository.close()
|
||||
assertEquals("24", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals("25", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
"0",
|
||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||
|
|
|
|||
|
|
@ -149,6 +149,63 @@ class SyncGenerationServiceTest {
|
|||
acceptAndroidAck(liveGenerationId, liveAck).contains("ACK_ACCEPT=acknowledged")
|
||||
)
|
||||
|
||||
val programSessionId =
|
||||
"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
assertEquals(
|
||||
StartProgramSessionResult.Started,
|
||||
android.startSyncedProgramSession(programId, programSessionId),
|
||||
)
|
||||
val active =
|
||||
(android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
val performed = active.copy(
|
||||
exercises = active.exercises.map { occurrence ->
|
||||
occurrence.copy(
|
||||
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
|
||||
)
|
||||
},
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
android.saveActiveSessionDraft(performed),
|
||||
)
|
||||
assertTrue(android.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
val completedSessionId = android.listSessions().single().sessionId
|
||||
val androidGeneration = service.capture(root, desktopPeer)
|
||||
val androidPublished = service.publish(
|
||||
androidGeneration,
|
||||
java.io.File(root, "android-objects"),
|
||||
)
|
||||
val desktopAck = java.io.File(root, "android-program-execution-ack.json")
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"consume-desktop",
|
||||
androidPublished.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
desktopAck.absolutePath,
|
||||
)
|
||||
assertEquals(
|
||||
"acknowledged",
|
||||
service.acceptAcknowledgement(desktopAck.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
"$programSessionId|$completedSessionId|completed",
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT program_session_id || '|' || session_id || '|' || state " +
|
||||
"FROM program_session_executions;",
|
||||
).trim(),
|
||||
)
|
||||
val completedDetail = run(
|
||||
fixture.absolutePath,
|
||||
desktopDb.absolutePath,
|
||||
"program-detail",
|
||||
)
|
||||
assertTrue(completedDetail.contains("\"execution_state\":\"completed\""))
|
||||
|
||||
val deleted =
|
||||
run(
|
||||
fixture.absolutePath,
|
||||
|
|
@ -329,7 +386,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(24, it.getInt(0))
|
||||
assertEquals(25, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(24, cursor.getInt(0))
|
||||
assertEquals(25, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(24, cursor.getInt(0))
|
||||
assertEquals(25, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ Sessions without offering import, archive, delete, preparation, or other
|
|||
program mutation actions. The projection is separate from capture data, AI
|
||||
proposals, manual preparations, and the active-session singleton.
|
||||
|
||||
Android schema v25 adds paired nullable Program provenance to the active draft
|
||||
and completed session. Starting a Program session allocates one ordinary stable
|
||||
execution identity, copies targets without fabricating performed values, and
|
||||
respects the existing singleton. Replay resumes the matching draft, refuses an
|
||||
unrelated draft, and refuses a second execution after completion. Finalization
|
||||
copies the same Program identities into completed history transactionally.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The Android application is Trainlog's low-friction capture client.
|
||||
|
|
@ -85,6 +92,12 @@ the projection and deletion state durably, and desktop records the correlated
|
|||
ACK before marking the tombstone acknowledged. It changes neither
|
||||
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparation exchange.
|
||||
|
||||
Android publishes the separate optional `trainlog-program-executions` V1
|
||||
companion in full generations. It contains only stable Program/session/execution
|
||||
identities, `in_progress` or `completed`, and an observation timestamp. It does
|
||||
not publish Program definitions and therefore does not transfer Program
|
||||
ownership to Android. Older consumers may ignore this optional companion.
|
||||
|
||||
Completed session occurrences persist an `entry_id`; it is never regenerated
|
||||
for exchange. Android schema v14 added feedback roots and completion `ended_at`
|
||||
anchors; v15 adds immutable correction history and the separately versioned
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | schema v26 |
|
||||
| Android SQLite | schema v24 |
|
||||
| Desktop SQLite | schema v27 |
|
||||
| Android SQLite | schema v25 |
|
||||
| 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` |
|
||||
|
|
@ -82,6 +82,7 @@ parallel implementation of its rules.
|
|||
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
||||
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
|
||||
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
||||
| Program execution flow | `TRAINLOG_PROGRAM_EXECUTION_FLOW_V1=IMPLEMENTED_VALIDATED_AWAITING_ROLLOUT` |
|
||||
| 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` |
|
||||
|
|
@ -272,6 +273,14 @@ snapshot plus unacknowledged tombstones under capability `programs-v1`; a
|
|||
durable correlated ACK records acknowledgment. It changes neither
|
||||
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparations.
|
||||
|
||||
Android schema v25 makes those projected sessions executable through the
|
||||
existing durable singleton. Stable paired Program provenance survives draft
|
||||
restart and completion. The optional Android-to-desktop
|
||||
`trainlog-program-executions` V1 companion records `in_progress` or `completed`
|
||||
against desktop schema v27 without transferring ownership of Program
|
||||
definitions. Web and Android derive their session state from these persisted
|
||||
identities; no display-field heuristic is used.
|
||||
|
||||
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
||||
schema v23 to v24 after verified backups, then installed the signed
|
||||
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
# Desktop database
|
||||
|
||||
## Schema v27: durable Program execution provenance
|
||||
|
||||
Schema v27 adds `program_session_executions`, the stable link from one
|
||||
desktop-owned Program session to its Android execution `session_id`. The state
|
||||
advances only from `in_progress` to `completed`; a Program session and an
|
||||
execution session are each unique. Completed links are accepted only after the
|
||||
referenced completed session has been imported. The migration is additive,
|
||||
transactional, creates no execution row, and preserves Program definitions,
|
||||
preparations and history unchanged.
|
||||
|
||||
## Schema v26: Programs presentation, Android projection, and logical deletion
|
||||
|
||||
Schema v26 adds nullable `programs.deleted_at` and the terminal
|
||||
|
|
@ -65,8 +75,8 @@ is a later, separately versioned migration.
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=26
|
||||
DATABASE_SCHEMA_V26=IMPLEMENTED
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=27
|
||||
DATABASE_SCHEMA_V27=IMPLEMENTED
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
|
|
@ -85,7 +95,7 @@ PRAGMA user_version;
|
|||
Current value:
|
||||
|
||||
```text
|
||||
26
|
||||
27
|
||||
```
|
||||
|
||||
The independent actual-set loads documented in the current desktop, Android
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
44
docs/reviews/program_execution_flow_v1_evidence.md
Normal file
44
docs/reviews/program_execution_flow_v1_evidence.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Program execution flow V1 validation evidence
|
||||
|
||||
Status: `IMPLEMENTED_VALIDATED_AWAITING_ROLLOUT`
|
||||
|
||||
This record covers the isolated `program-execution-flow-v1` worktree. It does
|
||||
not claim installation, user-database migration, service restart, device smoke
|
||||
testing, tag creation, or public release.
|
||||
|
||||
## Functional evidence
|
||||
|
||||
- Android starts one projected Program session through the production durable
|
||||
singleton, returns the same draft on replay, refuses an unrelated singleton,
|
||||
and refuses a second execution after completion.
|
||||
- Finalization retains the stable `program_id`, `program_session_id`, and
|
||||
execution `session_id`; reopening preserves completion and creates no draft.
|
||||
- A production full-generation scenario imports `programs-v1` on Android,
|
||||
performs and finalizes the session, publishes `program-executions-v1`, imports
|
||||
completed history and provenance on desktop, accepts the real correlated ACK,
|
||||
and exposes Web `completed` before continuing through deletion and replay.
|
||||
- Existing logical Program deletion, tombstone ACK, restart and
|
||||
non-resurrection coverage remains in the same integrated scenario.
|
||||
|
||||
## Validation evidence
|
||||
|
||||
- Native Meson suite: 87/87 passed.
|
||||
- ASan/UBSan Meson suite with Web assets: 89/89 passed.
|
||||
- Android JVM suite: 231 passed, 5 intentionally skipped hardware cases.
|
||||
- Android debug and private release builds passed; release lint passed.
|
||||
- Web Vitest: 94/94 passed; TypeScript and production build passed.
|
||||
- Real headless Firefox against the C server passed desktop and exact 390×844
|
||||
Programs flows, including readable state text and deletion confirmation.
|
||||
- Populated desktop migration continues from v25 through v27 with identical
|
||||
business projections, integrity `ok`, empty foreign-key check, and an empty
|
||||
execution ledger. Android v24 to v25 migration preserves the active draft and
|
||||
invents no Program provenance.
|
||||
- JSON/import validators, `git diff --check`, and
|
||||
`tools/check_changed_c_format.sh main` passed.
|
||||
|
||||
## Candidate boundary
|
||||
|
||||
The coordinated candidates are built only after the final source commit. Their
|
||||
exact paths, source commit, hashes, Android package metadata and signing digest
|
||||
belong in the consent-gate report so the archive inventory can bind to the exact
|
||||
final HEAD without circular documentation changes.
|
||||
|
|
@ -24,7 +24,7 @@ distinction between measured values and estimates.
|
|||
|
||||
## Current baseline
|
||||
|
||||
Desktop schema v26, Android schema v24, Notcurses, direct
|
||||
Desktop schema v27, Android schema v25, 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
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ does not publish to it or let it override an artifact in the new endpoint.
|
|||
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
|
||||
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
|
||||
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
|
||||
| Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance |
|
||||
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
|
||||
|
||||
|
|
@ -165,6 +166,17 @@ V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does
|
|||
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
|
||||
semantics.
|
||||
|
||||
## Optional Program executions companion V1
|
||||
|
||||
`trainlog-program-executions` V1 is an Android-to-desktop full-generation
|
||||
companion. Android schema v25 emits at most one stable execution fact per
|
||||
Program session. Desktop schema v27 validates that the Program/session pair
|
||||
exists, imports completed history before accepting a completed fact, and
|
||||
permits only the monotonic `in_progress` to `completed` transition. Exact replay
|
||||
is idempotent; identity reuse or regression is a hard conflict. Because the
|
||||
descriptor is optional, peers predating this companion keep exchanging all
|
||||
existing required domains without a capability failure.
|
||||
|
||||
`trainlog-exercise-aliases` v1 is the separate EXERCISE_MERGE_V1 identity
|
||||
companion. Its root contains exactly `format`, `version`, and `aliases`; every
|
||||
entry contains exactly `source_exercise_id` and `canonical_exercise_id` using
|
||||
|
|
|
|||
|
|
@ -75,6 +75,16 @@ no duplication. Python Programs/synchronization suites cover the staged
|
|||
companion's full snapshot, unacknowledged tombstone publication, and ACK-driven
|
||||
acknowledgment without changing mobile V3, catalog, or preparation artifacts.
|
||||
|
||||
Program execution regressions additionally cover Android schema v24 to v25,
|
||||
start/replay/singleton protection, target-only draft creation, completion,
|
||||
restart persistence and companion serialization. The integrated generation
|
||||
test uses the production desktop and Android producers/consumers to import a
|
||||
Program, execute one session, import completed history and its V1 provenance,
|
||||
acknowledge the correlated generation, expose Web `completed`, then continue
|
||||
through Program deletion, restart and old-generation replay without duplication
|
||||
or resurrection. The desktop populated migration test now continues through
|
||||
schema v27 and proves the new execution ledger starts empty.
|
||||
|
||||
`test_web_sessions_browser.py` runs headless Firefox against the embedded
|
||||
production assets and C HTTP server with private temporary XDG roots. It creates
|
||||
only a synthetic ready preparation and delivery, verifies French then persisted
|
||||
|
|
|
|||
|
|
@ -79,6 +79,12 @@ a preparation from one active program session copies targets and provenance
|
|||
into a new manual preparation but creates no delivery, execution or performed
|
||||
data. Archived or deleted programs cannot be prepared.
|
||||
|
||||
Desktop schema v27 enriches each Program session with a persisted execution
|
||||
state. Web displays `À faire`, `Préparée`, `En cours`, or `Effectuée`; completed
|
||||
and in-progress states come from stable Android execution provenance, while
|
||||
prepared comes from a live derived preparation. No title, date, position or
|
||||
content heuristic is used.
|
||||
|
||||
The discrete right-edge Program trash action is accessible and opens a
|
||||
confirmation dialog that names the program and its consequence. Focus moves
|
||||
into that dialog, Escape and Cancel perform no mutation, and focus returns to
|
||||
|
|
|
|||
100
tests/test_program_execution_exchange.py
Normal file
100
tests/test_program_execution_exchange.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Program-execution companion validation and idempotence regressions."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import program_execution_exchange
|
||||
|
||||
|
||||
class ProgramExecutionExchangeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.database = Path(self.temporary.name) / "trainlog.sqlite"
|
||||
self.artifact = Path(self.temporary.name) / "program-executions-v1.json"
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.executescript("""
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE programs(program_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE program_sessions(
|
||||
program_session_id TEXT PRIMARY KEY,
|
||||
program_id TEXT NOT NULL REFERENCES programs(program_id));
|
||||
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE);
|
||||
CREATE TABLE program_session_executions(
|
||||
program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id),
|
||||
program_id TEXT NOT NULL REFERENCES programs(program_id),
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
state TEXT NOT NULL,
|
||||
observed_at TEXT NOT NULL);
|
||||
PRAGMA user_version=27;
|
||||
""")
|
||||
db.execute("INSERT INTO programs VALUES(?)", (
|
||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
))
|
||||
db.execute("INSERT INTO program_sessions VALUES(?,?)", (
|
||||
"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
))
|
||||
db.execute("INSERT INTO sessions(session_id) VALUES(?)", (
|
||||
"se_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def document(self, state: str) -> dict:
|
||||
return {
|
||||
"format": "trainlog-program-executions",
|
||||
"version": 1,
|
||||
"generated_at": "2026-09-18T20:00:00Z",
|
||||
"executions": [{
|
||||
"program_id": "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"program_session_id": "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
"session_id": "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
"state": state,
|
||||
"observed_at": "2026-09-18T19:30:00Z",
|
||||
}],
|
||||
}
|
||||
|
||||
def apply(self, state: str):
|
||||
self.artifact.write_text(json.dumps(self.document(state)), encoding="utf-8")
|
||||
root = program_execution_exchange.load(self.artifact)
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
result = program_execution_exchange.apply_executions(db, root)
|
||||
db.commit()
|
||||
row = db.execute(
|
||||
"SELECT state,session_id FROM program_session_executions"
|
||||
).fetchone()
|
||||
return result, row
|
||||
|
||||
def test_progression_is_durable_and_replay_is_idempotent(self):
|
||||
self.assertEqual(((1, 0, 0), ("in_progress", "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc")),
|
||||
self.apply("in_progress"))
|
||||
self.assertEqual(((0, 0, 1), ("in_progress", "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc")),
|
||||
self.apply("in_progress"))
|
||||
self.assertEqual(((0, 1, 0), ("completed", "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc")),
|
||||
self.apply("completed"))
|
||||
self.assertEqual(((0, 0, 1), ("completed", "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc")),
|
||||
self.apply("completed"))
|
||||
|
||||
def test_completed_requires_imported_history(self):
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.execute("DELETE FROM sessions")
|
||||
db.commit()
|
||||
self.artifact.write_text(json.dumps(self.document("completed")), encoding="utf-8")
|
||||
root = program_execution_exchange.load(self.artifact)
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
with self.assertRaisesRegex(ValueError, "missing history"):
|
||||
program_execution_exchange.apply_executions(db, root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -66,9 +66,11 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
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.assertEqual(inventory["desktop_schema"], 26)
|
||||
self.assertEqual(inventory["desktop_schema"], 27)
|
||||
self.assertEqual(inventory["android_schema"], 25)
|
||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||
self.assertIn("programs-v1", inventory["protocols"])
|
||||
self.assertIn("program-executions-v1", inventory["protocols"])
|
||||
self.assertTrue((output / "bin/trainlog").is_file())
|
||||
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
||||
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ class BrowserSessionsTest(unittest.TestCase):
|
|||
detail = driver.find_element(By.CSS_SELECTOR, "main").text
|
||||
self.assertIn("PROGRAMME · ACTIF", detail)
|
||||
self.assertIn("Import trainlog-program v1", detail)
|
||||
self.assertIn("À faire", detail)
|
||||
self.assertLess(detail.index("1. Séance A ordonnée"), detail.index("2. Séance B ordonnée"))
|
||||
|
||||
driver.find_element(
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ def digest(value) -> str:
|
|||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26):
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27):
|
||||
raise CausalError(f"desktop schema v20-v25 required, found v{version}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26):
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27):
|
||||
raise LifecycleError(f"desktop schema v19-v25 required, found v{version}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27)
|
||||
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,7 +20,7 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26)
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v18-v25 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = tuple(range(8, 27))
|
||||
supported_versions = tuple(range(8, 28))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(8, 27))
|
||||
supported_versions = tuple(range(8, 28))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ def main():
|
|||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26):
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27):
|
||||
raise ValueError("schema desktop v12-v25 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(11, 27))
|
||||
supported_versions = tuple(range(11, 28))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27):
|
||||
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 range(8, 27):
|
||||
if version not in range(8, 28):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
supported_versions = range(11, 27)
|
||||
supported_versions = range(11, 28)
|
||||
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
||||
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ def nullable(row, index):
|
|||
|
||||
def export_programs(database: Path) -> dict:
|
||||
with closing(connect_database(database)) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 26:
|
||||
raise ValueError("desktop schema v26 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27):
|
||||
raise ValueError("desktop schema v26 or v27 required")
|
||||
programs = []
|
||||
for row in db.execute(
|
||||
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128
|
|||
|
||||
|
||||
def build_export(connection: sqlite3.Connection) -> dict:
|
||||
supported_versions = (24, 25, 26)
|
||||
supported_versions = (24, 25, 26, 27)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("desktop schema v24, v25 or v26 required")
|
||||
raise ValueError("desktop schema v24, v25, v26 or v27 required")
|
||||
deliveries = []
|
||||
rows = connection.execute(
|
||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ def main(complete_causal_envelope=False):
|
|||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(15, 27)
|
||||
supported_versions = range(15, 28)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v15-v25 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")
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ 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.
|
||||
"""
|
||||
supported_versions = range(18, 27)
|
||||
supported_versions = range(18, 28)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v18-v25 requis")
|
||||
draft, entries = validate(payload, connection)
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ def main():
|
|||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(8, 27)
|
||||
supported_versions = range(8, 28)
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
proof_path = args.mobile_export
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def validate(payload, reserved):
|
|||
|
||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||
"""Apply validated definitions without owning the surrounding transaction."""
|
||||
supported_versions = range(8, 27)
|
||||
supported_versions = range(8, 28)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
supported_versions = range(12, 27)
|
||||
supported_versions = range(12, 28)
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v12-v21 requis")
|
||||
for source, canonical in aliases:
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
|
|||
|
||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||
"""Apply validated zone rows without owning transaction boundaries."""
|
||||
supported_versions = range(11, 27)
|
||||
supported_versions = range(11, 28)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v11-v25 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")
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ def apply_profile_state(con, root, allow_pending=False):
|
|||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26):
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27):
|
||||
fail("schema desktop v17-v25 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
|
|
|
|||
|
|
@ -846,7 +846,7 @@ 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 range(5, 27):
|
||||
if version not in range(5, 28):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v25 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
|
@ -1978,7 +1978,7 @@ def apply_payload(connection, payload, trace_exercises=False):
|
|||
require_supported_schema(connection)
|
||||
|
||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26):
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v25")
|
||||
if schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
supported_versions = range(15, 27)
|
||||
supported_versions = range(15, 28)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v15-v25 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")
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ RUNTIME_TOOLS = [
|
|||
"export_ai_session_drafts.py",
|
||||
"export_session_preparations.py",
|
||||
"export_programs.py",
|
||||
"program_execution_exchange.py",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -97,8 +98,8 @@ def main() -> int:
|
|||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.2",
|
||||
"desktop_schema": 26,
|
||||
"android_schema": 24,
|
||||
"desktop_schema": 27,
|
||||
"android_schema": 25,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
"mobile-history-v4",
|
||||
|
|
@ -110,6 +111,7 @@ def main() -> int:
|
|||
"session-preparations-v2",
|
||||
"ai-session-drafts-v2",
|
||||
"programs-v1",
|
||||
"program-executions-v1",
|
||||
],
|
||||
"entry_points": [
|
||||
"bin/trainlog",
|
||||
|
|
|
|||
163
tools/program_execution_exchange.py
Normal file
163
tools/program_execution_exchange.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate and merge Android program-execution provenance companion V1."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
FORMAT = "trainlog-program-executions"
|
||||
VERSION = 1
|
||||
MAX_BYTES = 4 * 1024 * 1024
|
||||
MAX_EXECUTIONS = 4096
|
||||
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
PROGRAM_ID = re.compile(rf"^pg_{UUID4}$")
|
||||
PROGRAM_SESSION_ID = re.compile(rf"^pgs_{UUID4}$")
|
||||
SESSION_ID = re.compile(rf"^se_{UUID4}$")
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
fail(f"duplicate JSON field: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def timestamp(value: object) -> str:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or "T" not in value
|
||||
or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None
|
||||
):
|
||||
fail("invalid execution timestamp")
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
fail("invalid execution timestamp")
|
||||
return value
|
||||
|
||||
|
||||
def load(path: Path) -> dict:
|
||||
if path.stat().st_size > MAX_BYTES:
|
||||
fail("program execution artifact exceeds 4 MiB")
|
||||
root = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
|
||||
if not isinstance(root, dict) or set(root) != {
|
||||
"format", "version", "generated_at", "executions"
|
||||
}:
|
||||
fail("invalid program execution envelope")
|
||||
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
|
||||
fail("unsupported program execution format")
|
||||
timestamp(root["generated_at"])
|
||||
values = root["executions"]
|
||||
if not isinstance(values, list) or len(values) > MAX_EXECUTIONS:
|
||||
fail("program executions are absent or out of bounds")
|
||||
|
||||
seen_program_sessions = set()
|
||||
seen_sessions = set()
|
||||
for item in values:
|
||||
if not isinstance(item, dict) or set(item) != {
|
||||
"program_id", "program_session_id", "session_id", "state", "observed_at"
|
||||
}:
|
||||
fail("invalid program execution")
|
||||
if not PROGRAM_ID.fullmatch(item["program_id"]):
|
||||
fail("invalid program_id")
|
||||
if not PROGRAM_SESSION_ID.fullmatch(item["program_session_id"]):
|
||||
fail("invalid program_session_id")
|
||||
if not SESSION_ID.fullmatch(item["session_id"]):
|
||||
fail("invalid session_id")
|
||||
if item["state"] not in ("in_progress", "completed"):
|
||||
fail("invalid program execution state")
|
||||
timestamp(item["observed_at"])
|
||||
if item["program_session_id"] in seen_program_sessions or item["session_id"] in seen_sessions:
|
||||
fail("duplicate program execution identity")
|
||||
seen_program_sessions.add(item["program_session_id"])
|
||||
seen_sessions.add(item["session_id"])
|
||||
return root
|
||||
|
||||
|
||||
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 27:
|
||||
fail("desktop schema v27 required")
|
||||
added = 0
|
||||
advanced = 0
|
||||
skipped = 0
|
||||
for item in root["executions"]:
|
||||
program_session = db.execute(
|
||||
"SELECT program_id FROM program_sessions WHERE program_session_id=?",
|
||||
(item["program_session_id"],),
|
||||
).fetchone()
|
||||
if program_session is None or program_session[0] != item["program_id"]:
|
||||
fail("program execution references an unknown or mismatched session")
|
||||
if item["state"] == "completed":
|
||||
completed = db.execute(
|
||||
"SELECT 1 FROM sessions WHERE session_id=?", (item["session_id"],)
|
||||
).fetchone()
|
||||
if completed is None:
|
||||
fail("completed program execution references missing history")
|
||||
|
||||
existing = db.execute(
|
||||
"SELECT program_id,session_id,state,observed_at "
|
||||
"FROM program_session_executions WHERE program_session_id=?",
|
||||
(item["program_session_id"],),
|
||||
).fetchone()
|
||||
expected = (
|
||||
item["program_id"], item["session_id"], item["state"], item["observed_at"]
|
||||
)
|
||||
if existing is None:
|
||||
db.execute(
|
||||
"INSERT INTO program_session_executions("
|
||||
"program_session_id,program_id,session_id,state,observed_at) "
|
||||
"VALUES(?,?,?,?,?)",
|
||||
(item["program_session_id"], *expected),
|
||||
)
|
||||
added += 1
|
||||
elif tuple(existing) == expected:
|
||||
skipped += 1
|
||||
elif existing[0] == item["program_id"] and existing[1] == item["session_id"] and \
|
||||
existing[2] == "in_progress" and item["state"] == "completed":
|
||||
db.execute(
|
||||
"UPDATE program_session_executions SET state='completed',observed_at=? "
|
||||
"WHERE program_session_id=?",
|
||||
(item["observed_at"], item["program_session_id"]),
|
||||
)
|
||||
advanced += 1
|
||||
else:
|
||||
fail("HARD CONFLICT for program execution provenance")
|
||||
return added, advanced, skipped
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("artifact", type=Path)
|
||||
parser.add_argument("--database", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
root = load(args.artifact)
|
||||
db = connect_database(args.database)
|
||||
try:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
result = apply_executions(db, root)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
print(
|
||||
"PROGRAM_EXECUTIONS_IMPORT=PASS "
|
||||
f"added={result[0]} advanced={result[1]} skipped={result[2]}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -28,6 +28,7 @@ import import_exercise_body_zones
|
|||
import import_exercise_profile_state
|
||||
import import_mobile_export
|
||||
import import_training_feedback
|
||||
import program_execution_exchange
|
||||
import export_equipment_definitions
|
||||
import export_exercise_body_zones
|
||||
import export_pc_mobile
|
||||
|
|
@ -75,7 +76,9 @@ ARTIFACTS = (
|
|||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||
"export_programs.py", ()),
|
||||
)
|
||||
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS}
|
||||
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
|
||||
("trainlog-program-executions", 1),
|
||||
}
|
||||
|
||||
|
||||
class GenerationError(RuntimeError):
|
||||
|
|
@ -203,9 +206,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
|||
|
||||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
supported_versions = (24, 25, 26)
|
||||
supported_versions = (24, 25, 26, 27)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise GenerationError("desktop schema v24, v25 or v26 required")
|
||||
raise GenerationError("desktop schema v24, v25, v26 or v27 required")
|
||||
|
||||
|
||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||
|
|
@ -602,6 +605,10 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
|||
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"))
|
||||
program_executions = (
|
||||
program_execution_exchange.load(listed["program-executions"])
|
||||
if "program-executions" in listed else None
|
||||
)
|
||||
mobile_occurrences = import_equipment_associations.load_mobile_occurrences(required("history"))
|
||||
|
||||
with closing(connect_database(database)) as db:
|
||||
|
|
@ -633,6 +640,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
|||
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)
|
||||
if program_executions is not None:
|
||||
program_execution_exchange.apply_executions(db, program_executions)
|
||||
ack = record_consumed(db, manifest, manifest_digest)
|
||||
db.commit()
|
||||
return ack
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 26
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 27
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -1230,6 +1230,25 @@ static const char *const MIGRATE_V25_TO_V26_SQL =
|
|||
"generation_id,deleted_at,request_id) WHERE acknowledged_at IS NULL;"
|
||||
"PRAGMA user_version=26;COMMIT;";
|
||||
|
||||
/* WHY: Android can execute a desktop-owned program session offline, so the
|
||||
* canonical desktop needs a durable stable-identity link after import.
|
||||
* CONTRACT: rows describe execution provenance only and never own or mutate
|
||||
* program definitions or completed-session history. INVARIANT: one program
|
||||
* session has at most one execution and replaying the same fact is idempotent. */
|
||||
static const char *const MIGRATE_V26_TO_V27_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TABLE IF NOT EXISTS program_session_executions("
|
||||
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
|
||||
"ON DELETE RESTRICT,"
|
||||
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
||||
"session_id TEXT NOT NULL UNIQUE,"
|
||||
"state TEXT NOT NULL CHECK(state IN('in_progress','completed')) ,"
|
||||
"observed_at TEXT NOT NULL,"
|
||||
"CHECK(length(program_id)>0 AND length(session_id)>0));"
|
||||
"CREATE INDEX IF NOT EXISTS program_session_executions_program "
|
||||
"ON program_session_executions(program_id,state);"
|
||||
"PRAGMA user_version=27;COMMIT;";
|
||||
|
||||
/* CONTRACT: migration fixtures may retain additive v25 columns while
|
||||
* deliberately lowering user_version. Add each provenance column only when
|
||||
* absent; a genuine v24 database still receives both before opening returns. */
|
||||
|
|
@ -2019,7 +2038,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
|
||||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
|
||||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
||||
version == 26) {
|
||||
version == 26 || version == 27) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -2155,6 +2174,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status == TRAINLOG_STATUS_OK && version < 26) {
|
||||
status = execute_sql(database, MIGRATE_V25_TO_V26_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 27) {
|
||||
status = execute_sql(database, MIGRATE_V26_TO_V27_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = ensure_v18_ai_draft_publication_state(database);
|
||||
}
|
||||
|
|
@ -2174,7 +2196,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status != TRAINLOG_STATUS_OK) {
|
||||
set_open_diagnostic(output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v26" : "migrate database to schema v26",
|
||||
version == 0 ? "create schema v27" : "migrate database to schema v27",
|
||||
database->connection,
|
||||
SQLITE_ERROR);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
|
|
|
|||
|
|
@ -366,6 +366,8 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database,
|
|||
!add_column_text(document, session, "session_type", statement, 3) ||
|
||||
!add_column_text(document, session, "planned_for", statement, 4) ||
|
||||
!add_column_text(document, session, "note", statement, 5) ||
|
||||
!add_column_text(document, session, "execution_state", statement, 6) ||
|
||||
!add_column_text(document, session, "execution_session_id", statement, 7) ||
|
||||
!yyjson_mut_obj_add_val(document, session, "occurrences", entries) ||
|
||||
!yyjson_mut_arr_add_val(sessions, session)) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
|
|
@ -379,8 +381,15 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
|
|||
yyjson_mut_doc *document,
|
||||
yyjson_mut_val *sessions) {
|
||||
static const char SESSION_SQL[] =
|
||||
"SELECT program_session_id,position,title,session_type,planned_for,note FROM "
|
||||
"program_sessions WHERE program_id=?1 ORDER BY position,program_session_id";
|
||||
"SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
|
||||
"ps.note,CASE WHEN pe.state='completed' THEN 'completed' "
|
||||
"WHEN pe.state='in_progress' THEN 'in_progress' "
|
||||
"WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE "
|
||||
"sp.source_program_session_id=ps.program_session_id AND sp.withdrawn_at IS NULL) "
|
||||
"THEN 'prepared' ELSE 'todo' END,pe.session_id "
|
||||
"FROM program_sessions ps LEFT JOIN program_session_executions pe "
|
||||
"ON pe.program_session_id=ps.program_session_id "
|
||||
"WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id";
|
||||
sqlite3_stmt *session_statement = NULL;
|
||||
int step;
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,21 @@ delete_program(TrainlogDatabase *database, const char *expected_revision, const
|
|||
return 0;
|
||||
}
|
||||
|
||||
static int print_program_detail(TrainlogDatabase *database) {
|
||||
char *response = NULL;
|
||||
size_t response_size = 0U;
|
||||
TrainlogStatus status = trainlog_web_programs_detail_json(
|
||||
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size);
|
||||
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
free(response);
|
||||
return 6;
|
||||
}
|
||||
(void)printf("PROGRAM_DETAIL=%s\n", response);
|
||||
free(response);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int create_session_fixture(TrainlogDatabase *database) {
|
||||
TrainlogSetInput set = {.reps = 9, .has_weight = true, .weight_kg = 32.5};
|
||||
TrainlogSessionExerciseInput exercise;
|
||||
|
|
@ -212,6 +227,8 @@ int main(int argc, char **argv) {
|
|||
result = create_session_fixture(database);
|
||||
} else if (argc == 3 && strcmp(argv[2], "program-create") == 0) {
|
||||
result = create_program(database);
|
||||
} else if (argc == 3 && strcmp(argv[2], "program-detail") == 0) {
|
||||
result = print_program_detail(database);
|
||||
} else if (argc == 5 && strcmp(argv[2], "program-delete") == 0) {
|
||||
result = delete_program(database, argv[3], argv[4]);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -286,6 +286,8 @@ static bool build_v25_fixture(const char *path) {
|
|||
"PRAGMA foreign_keys=OFF;"
|
||||
"DROP INDEX program_deletions_pending;"
|
||||
"DROP TABLE program_deletions;"
|
||||
"DROP INDEX program_session_executions_program;"
|
||||
"DROP TABLE program_session_executions;"
|
||||
"ALTER TABLE programs DROP COLUMN deleted_at;"
|
||||
"PRAGMA user_version=25;"
|
||||
"PRAGMA foreign_keys=ON;") ||
|
||||
|
|
@ -388,7 +390,8 @@ static int run_test(void) {
|
|||
static const ProjectionSpec UNCHANGED_SCHEMA[] = {
|
||||
{"schema",
|
||||
"SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' "
|
||||
"AND name NOT IN('programs','program_deletions','program_deletions_pending') "
|
||||
"AND name NOT IN('programs','program_deletions','program_deletions_pending',"
|
||||
"'program_session_executions','program_session_executions_program') "
|
||||
"ORDER BY type,name"},
|
||||
};
|
||||
char path[] = "/tmp/trainlog-v26-populated-XXXXXX";
|
||||
|
|
@ -429,12 +432,12 @@ static int run_test(void) {
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(version == 26);
|
||||
CHECK(version == 27);
|
||||
trainlog_database_close(database);
|
||||
database = NULL;
|
||||
|
||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 26);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 27);
|
||||
CHECK(capture_projection(raw,
|
||||
BUSINESS_PROJECTIONS,
|
||||
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),
|
||||
|
|
@ -449,6 +452,7 @@ static int run_test(void) {
|
|||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM programs WHERE deleted_at IS NOT NULL") == 0);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM program_deletions") == 0);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM program_session_executions") == 0);
|
||||
CHECK(scalar(raw,
|
||||
"SELECT COUNT(*) FROM pragma_table_info('programs') WHERE name='deleted_at' AND "
|
||||
"type='TEXT' AND \"notnull\"=0") == 1);
|
||||
|
|
@ -458,7 +462,7 @@ static int run_test(void) {
|
|||
"name='program_deletions_pending')") == 2);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 13);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'") ==
|
||||
schema_object_count_before + 2);
|
||||
schema_object_count_before + 4);
|
||||
ok = true;
|
||||
cleanup:
|
||||
if (!ok && diagnostic[0] != '\0') {
|
||||
|
|
|
|||
|
|
@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
|
|||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strncmp(diagnostic,
|
||||
"migrate database to schema v26: SQLite rc=",
|
||||
strlen("migrate database to schema v26: SQLite rc=")) == 0);
|
||||
"migrate database to schema v27: SQLite rc=",
|
||||
strlen("migrate database to schema v27: SQLite rc=")) == 0);
|
||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||
|
|
@ -197,12 +197,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=27;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=28;", 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 27 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 28 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v26") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v27") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ static bool import_archive_and_prepare(void) {
|
|||
char *second_entry;
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(scalar(database, "PRAGMA user_version") == 26);
|
||||
CHECK(scalar(database, "PRAGMA user_version") == 27);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE "
|
||||
"name IN('generation_id','acknowledged_at')") == 2);
|
||||
|
|
@ -268,6 +268,7 @@ static bool import_archive_and_prepare(void) {
|
|||
TRAINLOG_STATUS_OK);
|
||||
CHECK(extract_string(response, "revision_id", revision, sizeof(revision)));
|
||||
CHECK(strstr(response, "Exercise fixture") == NULL);
|
||||
CHECK(strstr(response, "\"execution_state\":\"todo\"") != NULL);
|
||||
free(response);
|
||||
response = NULL;
|
||||
|
||||
|
|
@ -283,6 +284,12 @@ static bool import_archive_and_prepare(void) {
|
|||
"WHERE source_program_id='pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'") == 1);
|
||||
free(response);
|
||||
response = NULL;
|
||||
CHECK(trainlog_web_programs_detail_json(
|
||||
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(response, "\"execution_state\":\"prepared\"") != NULL);
|
||||
free(response);
|
||||
response = NULL;
|
||||
|
||||
CHECK(trainlog_web_programs_archive_json(database,
|
||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export interface ProgramSession {
|
|||
session_type: 'training' | 'max_test'
|
||||
planned_for: string | null
|
||||
note: string | null
|
||||
execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed'
|
||||
execution_session_id: string | null
|
||||
occurrences: ProgramOccurrence[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,8 @@ describe('Programs tab', () => {
|
|||
session_type: 'training',
|
||||
planned_for: null,
|
||||
note: null,
|
||||
execution_state: 'todo',
|
||||
execution_session_id: null,
|
||||
occurrences: [{
|
||||
entry_id: 'pge_test',
|
||||
position: 0,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
|
|||
setError(reason instanceof Error ? reason.message : 'Création impossible')
|
||||
}
|
||||
}
|
||||
const executionLabel = (state: ProgramDetail['sessions'][number]['execution_state']) => ({
|
||||
todo: 'À faire',
|
||||
prepared: 'Préparée',
|
||||
in_progress: 'En cours',
|
||||
completed: 'Effectuée',
|
||||
})[state]
|
||||
return <article className="session-detail program-detail">
|
||||
<div className="sessions-toolbar">
|
||||
<button type="button" className="quiet-action" onClick={onBack}>
|
||||
|
|
@ -97,6 +103,9 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
|
|||
{program.sessions.map((session) => <li key={session.program_session_id}>
|
||||
<div>
|
||||
<strong>{session.position + 1}. {session.title}</strong>
|
||||
<span className={`program-execution-state state-${session.execution_state}`}>
|
||||
{executionLabel(session.execution_state)}
|
||||
</span>
|
||||
<p>
|
||||
{session.session_type === 'max_test'
|
||||
? 'Test MAX planifié' : 'Entraînement planifié'}
|
||||
|
|
@ -118,11 +127,14 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
|
|||
</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
<button
|
||||
{session.execution_state === 'todo' && <button
|
||||
type="button"
|
||||
className="primary-action"
|
||||
onClick={() => void prepare(session.program_session_id)}
|
||||
>Créer une préparation</button>
|
||||
>Créer une préparation</button>}
|
||||
{session.execution_state === 'prepared' && <span>Préparation disponible</span>}
|
||||
{session.execution_state === 'in_progress' && <span>Séance en cours sur Android</span>}
|
||||
{session.execution_state === 'completed' && <span>✓ Séance effectuée</span>}
|
||||
</li>)}
|
||||
</ol>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -172,6 +172,10 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
.program-session-list { padding: 0; list-style: none; }
|
||||
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; }
|
||||
.program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); }
|
||||
.program-execution-state { display: inline-flex; margin-left: .65rem; padding: .2rem .55rem; border-radius: 999px; font-size: .82rem; font-weight: 700; }
|
||||
.program-execution-state.state-todo { background: var(--surface-1); color: var(--subtext-1); }
|
||||
.program-execution-state.state-prepared, .program-execution-state.state-in_progress { background: color-mix(in srgb, var(--yellow) 18%, transparent); color: var(--yellow); }
|
||||
.program-execution-state.state-completed { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
|
||||
.import-preview { margin: 1rem 0; padding: 1rem; border: 1px solid var(--blue); border-radius: .75rem; background: var(--mantle); }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 600px) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue