fix(programs): reflect completed sessions in progress
This commit is contained in:
parent
e6e5e76f68
commit
891c09179e
12 changed files with 438 additions and 13 deletions
|
|
@ -2,6 +2,13 @@
|
||||||
|
|
||||||
## Unreleased — 0.1.4
|
## Unreleased — 0.1.4
|
||||||
|
|
||||||
|
- Fixed Program progress for sessions started from a delivered manual
|
||||||
|
preparation. The preparation companion now carries its stable Program and
|
||||||
|
Program-session provenance through Android start, finalization, restart and
|
||||||
|
the existing execution companion; legacy stored deliveries are safely
|
||||||
|
enriched only when every pre-existing field and execution identity matches.
|
||||||
|
Android and Web consequently present the completed session and progress from
|
||||||
|
the same persisted identity without fabricating history.
|
||||||
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
|
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
|
||||||
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
|
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
|
||||||
the previous entry immediately, adopts only an exact-date result and rejects
|
the previous entry immediately, adopts only an exact-date result and rejects
|
||||||
|
|
|
||||||
|
|
@ -2543,10 +2543,14 @@ class TrainlogRepository(
|
||||||
return try {
|
return try {
|
||||||
for (deliveryIndex in 0 until values.length()) {
|
for (deliveryIndex in 0 until values.length()) {
|
||||||
val delivery = values.getJSONObject(deliveryIndex)
|
val delivery = values.getJSONObject(deliveryIndex)
|
||||||
val required = setOf("delivery_id", "preparation_id", "revision_id", "execution_session_id",
|
val legacyKeys = setOf("delivery_id", "preparation_id", "revision_id", "execution_session_id",
|
||||||
"state", "title", "session_type", "planned_for", "notes", "source_proposal_id",
|
"state", "title", "session_type", "planned_for", "notes", "source_proposal_id",
|
||||||
"source_payload_sha256", "occurrences")
|
"source_payload_sha256", "occurrences")
|
||||||
require(delivery.hasExactKeys(required)) { "Forme de préparation invalide." }
|
val provenanceKeys = setOf("source_program_id", "source_program_session_id")
|
||||||
|
require(delivery.hasExactKeys(legacyKeys) ||
|
||||||
|
delivery.hasExactKeys(legacyKeys + provenanceKeys)) {
|
||||||
|
"Forme de préparation invalide."
|
||||||
|
}
|
||||||
val deliveryId = delivery.getString("delivery_id")
|
val deliveryId = delivery.getString("delivery_id")
|
||||||
val preparationId = delivery.getString("preparation_id")
|
val preparationId = delivery.getString("preparation_id")
|
||||||
val revisionId = delivery.getString("revision_id")
|
val revisionId = delivery.getString("revision_id")
|
||||||
|
|
@ -2555,6 +2559,17 @@ class TrainlogRepository(
|
||||||
stableUuidV4Id(preparationId, "sp_") &&
|
stableUuidV4Id(preparationId, "sp_") &&
|
||||||
stableUuidV4Id(revisionId, "spr_") &&
|
stableUuidV4Id(revisionId, "spr_") &&
|
||||||
stableUuidV4Id(executionId, "se_")) { "Identité de préparation invalide." }
|
stableUuidV4Id(executionId, "se_")) { "Identité de préparation invalide." }
|
||||||
|
if (delivery.has("source_program_id")) {
|
||||||
|
val programId = if (delivery.isNull("source_program_id")) null else
|
||||||
|
delivery.getString("source_program_id")
|
||||||
|
val programSessionId = if (delivery.isNull("source_program_session_id")) null else
|
||||||
|
delivery.getString("source_program_session_id")
|
||||||
|
require((programId == null) == (programSessionId == null) &&
|
||||||
|
(programId == null || (stableUuidV4Id(programId, "pg_") &&
|
||||||
|
stableUuidV4Id(programSessionId!!, "pgs_")))) {
|
||||||
|
"Provenance de programme invalide."
|
||||||
|
}
|
||||||
|
}
|
||||||
require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." }
|
require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." }
|
||||||
val occurrences = delivery.getJSONArray("occurrences")
|
val occurrences = delivery.getJSONArray("occurrences")
|
||||||
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
|
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
|
||||||
|
|
@ -2581,9 +2596,83 @@ class TrainlogRepository(
|
||||||
})
|
})
|
||||||
imported++
|
imported++
|
||||||
} else {
|
} else {
|
||||||
require(existing.first == revisionId && JSONObject(existing.second).toString() == canonical) {
|
val existingPayload = JSONObject(existing.second)
|
||||||
|
/* WHY: the PC delivery lifecycle can advance from pending
|
||||||
|
* to acknowledged before a legacy Android row receives
|
||||||
|
* newly exported Program provenance. CONTRACT: recovery
|
||||||
|
* ignores only that transport state and the two missing
|
||||||
|
* provenance fields; every preparation fact and identity
|
||||||
|
* must remain byte-for-byte equivalent. */
|
||||||
|
val legacyEnrichment =
|
||||||
|
existing.first == revisionId &&
|
||||||
|
!existingPayload.has("source_program_id") &&
|
||||||
|
delivery.has("source_program_id") &&
|
||||||
|
JSONObject(delivery.toString()).apply {
|
||||||
|
remove("source_program_id")
|
||||||
|
remove("source_program_session_id")
|
||||||
|
remove("state")
|
||||||
|
}.toString() == JSONObject(existingPayload.toString()).apply {
|
||||||
|
remove("state")
|
||||||
|
}.toString()
|
||||||
|
require(existing.first == revisionId &&
|
||||||
|
(existingPayload.toString() == canonical || legacyEnrichment)) {
|
||||||
"Identité de livraison réutilisée avec un contenu différent."
|
"Identité de livraison réutilisée avec un contenu différent."
|
||||||
}
|
}
|
||||||
|
if (legacyEnrichment) {
|
||||||
|
val programId = if (delivery.isNull("source_program_id")) null else
|
||||||
|
delivery.getString("source_program_id")
|
||||||
|
val programSessionId = if (delivery.isNull("source_program_session_id")) null else
|
||||||
|
delivery.getString("source_program_session_id")
|
||||||
|
if (programId != null && programSessionId != null) {
|
||||||
|
val currentProvenance = db.rawQuery(
|
||||||
|
"SELECT source_program_id,source_program_session_id FROM sessions " +
|
||||||
|
"WHERE session_id=? UNION ALL SELECT source_program_id," +
|
||||||
|
"source_program_session_id FROM active_session_draft WHERE session_id=?",
|
||||||
|
arrayOf(executionId, executionId),
|
||||||
|
).use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
Pair(
|
||||||
|
if (cursor.isNull(0)) null else cursor.getString(0),
|
||||||
|
if (cursor.isNull(1)) null else cursor.getString(1),
|
||||||
|
).also {
|
||||||
|
require(!cursor.moveToNext()) {
|
||||||
|
"Identité d'exécution dupliquée."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(currentProvenance == null ||
|
||||||
|
(currentProvenance.first == null &&
|
||||||
|
currentProvenance.second == null) ||
|
||||||
|
currentProvenance == Pair(programId, programSessionId)) {
|
||||||
|
"Conflit de provenance du programme."
|
||||||
|
}
|
||||||
|
/* CONTRACT: old deliveries omitted Program
|
||||||
|
* provenance. A later canonical delivery may fill
|
||||||
|
* only those missing identities on the exact
|
||||||
|
* reserved execution session; performed facts and
|
||||||
|
* lifecycle state remain untouched. */
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE sessions SET source_program_id=?," +
|
||||||
|
"source_program_session_id=? WHERE session_id=? AND " +
|
||||||
|
"source_program_id IS NULL AND source_program_session_id IS NULL",
|
||||||
|
arrayOf(programId, programSessionId, executionId),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE active_session_draft SET source_program_id=?," +
|
||||||
|
"source_program_session_id=? WHERE session_id=? AND " +
|
||||||
|
"source_program_id IS NULL AND source_program_session_id IS NULL",
|
||||||
|
arrayOf(programId, programSessionId, executionId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE session_preparation_deliveries SET payload_json=? " +
|
||||||
|
"WHERE delivery_id=? AND revision_id=?",
|
||||||
|
arrayOf(canonical, deliveryId, revisionId),
|
||||||
|
)
|
||||||
|
}
|
||||||
skipped++
|
skipped++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2754,9 +2843,24 @@ class TrainlogRepository(
|
||||||
val payload = JSONObject(row.second)
|
val payload = JSONObject(row.second)
|
||||||
val entries = parsePreparedOccurrences(db, payload.getJSONArray("occurrences"))
|
val entries = parsePreparedOccurrences(db, payload.getJSONArray("occurrences"))
|
||||||
val type = if (payload.getString("session_type") == "max_test") SessionType.MAX_TEST else SessionType.TRAINING
|
val type = if (payload.getString("session_type") == "max_test") SessionType.MAX_TEST else SessionType.TRAINING
|
||||||
/* INVARIANT: the desktop-reserved execution identity survives
|
val sourceProgramId = if (!payload.has("source_program_id") ||
|
||||||
* start and replay; no performed value is manufactured. */
|
payload.isNull("source_program_id")) null else payload.getString("source_program_id")
|
||||||
persistActiveSessionDraft(db, ActiveSessionDraft(sessionId = row.first, exercises = entries, sessionType = type))
|
val sourceProgramSessionId = if (!payload.has("source_program_session_id") ||
|
||||||
|
payload.isNull("source_program_session_id")) null else
|
||||||
|
payload.getString("source_program_session_id")
|
||||||
|
/* INVARIANT: the desktop-reserved execution and Program
|
||||||
|
* identities survive start, edits, finalization and replay;
|
||||||
|
* no performed value is manufactured by this transition. */
|
||||||
|
persistActiveSessionDraft(
|
||||||
|
db,
|
||||||
|
ActiveSessionDraft(
|
||||||
|
sessionId = row.first,
|
||||||
|
exercises = entries,
|
||||||
|
sessionType = type,
|
||||||
|
sourceProgramId = sourceProgramId,
|
||||||
|
sourceProgramSessionId = sourceProgramSessionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
db.execSQL("UPDATE session_preparation_deliveries SET state='started',started_at=? WHERE delivery_id=? AND state='pending'",
|
db.execSQL("UPDATE session_preparation_deliveries SET state='started',started_at=? WHERE delivery_id=? AND state='pending'",
|
||||||
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), deliveryId))
|
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), deliveryId))
|
||||||
db.setTransactionSuccessful()
|
db.setTransactionSuccessful()
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,57 @@ class ProgramsProjectionRepositoryTest {
|
||||||
.put("deletions", deletions)
|
.put("deletions", deletions)
|
||||||
.toString()
|
.toString()
|
||||||
|
|
||||||
|
private fun preparationArtifact(
|
||||||
|
includeProgramProvenance: Boolean = true,
|
||||||
|
deliveryState: String = "pending",
|
||||||
|
): String {
|
||||||
|
val occurrence =
|
||||||
|
JSONObject()
|
||||||
|
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
|
||||||
|
.put("position", 0)
|
||||||
|
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
|
||||||
|
.put("equipment_id", JSONObject.NULL)
|
||||||
|
.put("recording_mode", "sets")
|
||||||
|
.put("tracking_mode", "reps")
|
||||||
|
.put("data_fields", 0)
|
||||||
|
.put("load_mode", "external")
|
||||||
|
.put("rest_seconds", 90)
|
||||||
|
.put("target_sets", 3)
|
||||||
|
.put("target_reps", 8)
|
||||||
|
.put("target_duration_seconds", JSONObject.NULL)
|
||||||
|
.put("target_weight_kg", 40.0)
|
||||||
|
.put("notes", JSONObject.NULL)
|
||||||
|
val delivery =
|
||||||
|
JSONObject()
|
||||||
|
.put("delivery_id", "spd_22222222-2222-4222-8222-222222222222")
|
||||||
|
.put("preparation_id", "sp_33333333-3333-4333-8333-333333333333")
|
||||||
|
.put("revision_id", "spr_44444444-4444-4444-8444-444444444444")
|
||||||
|
.put("execution_session_id", "se_55555555-5555-4555-8555-555555555555")
|
||||||
|
.put("state", deliveryState)
|
||||||
|
.put("title", "Séance A")
|
||||||
|
.put("session_type", "training")
|
||||||
|
.put("planned_for", JSONObject.NULL)
|
||||||
|
.put("notes", JSONObject.NULL)
|
||||||
|
.put("source_proposal_id", JSONObject.NULL)
|
||||||
|
.put("source_payload_sha256", JSONObject.NULL)
|
||||||
|
.put("occurrences", JSONArray().put(occurrence))
|
||||||
|
if (includeProgramProvenance) {
|
||||||
|
delivery
|
||||||
|
.put("source_program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||||
|
.put(
|
||||||
|
"source_program_session_id",
|
||||||
|
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return JSONObject()
|
||||||
|
.put("format", "trainlog-session-preparations")
|
||||||
|
.put("version", 2)
|
||||||
|
.put("generated_at", "2026-09-18T12:05:00Z")
|
||||||
|
.put("deliveries", JSONArray().put(delivery))
|
||||||
|
.put("withdrawals", JSONArray())
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
|
|
||||||
private fun deletion(): JSONObject =
|
private fun deletion(): JSONObject =
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
|
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
|
||||||
|
|
@ -194,4 +245,103 @@ class ProgramsProjectionRepositoryTest {
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deliveredProgramPreparationCompletesProgramProgressAcrossRestart() {
|
||||||
|
val name = "program-delivery-${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)
|
||||||
|
assertEquals(
|
||||||
|
ProgramsImportResult.Applied(1, 0, 0),
|
||||||
|
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AiSessionDraftImportResult.Applied(1, 0),
|
||||||
|
repository.applySessionPreparationsJson(
|
||||||
|
preparationArtifact(includeProgramProvenance = false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
StartAiSessionDraftResult.Started,
|
||||||
|
repository.startPreparedSession("spd_22222222-2222-4222-8222-222222222222"),
|
||||||
|
)
|
||||||
|
val active =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
assertEquals(null, active.sourceProgramId)
|
||||||
|
assertEquals(null, 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.TODO,
|
||||||
|
repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||||
|
.sessions.single().executionState,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AiSessionDraftImportResult.Applied(0, 1),
|
||||||
|
repository.applySessionPreparationsJson(
|
||||||
|
preparationArtifact(deliveryState = "remote_unknown"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||||
|
assertEquals(ProgramSessionExecutionState.COMPLETED, detail.sessions.single().executionState)
|
||||||
|
val execution =
|
||||||
|
JSONObject(repository.buildProgramExecutionsV1Json())
|
||||||
|
.getJSONArray("executions")
|
||||||
|
.getJSONObject(0)
|
||||||
|
assertEquals("completed", execution.getString("state"))
|
||||||
|
assertEquals(
|
||||||
|
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||||
|
execution.getString("program_session_id"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||||
|
assertEquals(1, detail.sessions.count {
|
||||||
|
it.executionState == ProgramSessionExecutionState.COMPLETED
|
||||||
|
})
|
||||||
|
assertEquals(
|
||||||
|
StartProgramSessionResult.AlreadyCompleted,
|
||||||
|
repository.startSyncedProgramSession(
|
||||||
|
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,12 @@ Android schema v22 adds a pending manual-preparation store which is separate
|
||||||
from AI proposals and the exactly-one active-session singleton. A received
|
from AI proposals and the exactly-one active-session singleton. A received
|
||||||
preparation is inert until explicit start; an occupied singleton is never
|
preparation is inert until explicit start; an occupied singleton is never
|
||||||
overwritten. Starting preserves the desktop-reserved execution `session_id`,
|
overwritten. Starting preserves the desktop-reserved execution `session_id`,
|
||||||
copies targets only and creates no performed facts.
|
copies targets only and creates no performed facts. A Program-owned preparation
|
||||||
|
also preserves `source_program_id` and `source_program_session_id` through the
|
||||||
|
active draft and completed session. A legacy stored delivery may receive those
|
||||||
|
two omitted fields later only when its revision, complete prior payload and
|
||||||
|
reserved execution identity match exactly; this repairs provenance without
|
||||||
|
altering performed workout facts.
|
||||||
|
|
||||||
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
|
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
|
||||||
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
|
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,13 @@ ACK without duplicating the execution or resurrecting the Program. A final
|
||||||
correlated exchange remained completed with no missing capability. The real
|
correlated exchange remained completed with no missing capability. The real
|
||||||
Program remains active and unique with 24 sessions and 164 ordered entries.
|
Program remains active and unique with 24 sessions and 164 ordered entries.
|
||||||
|
|
||||||
|
Program-owned manual preparations now carry their stable Program provenance to
|
||||||
|
Android. Starting and finalizing such a delivery retains both identities, and a
|
||||||
|
strict same-delivery enrichment repairs completed sessions produced from the
|
||||||
|
earlier omission without changing workout facts. Android and Web therefore
|
||||||
|
derive the completed count and per-session state from the same execution
|
||||||
|
identity after synchronization and restart.
|
||||||
|
|
||||||
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
||||||
schema v23 to v24 after verified backups, then installed the signed
|
schema v23 to v24 after verified backups, then installed the signed
|
||||||
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
|
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,11 @@ full run, generation, peer, hash and ACK envelope and never starts a workout.
|
||||||
`trainlog-session-preparations` V2 is a separate optional desktop-to-Android
|
`trainlog-session-preparations` V2 is a separate optional desktop-to-Android
|
||||||
generation artifact. It carries immutable ready revision snapshots and stable
|
generation artifact. It carries immutable ready revision snapshots and stable
|
||||||
delivery, preparation, occurrence and reserved execution identities, plus
|
delivery, preparation, occurrence and reserved execution identities, plus
|
||||||
immutable preparation-withdrawal operations. It never
|
immutable preparation-withdrawal operations. Program-owned deliveries also
|
||||||
|
carry the paired stable Program and Program-session identities. Android keeps
|
||||||
|
reading legacy deliveries that omitted this pair and accepts a later enrichment
|
||||||
|
only when all legacy fields and the reserved execution identity are identical.
|
||||||
|
It never
|
||||||
overloads mobile export, AI proposals or execution-drafts V1. Android validates
|
overloads mobile export, AI proposals or execution-drafts V1. Android validates
|
||||||
the complete artifact in the generation transaction and stores it as pending;
|
the complete artifact in the generation transaction and stores it as pending;
|
||||||
start remains an explicit foreground action. Desktop records the exact
|
start remains an explicit foreground action. Desktop records the exact
|
||||||
|
|
@ -246,6 +250,11 @@ is idempotent; identity reuse or regression is a hard conflict. Because the
|
||||||
descriptor is optional, peers predating this companion keep exchanging all
|
descriptor is optional, peers predating this companion keep exchanging all
|
||||||
existing required domains without a capability failure.
|
existing required domains without a capability failure.
|
||||||
|
|
||||||
|
For Program-owned manual preparations, an acknowledged delivery is republished
|
||||||
|
while its matching canonical execution evidence is absent on the PC. This
|
||||||
|
bounded recovery lets Android enrich legacy deliveries with Program provenance
|
||||||
|
and stops automatically when the execution returns through this companion.
|
||||||
|
|
||||||
Desktop schema v28 adds terminal `deleted` provenance for a completed session
|
Desktop schema v28 adds terminal `deleted` provenance for a completed session
|
||||||
removed through causal history deletion. A retained older
|
removed through causal history deletion. A retained older
|
||||||
`program-executions-v1` fact is skipped only when the matching session has a
|
`program-executions-v1` fact is skipped only when the matching session has a
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,13 @@ through Program deletion, restart and old-generation replay without duplication
|
||||||
or resurrection. The desktop populated migration test now continues through
|
or resurrection. The desktop populated migration test now continues through
|
||||||
schema v27 and proves the new execution ledger starts empty.
|
schema v27 and proves the new execution ledger starts empty.
|
||||||
|
|
||||||
|
The delivered-Program regression covers the previously omitted provenance
|
||||||
|
pair end to end: legacy preparation receipt, start, performed capture,
|
||||||
|
finalization, exact-payload enrichment, completed Android projection, execution
|
||||||
|
companion output and restart. Export tests assert both stable IDs, the C Web API
|
||||||
|
asserts `completed`, and the Web component verifies `1/24`, `Effectuée` and the
|
||||||
|
absence of a preparation action on the completed session.
|
||||||
|
|
||||||
`schema_v28_migration` migrates a populated v27 fixture, preserves Program,
|
`schema_v28_migration` migrates a populated v27 fixture, preserves Program,
|
||||||
session and execution identities byte-for-byte, admits the terminal deleted
|
session and execution identities byte-for-byte, admits the terminal deleted
|
||||||
state, reports `integrity_check=ok` and an empty foreign-key check, and proves
|
state, reports `integrity_check=ok` and an empty foreign-key check, and proves
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ class SessionPreparationExportTest(unittest.TestCase):
|
||||||
current_revision_id TEXT NOT NULL,
|
current_revision_id TEXT NOT NULL,
|
||||||
source_proposal_id TEXT,
|
source_proposal_id TEXT,
|
||||||
source_payload_sha256 TEXT,
|
source_payload_sha256 TEXT,
|
||||||
|
source_program_id TEXT,
|
||||||
|
source_program_session_id TEXT,
|
||||||
withdrawn_at TEXT
|
withdrawn_at TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE session_preparation_revisions(
|
CREATE TABLE session_preparation_revisions(
|
||||||
|
|
@ -56,6 +58,13 @@ class SessionPreparationExportTest(unittest.TestCase):
|
||||||
state TEXT NOT NULL,
|
state TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE program_session_executions(
|
||||||
|
program_session_id TEXT PRIMARY KEY,
|
||||||
|
program_id TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL UNIQUE,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
observed_at TEXT NOT NULL
|
||||||
|
);
|
||||||
CREATE TABLE session_preparation_withdrawals(
|
CREATE TABLE session_preparation_withdrawals(
|
||||||
withdrawal_id TEXT PRIMARY KEY,
|
withdrawal_id TEXT PRIMARY KEY,
|
||||||
preparation_id TEXT NOT NULL,
|
preparation_id TEXT NOT NULL,
|
||||||
|
|
@ -77,12 +86,14 @@ class SessionPreparationExportTest(unittest.TestCase):
|
||||||
delivery = f"spd_{suffix}"
|
delivery = f"spd_{suffix}"
|
||||||
execution = f"se_{suffix}"
|
execution = f"se_{suffix}"
|
||||||
self.database.execute(
|
self.database.execute(
|
||||||
"INSERT INTO session_preparations VALUES(?,?,?,?,?)",
|
"INSERT INTO session_preparations VALUES(?,?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
preparation,
|
preparation,
|
||||||
revision,
|
revision,
|
||||||
"aid_11111111-1111-4111-8111-111111111111",
|
"aid_11111111-1111-4111-8111-111111111111",
|
||||||
"a" * 64,
|
"a" * 64,
|
||||||
|
"pg_11111111-1111-4111-8111-111111111111",
|
||||||
|
"pgs_22222222-2222-4222-8222-222222222222",
|
||||||
"2026-09-18T12:00:00Z" if withdrawn else None,
|
"2026-09-18T12:00:00Z" if withdrawn else None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -120,6 +131,14 @@ class SessionPreparationExportTest(unittest.TestCase):
|
||||||
self.assertEqual("trainlog-session-preparations", payload["format"])
|
self.assertEqual("trainlog-session-preparations", payload["format"])
|
||||||
self.assertEqual(2, payload["version"])
|
self.assertEqual(2, payload["version"])
|
||||||
self.assertEqual([f"spd_{kept}"], [row["delivery_id"] for row in payload["deliveries"]])
|
self.assertEqual([f"spd_{kept}"], [row["delivery_id"] for row in payload["deliveries"]])
|
||||||
|
self.assertEqual(
|
||||||
|
"pg_11111111-1111-4111-8111-111111111111",
|
||||||
|
payload["deliveries"][0]["source_program_id"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"pgs_22222222-2222-4222-8222-222222222222",
|
||||||
|
payload["deliveries"][0]["source_program_session_id"],
|
||||||
|
)
|
||||||
self.assertEqual(1, len(payload["withdrawals"]))
|
self.assertEqual(1, len(payload["withdrawals"]))
|
||||||
removal = payload["withdrawals"][0]
|
removal = payload["withdrawals"][0]
|
||||||
self.assertEqual(f"spw_{withdrawn}", removal["withdrawal_id"])
|
self.assertEqual(f"spw_{withdrawn}", removal["withdrawal_id"])
|
||||||
|
|
@ -147,6 +166,35 @@ class SessionPreparationExportTest(unittest.TestCase):
|
||||||
self.assertEqual([], payload["deliveries"])
|
self.assertEqual([], payload["deliveries"])
|
||||||
self.assertEqual([], payload["withdrawals"])
|
self.assertEqual([], payload["withdrawals"])
|
||||||
|
|
||||||
|
def test_acknowledged_program_delivery_is_republished_until_execution_returns(self):
|
||||||
|
suffix = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
||||||
|
self.add_preparation(suffix, withdrawn=False)
|
||||||
|
self.database.execute(
|
||||||
|
"UPDATE session_preparation_deliveries SET state='acknowledged'"
|
||||||
|
)
|
||||||
|
self.database.commit()
|
||||||
|
|
||||||
|
payload = export_session_preparations.build_export(self.database)
|
||||||
|
|
||||||
|
self.assertEqual([f"spd_{suffix}"], [row["delivery_id"] for row in payload["deliveries"]])
|
||||||
|
self.assertEqual("remote_unknown", payload["deliveries"][0]["state"])
|
||||||
|
|
||||||
|
self.database.execute(
|
||||||
|
"INSERT INTO program_session_executions VALUES(?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
"pgs_22222222-2222-4222-8222-222222222222",
|
||||||
|
"pg_11111111-1111-4111-8111-111111111111",
|
||||||
|
f"se_{suffix}",
|
||||||
|
"completed",
|
||||||
|
"2026-09-18T12:10:00Z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.database.commit()
|
||||||
|
|
||||||
|
payload = export_session_preparations.build_export(self.database)
|
||||||
|
|
||||||
|
self.assertEqual([], payload["deliveries"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,21 @@ def build_export(connection: sqlite3.Connection) -> dict:
|
||||||
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||||
deliveries = []
|
deliveries = []
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,"
|
||||||
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256 "
|
"CASE WHEN d.state='acknowledged' THEN 'remote_unknown' ELSE d.state END,"
|
||||||
|
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256,"
|
||||||
|
"p.source_program_id,p.source_program_session_id "
|
||||||
"FROM session_preparation_deliveries d "
|
"FROM session_preparation_deliveries d "
|
||||||
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
||||||
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
|
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
|
||||||
"WHERE p.withdrawn_at IS NULL AND d.state IN('pending','remote_unknown') "
|
"WHERE p.withdrawn_at IS NULL AND ("
|
||||||
|
"d.state IN('pending','remote_unknown') OR ("
|
||||||
|
"d.state='acknowledged' AND p.source_program_id IS NOT NULL AND "
|
||||||
|
"p.source_program_session_id IS NOT NULL AND NOT EXISTS("
|
||||||
|
"SELECT 1 FROM program_session_executions x "
|
||||||
|
"WHERE x.program_session_id=p.source_program_session_id "
|
||||||
|
"AND x.program_id=p.source_program_id AND x.session_id=d.execution_session_id"
|
||||||
|
"))) "
|
||||||
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
|
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
|
||||||
(MAX_DELIVERIES + 1,),
|
(MAX_DELIVERIES + 1,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
@ -48,6 +57,7 @@ def build_export(connection: sqlite3.Connection) -> dict:
|
||||||
"execution_session_id": row[3], "state": row[4], "title": row[5],
|
"execution_session_id": row[3], "state": row[4], "title": row[5],
|
||||||
"session_type": row[6], "planned_for": row[7], "notes": row[8],
|
"session_type": row[6], "planned_for": row[7], "notes": row[8],
|
||||||
"source_proposal_id": row[9], "source_payload_sha256": row[10],
|
"source_proposal_id": row[9], "source_payload_sha256": row[10],
|
||||||
|
"source_program_id": row[11], "source_program_session_id": row[12],
|
||||||
"occurrences": [{
|
"occurrences": [{
|
||||||
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
|
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
|
||||||
"equipment_id": entry[3], "recording_mode": entry[4],
|
"equipment_id": entry[3], "recording_mode": entry[4],
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,26 @@ static bool import_archive_and_prepare(void) {
|
||||||
free(response);
|
free(response);
|
||||||
response = NULL;
|
response = NULL;
|
||||||
|
|
||||||
|
CHECK(sqlite3_exec(database->connection,
|
||||||
|
"INSERT INTO program_session_executions("
|
||||||
|
"program_session_id,program_id,session_id,state,observed_at) VALUES("
|
||||||
|
"'pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',"
|
||||||
|
"'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',"
|
||||||
|
"'se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee','completed',"
|
||||||
|
"'2026-09-21T12:00:00Z')",
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL) == SQLITE_OK);
|
||||||
|
CHECK(trainlog_web_programs_detail_json(
|
||||||
|
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(strstr(response, "\"execution_state\":\"completed\"") != NULL);
|
||||||
|
CHECK(strstr(response,
|
||||||
|
"\"execution_session_id\":"
|
||||||
|
"\"se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee\"") != NULL);
|
||||||
|
free(response);
|
||||||
|
response = NULL;
|
||||||
|
|
||||||
CHECK(trainlog_web_programs_archive_json(database,
|
CHECK(trainlog_web_programs_archive_json(database,
|
||||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
revision,
|
revision,
|
||||||
|
|
|
||||||
|
|
@ -206,4 +206,56 @@ describe('Programs tab', () => {
|
||||||
expect(fetchMock.mock.calls.filter(([, options]) =>
|
expect(fetchMock.mock.calls.filter(([, options]) =>
|
||||||
(options as RequestInit | undefined)?.method === 'POST')).toHaveLength(1)
|
(options as RequestInit | undefined)?.method === 'POST')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('counts a completed program session and no longer offers to prepare it', async () => {
|
||||||
|
const sessions = Array.from({ length: 24 }, (_, index) => ({
|
||||||
|
program_session_id: `pgs_${index}`,
|
||||||
|
position: index,
|
||||||
|
title: index === 0 ? 'S1 A — Ceinture abdominale + jambes' : `Séance ${index + 1}`,
|
||||||
|
session_type: 'training',
|
||||||
|
planned_for: null,
|
||||||
|
note: null,
|
||||||
|
execution_state: index === 0 ? 'completed' : 'todo',
|
||||||
|
execution_session_id: index === 0 ? 'se_completed' : null,
|
||||||
|
occurrences: [],
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url === '/api/v1/sessions/program/pg_test') {
|
||||||
|
return jsonResponse({
|
||||||
|
api_version: 1,
|
||||||
|
program_id: 'pg_test',
|
||||||
|
title: 'Programme 24 séances',
|
||||||
|
note: null,
|
||||||
|
state: 'active',
|
||||||
|
start_date: null,
|
||||||
|
end_date: null,
|
||||||
|
created_at: '2026-09-18T12:00:00Z',
|
||||||
|
updated_at: '2026-09-21T12:00:00Z',
|
||||||
|
revision_id: 'revision',
|
||||||
|
source_format: 'trainlog-program',
|
||||||
|
source_version: 1,
|
||||||
|
source_payload_sha256: 'a'.repeat(64),
|
||||||
|
sessions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected request ${url}`)
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(<ProgramsTab detailProgramId="pg_test" onOpen={vi.fn()} onBack={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(await screen.findByText('1/24')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('4% effectué')).toBeInTheDocument()
|
||||||
|
const completed = screen
|
||||||
|
.getByText(/S1 A — Ceinture abdominale \+ jambes/)
|
||||||
|
.closest('li')
|
||||||
|
expect(completed).not.toBeNull()
|
||||||
|
expect(within(completed as HTMLElement).getByText('Effectuée')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
within(completed as HTMLElement).queryByRole('button', {
|
||||||
|
name: 'Créer une préparation',
|
||||||
|
}),
|
||||||
|
).toBeNull()
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Créer une préparation' })).toHaveLength(23)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
waitFor,
|
waitFor,
|
||||||
within,
|
within,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
SleepEntry,
|
SleepEntry,
|
||||||
SleepEntryInput,
|
SleepEntryInput,
|
||||||
|
|
@ -170,6 +170,8 @@ const dayAEntry = (): SleepEntry => ({
|
||||||
|
|
||||||
describe("SleepDiaryWorkspace", () => {
|
describe("SleepDiaryWorkspace", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
vi.setSystemTime(new Date("2026-09-21T16:00:00+02:00"));
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
api.nextId = 0;
|
api.nextId = 0;
|
||||||
api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
|
api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
|
||||||
|
|
@ -181,6 +183,10 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["bed_time", "Mise au lit", "22:30", null],
|
["bed_time", "Mise au lit", "22:30", null],
|
||||||
["sleep", "Sommeil", "23:00", "06:30"],
|
["sleep", "Sommeil", "23:00", "06:30"],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue