Add durable program session execution flow

This commit is contained in:
fy59 2026-09-18 21:30:14 +02:00
parent db722588f8
commit 949ace6301
71 changed files with 1126 additions and 86 deletions

View file

@ -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

View file

@ -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;

View file

@ -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()

View file

@ -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
}

View file

@ -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()

View file

@ -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,

View file

@ -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 = "",
)

View file

@ -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()

View file

@ -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"))
}

View file

@ -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()

View file

@ -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>

View file

@ -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>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(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"))

View file

@ -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") }

View file

@ -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"),

View file

@ -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)
}
}

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(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))

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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"),

View file

@ -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())

View file

@ -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())

View file

@ -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

View file

@ -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

View file

@ -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

View 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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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()

View file

@ -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())

View 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(

View file

@ -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}")

View file

@ -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}")

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 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",

View file

@ -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(

View file

@ -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)

View file

@ -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():

View file

@ -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 "

View file

@ -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():

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 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,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 27):
if version not in range(8, 28):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -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)")

View file

@ -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,"

View file

@ -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,"

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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():

View file

@ -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:

View file

@ -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")

View file

@ -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"]:

View file

@ -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(

View file

@ -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")

View file

@ -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",

View 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()

View file

@ -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

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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 {

View file

@ -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') {

View file

@ -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;

View file

@ -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);

View file

@ -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",

View file

@ -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[]
}

View file

@ -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,

View file

@ -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>

View file

@ -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) {