Compare commits
3 commits
8f8b361915
...
5a7f80049a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a7f80049a | ||
|
|
41d0f1ff96 | ||
|
|
0750a47cef |
19 changed files with 589 additions and 15 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -61,6 +61,16 @@
|
|||
timeline, calibration provenance and exact raw HR evidence for instruction
|
||||
events carrying BPM before accepting them.
|
||||
|
||||
- Fixed live Cardio guidance after changing an already-started manual draft
|
||||
from Training to Cardio. Draft persistence now atomically reconciles the
|
||||
same active heart-rate capture from `session` to `cardio` without replacing
|
||||
its identity or losing existing BPM/RR samples and sequence. A transition
|
||||
away from Cardio remains possible before Cardio-only facts exist, but is
|
||||
rejected once durable guidance or calibration facts would make it
|
||||
ambiguous. Repository coverage reproduces the real ordering through an
|
||||
active exercise and guidance event; a private vc46 device re-smoke remains
|
||||
required.
|
||||
|
||||
- Added factual synchronized heart-rate timelines to the local Web. One bounded C17 read model and loopback GET endpoint now project raw BPM/RR measurements with canonical workout exercise intervals or Sleep Diary/medication markers. Cardio history additionally presents resolved phase targets, instruction changes and calibration peak/recovery facts. History and Sleep reuse one responsive SVG component; full-sample min/max/mean statistics remain exact while only very large chart projections retain bounded bucket extrema. No sleep stage, HRV, readiness or medical inference is added.
|
||||
|
||||
Development opened after stable v0.1.4 release.
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ private const val MAX_BT_FILE_BYTES = 64L * 1024L * 1024L
|
|||
private const val MAX_BT_FILES = 128
|
||||
private const val AUTO_REQUEST_COOLDOWN_MS = 5L * 60L * 1000L
|
||||
|
||||
internal val BLUETOOTH_ANDROID_PULL_COORDINATION =
|
||||
listOf(
|
||||
"android-peer-v1.json",
|
||||
FULL_GENERATION_REQUEST_NAME,
|
||||
LEGACY_SYNC_REQUEST_NAME,
|
||||
"android-generation-v1.json",
|
||||
"android-consumption-ack-v1.json",
|
||||
"android-archive-acknowledgements-v1.json",
|
||||
"android-generation-error-v1.json",
|
||||
)
|
||||
|
||||
internal data class BondedBluetoothDesktop(
|
||||
val name: String,
|
||||
val address: String,
|
||||
|
|
@ -248,14 +259,7 @@ private class BluetoothExchangeArchive(
|
|||
val totals = longArrayOf(0L, 0L)
|
||||
try {
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(output))).use { zip ->
|
||||
listOf(
|
||||
"android-peer-v1.json",
|
||||
FULL_GENERATION_REQUEST_NAME,
|
||||
LEGACY_SYNC_REQUEST_NAME,
|
||||
"android-generation-v1.json",
|
||||
"android-consumption-ack-v1.json",
|
||||
"android-generation-error-v1.json",
|
||||
).forEach { name ->
|
||||
BLUETOOTH_ANDROID_PULL_COORDINATION.forEach { name ->
|
||||
addFile(zip, File(exchangeDirectory, name), name, seen, totals)
|
||||
}
|
||||
val reference = File(exchangeDirectory, "android-generation-v1.json")
|
||||
|
|
|
|||
|
|
@ -195,7 +195,8 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
|
|||
localRoot.mkdirs()
|
||||
val coordination = listOf(
|
||||
"android-peer-v1.json", "android-generation-v1.json", "android-consumption-ack-v1.json",
|
||||
"android-generation-error-v1.json", "request-v1.json",
|
||||
"android-archive-acknowledgements-v1.json", "android-generation-error-v1.json",
|
||||
"request-v1.json",
|
||||
"desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json",
|
||||
"desktop-generation-v1.json",
|
||||
)
|
||||
|
|
@ -261,6 +262,13 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
|
|||
upload("android-generation-v1.json", reference)
|
||||
}
|
||||
|
||||
fun pushRecoveryAcknowledgements(localRoot: File) {
|
||||
upload(
|
||||
"android-archive-acknowledgements-v1.json",
|
||||
File(localRoot, "android-archive-acknowledgements-v1.json"),
|
||||
)
|
||||
}
|
||||
|
||||
fun pushAcknowledgement(localRoot: File) {
|
||||
upload(
|
||||
"android-consumption-ack-v1.json",
|
||||
|
|
@ -318,6 +326,10 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
|||
local,
|
||||
Duration.ofMinutes(10),
|
||||
afterPeerPublication = { transport.pushPeer(local) },
|
||||
afterRecoveryAcknowledgements = {
|
||||
stage = "recovery-acknowledgement"
|
||||
transport.pushRecoveryAcknowledgements(local)
|
||||
},
|
||||
afterPublication = {
|
||||
stage = "generation-publication"
|
||||
transport.pushGeneration(local)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ internal class SyncGenerationCoordinator(
|
|||
) {
|
||||
private val service = SyncGenerationService(repository)
|
||||
|
||||
private companion object {
|
||||
const val ACK_RECOVERY_CAPABILITY = "generation-ack-recovery-v1"
|
||||
const val MAX_COORDINATION_BYTES = 65_536
|
||||
}
|
||||
|
||||
private fun phase(
|
||||
runId: String?,
|
||||
phase: String,
|
||||
|
|
@ -196,6 +201,45 @@ internal class SyncGenerationCoordinator(
|
|||
if (actual != expected) throw SyncGenerationException("invalid $label fields")
|
||||
}
|
||||
|
||||
private fun requestSupports(request: JSONObject, capability: String): Boolean {
|
||||
val capabilities = request.optJSONArray("capabilities") ?: return false
|
||||
for (index in 0 until capabilities.length()) {
|
||||
if (capabilities.optString(index) == capability) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun recoveryAcknowledgements(
|
||||
desktopPeerId: String,
|
||||
androidPeerId: String,
|
||||
): JSONArray =
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
val acknowledgements = JSONArray()
|
||||
db.rawQuery(
|
||||
"SELECT ack_json FROM sync_consumed_generations WHERE producer_peer_id=? " +
|
||||
"AND consumer_peer_id=? AND result IN('consumed','rejected') " +
|
||||
"ORDER BY consumed_at DESC,generation_id DESC LIMIT 32",
|
||||
arrayOf(desktopPeerId, androidPeerId),
|
||||
)
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val acknowledgement = JSONObject(cursor.getString(0))
|
||||
/* ACKs written before cross-runtime rejection diagnostics
|
||||
* were normalized can hash '/' differently in Android
|
||||
* and Python JSON canonicalizers. They remain immutable
|
||||
* evidence, but must not block recovery of newer,
|
||||
* compatible ACKs. Consumed ACKs are unaffected. */
|
||||
if (
|
||||
acknowledgement.optString("result") != "rejected" ||
|
||||
!acknowledgement.optString("diagnostic").contains("/")
|
||||
) {
|
||||
acknowledgements.put(acknowledgement)
|
||||
}
|
||||
}
|
||||
}
|
||||
acknowledgements
|
||||
}
|
||||
|
||||
private fun reconcileArchiveAcknowledgements(
|
||||
raw: ByteArray,
|
||||
runId: String,
|
||||
|
|
@ -299,6 +343,7 @@ internal class SyncGenerationCoordinator(
|
|||
"mobile-history-v4",
|
||||
"execution-draft-v1",
|
||||
"generation-archive-v1",
|
||||
ACK_RECOVERY_CAPABILITY,
|
||||
"session-preparations-v2",
|
||||
"programs-v1",
|
||||
)
|
||||
|
|
@ -359,6 +404,7 @@ internal class SyncGenerationCoordinator(
|
|||
directory: File,
|
||||
timeout: Duration = Duration.ofMinutes(5),
|
||||
afterPeerPublication: (() -> Unit)? = null,
|
||||
afterRecoveryAcknowledgements: (() -> Unit)? = null,
|
||||
afterPublication: (() -> Unit)? = null,
|
||||
pollTransport: (() -> Unit)? = null,
|
||||
afterAcknowledgement: (() -> Unit)? = null,
|
||||
|
|
@ -433,6 +479,44 @@ internal class SyncGenerationCoordinator(
|
|||
request.getString("desktop_peer_id"),
|
||||
)
|
||||
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
|
||||
if (requestSupports(request, ACK_RECOVERY_CAPABILITY)) {
|
||||
/* WHY: Android may commit consumption after the desktop
|
||||
* producer has already published its immutable generation, then
|
||||
* transport can fail before the ACK reaches that producer.
|
||||
* CONTRACT: return only exact durable consumer ACK evidence,
|
||||
* bounded to the newest 32 rows, before either side captures a
|
||||
* new generation. INVARIANT: recovery never fabricates lineage
|
||||
* and never rewrites Android consumption history. */
|
||||
val recoveryPath =
|
||||
File(directory, "android-archive-acknowledgements-v1.json")
|
||||
val recoveryEnvelope =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-archive-acknowledgements")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("android_peer_id", peer)
|
||||
.put("desktop_peer_id", request.getString("desktop_peer_id"))
|
||||
.put(
|
||||
"acknowledgements",
|
||||
recoveryAcknowledgements(
|
||||
request.getString("desktop_peer_id"),
|
||||
peer,
|
||||
),
|
||||
)
|
||||
.toString()
|
||||
if (recoveryEnvelope.toByteArray(Charsets.UTF_8).size > MAX_COORDINATION_BYTES)
|
||||
throw SyncGenerationException(
|
||||
"Android archive acknowledgement envelope exceeds bound"
|
||||
)
|
||||
publish(recoveryPath, recoveryEnvelope)
|
||||
visibility.confirm(listOf(recoveryPath))
|
||||
afterRecoveryAcknowledgements?.invoke()
|
||||
phase(
|
||||
runId,
|
||||
"android_archive_ack_published",
|
||||
recoveryPath.name,
|
||||
)
|
||||
}
|
||||
/* WHY: transport can fail after immutable generation publication.
|
||||
* CONTRACT: an explicit retry for the same run republishes that
|
||||
* exact generation and resumes ACK handling; it never recaptures
|
||||
|
|
|
|||
|
|
@ -10404,6 +10404,21 @@ class TrainlogRepository(
|
|||
db: SQLiteDatabase,
|
||||
draft: ActiveSessionDraft,
|
||||
) {
|
||||
val persistedSession =
|
||||
db.rawQuery(
|
||||
"SELECT session_id,session_kind FROM active_session_draft WHERE id=?",
|
||||
arrayOf(ACTIVE_DRAFT_ID.toString()),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1)
|
||||
}
|
||||
if (
|
||||
persistedSession != null &&
|
||||
persistedSession.second == SessionType.CARDIO.wireValue &&
|
||||
draft.sessionType != SessionType.CARDIO &&
|
||||
hasCardioOnlyFacts(db, persistedSession.first)
|
||||
) {
|
||||
error("A cardio session with guidance or calibration facts cannot change type.")
|
||||
}
|
||||
val selectedRowId =
|
||||
draft.form.selectedExercise
|
||||
?.let {
|
||||
|
|
@ -10464,6 +10479,8 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
reconcileActiveSessionHeartRateContext(db, draft.sessionType)
|
||||
|
||||
ensureSessionTimelineForPersistedDraft(db, draft)
|
||||
|
||||
/* CONTRACT: draft persistence currently rebuilds occurrence rows.
|
||||
|
|
@ -10630,6 +10647,48 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
private fun hasCardioOnlyFacts(
|
||||
db: SQLiteDatabase,
|
||||
sessionId: String,
|
||||
): Boolean =
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM cardio_guidance_runs WHERE session_id=? " +
|
||||
"UNION ALL SELECT 1 FROM cardio_calibrations WHERE session_id=? LIMIT 1",
|
||||
arrayOf(sessionId, sessionId),
|
||||
).use { it.moveToFirst() }
|
||||
|
||||
private fun reconcileActiveSessionHeartRateContext(
|
||||
db: SQLiteDatabase,
|
||||
sessionType: SessionType,
|
||||
) {
|
||||
val sessionId =
|
||||
db.rawQuery(
|
||||
"SELECT session_id FROM active_session_draft WHERE id=?",
|
||||
arrayOf(ACTIVE_DRAFT_ID.toString()),
|
||||
).use { cursor ->
|
||||
check(cursor.moveToFirst())
|
||||
cursor.getString(0)
|
||||
}
|
||||
val expectedContext =
|
||||
if (sessionType == SessionType.CARDIO) {
|
||||
HeartRateContextKind.CARDIO
|
||||
} else {
|
||||
HeartRateContextKind.SESSION
|
||||
}
|
||||
|
||||
/* WHY: the sensor service can open the capture while a new manual draft still has its
|
||||
* default Training type, before the user selects Cardio.
|
||||
* CONTRACT: every successful mutable draft-type save atomically reconciles the same
|
||||
* session's active capture; cardio-only durable facts prevent a later transition away.
|
||||
* INVARIANT: capture identity, samples, RR facts, sequence and the single active slot are
|
||||
* preserved; only the compatible session/cardio context discriminator may change. */
|
||||
db.execSQL(
|
||||
"UPDATE heart_rate_captures SET context_kind=? WHERE active_slot=1 " +
|
||||
"AND context_id=? AND context_kind IN('session','cardio') AND context_kind<>?",
|
||||
arrayOf(expectedContext.wireValue, sessionId, expectedContext.wireValue),
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertCompletedSession(
|
||||
db: SQLiteDatabase,
|
||||
draft: SessionDraft,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BluetoothSyncTransportContractTest {
|
||||
@Test
|
||||
fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() {
|
||||
assertTrue(
|
||||
BLUETOOTH_ANDROID_PULL_COORDINATION.contains(
|
||||
"android-archive-acknowledgements-v1.json"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
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.CardioGuidanceInstruction
|
||||
|
|
@ -9,6 +10,7 @@ import com.labfytools.trainlog.model.CardioGuidedPhase
|
|||
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
|
|
@ -81,6 +83,201 @@ class CardioGuidanceRepositoryTest {
|
|||
return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trainingCaptureTransitionsToCardioWithoutStarvingGuidance() {
|
||||
val name = "guidance-transition-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val exercise =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Vélo guidé après transition",
|
||||
RecordingMode.CONTINUOUS,
|
||||
TrackingMode.DURATION,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||
val training =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
val base = OffsetDateTime.parse(checkNotNull(training.startedAt))
|
||||
val firstMeasurement =
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = 101,
|
||||
sensorContactDetected = true,
|
||||
energyExpended = 12,
|
||||
rrIntervals1024 = listOf(611),
|
||||
)
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
base.plusSeconds(1).toString(),
|
||||
firstMeasurement,
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val originalCapture = checkNotNull(repository.activeHeartRateCapture())
|
||||
assertEquals(HeartRateContextKind.SESSION, originalCapture.contextKind)
|
||||
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
|
||||
)
|
||||
val transitionedCapture = checkNotNull(repository.activeHeartRateCapture())
|
||||
assertEquals(originalCapture.captureId, transitionedCapture.captureId)
|
||||
assertEquals(HeartRateContextKind.CARDIO, transitionedCapture.contextKind)
|
||||
|
||||
val entry =
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
plan = SessionExercisePlan(sets = 0, durationSeconds = 60),
|
||||
)
|
||||
val cardio =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(cardio.copy(exercises = listOf(entry))),
|
||||
)
|
||||
assertTrue(
|
||||
repository.startActiveSessionExercise(entry.entryId, base.plusSeconds(2).toString()) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
val target = CardioTargetSnapshot(100, 110)
|
||||
val phase =
|
||||
CardioGuidedPhase(
|
||||
phaseId = "cgp_" + UUID.randomUUID(),
|
||||
kind = CardioPhaseKind.WARMUP,
|
||||
target = target,
|
||||
exitCondition = CardioPhaseExitCondition.FixedDuration(60),
|
||||
)
|
||||
assertTrue(
|
||||
repository.startCardioGuidancePhase(
|
||||
entry.entryId,
|
||||
phase,
|
||||
base.plusSeconds(3).toString(),
|
||||
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
||||
)
|
||||
val secondAt = base.plusSeconds(4).toString()
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
secondAt,
|
||||
firstMeasurement.copy(bpm = 99),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
repository.recordCardioGuidanceOutput(
|
||||
phase.phaseId,
|
||||
secondAt,
|
||||
CardioGuidanceOutput(CardioGuidanceInstruction.ACCELERATE, 99, target),
|
||||
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
||||
)
|
||||
assertEquals(
|
||||
listOf(CardioGuidanceInstruction.ACCELERATE),
|
||||
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
|
||||
)
|
||||
|
||||
val lateReverse =
|
||||
repository.saveActiveSessionDraft(
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
||||
.draft
|
||||
.copy(sessionType = SessionType.TRAINING),
|
||||
)
|
||||
assertTrue(lateReverse is ActiveDraftMutationResult.Error)
|
||||
assertEquals(
|
||||
SessionType.CARDIO,
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
||||
.draft
|
||||
.sessionType,
|
||||
)
|
||||
assertEquals(
|
||||
HeartRateContextKind.CARDIO,
|
||||
checkNotNull(repository.activeHeartRateCapture()).contextKind,
|
||||
)
|
||||
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
db.rawQuery(
|
||||
"SELECT sequence,bpm,exercise_entry_id FROM heart_rate_samples " +
|
||||
"WHERE capture_id=? ORDER BY sequence",
|
||||
arrayOf(originalCapture.captureId),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(0L, cursor.getLong(0))
|
||||
assertEquals(101, cursor.getInt(1))
|
||||
assertTrue(cursor.isNull(2))
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(1L, cursor.getLong(0))
|
||||
assertEquals(99, cursor.getInt(1))
|
||||
assertEquals(entry.entryId, cursor.getString(2))
|
||||
assertTrue(!cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.discardActiveSessionDraft())
|
||||
assertNull(repository.activeHeartRateCapture())
|
||||
assertNull(repository.activeCardioGuidancePhase())
|
||||
assertTrue(repository.listActiveSessionExerciseTimings().isEmpty())
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardioCaptureCanReturnToTrainingBeforeCardioOnlyFactsExist() {
|
||||
val name = "guidance-early-reverse-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||
val training =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
val observedAt =
|
||||
OffsetDateTime.parse(checkNotNull(training.startedAt)).plusSeconds(1).toString()
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
observedAt,
|
||||
ParsedHeartRateMeasurement(103, null, null, listOf(600)),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
|
||||
)
|
||||
val cardio =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(cardio.copy(sessionType = SessionType.TRAINING)),
|
||||
)
|
||||
val reversed = checkNotNull(repository.activeHeartRateCapture())
|
||||
assertEquals(captureId, reversed.captureId)
|
||||
assertEquals(HeartRateContextKind.SESSION, reversed.contextKind)
|
||||
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*),MIN(bpm) FROM heart_rate_samples WHERE capture_id=?",
|
||||
arrayOf(captureId),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
assertEquals(103, cursor.getInt(1))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() {
|
||||
val active = activeCardio()
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ class SyncDeploymentConversationTest {
|
|||
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, databaseName)
|
||||
var localWorker: Process? = null
|
||||
var lostDesktopAckGeneration: String? = null
|
||||
try {
|
||||
transport.mkdirs()
|
||||
// A previous interrupted exchange leaves durable coordination
|
||||
|
|
@ -180,6 +181,7 @@ class SyncDeploymentConversationTest {
|
|||
"full_generation_trigger_published",
|
||||
"generation_request_observed",
|
||||
"archive_ack_observed",
|
||||
"android_archive_ack_published",
|
||||
"generation_capture_started",
|
||||
"generation_captured",
|
||||
"generation_objects_published",
|
||||
|
|
@ -215,6 +217,71 @@ class SyncDeploymentConversationTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (suppliedTransport == null && it == 11) {
|
||||
/* Reproduce the inverse deployed failure: Android has
|
||||
* durably consumed the newest desktop generation, but its
|
||||
* ACK was lost before the desktop producer committed it.
|
||||
* The next conversation must recover that exact Android
|
||||
* evidence before capturing another desktop generation. */
|
||||
SQLiteDatabase.openDatabase(
|
||||
desktop.absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READWRITE,
|
||||
).use { database ->
|
||||
val generation =
|
||||
database.rawQuery(
|
||||
"SELECT generation_id FROM sync_generations " +
|
||||
"WHERE status='acknowledged' ORDER BY generated_at DESC LIMIT 1",
|
||||
null,
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
cursor.getString(0)
|
||||
}
|
||||
database.delete(
|
||||
"sync_acknowledgements",
|
||||
"generation_id=?",
|
||||
arrayOf(generation),
|
||||
)
|
||||
database.execSQL(
|
||||
"UPDATE sync_generations SET status='waiting_acknowledgement'," +
|
||||
"acknowledged_at=NULL WHERE generation_id=?",
|
||||
arrayOf(generation),
|
||||
)
|
||||
database.rawQuery(
|
||||
"SELECT status FROM sync_generations WHERE generation_id=?",
|
||||
arrayOf(generation),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals("waiting_acknowledgement", cursor.getString(0))
|
||||
}
|
||||
lostDesktopAckGeneration = generation
|
||||
}
|
||||
}
|
||||
if (suppliedTransport == null && it == 12) {
|
||||
val recovered = checkNotNull(lostDesktopAckGeneration)
|
||||
SQLiteDatabase.openDatabase(
|
||||
desktop.absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { database ->
|
||||
database.rawQuery(
|
||||
"SELECT status FROM sync_generations WHERE generation_id=?",
|
||||
arrayOf(recovered),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals("acknowledged", cursor.getString(0))
|
||||
}
|
||||
database.rawQuery(
|
||||
"SELECT parent_generation_id,status FROM sync_generations " +
|
||||
"ORDER BY generated_at DESC LIMIT 1",
|
||||
null,
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(recovered, cursor.getString(0))
|
||||
assertEquals("acknowledged", cursor.getString(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
localWorker = null
|
||||
}
|
||||
repository.close()
|
||||
|
|
|
|||
|
|
@ -156,8 +156,8 @@ parallel implementation of its rules.
|
|||
| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
|
||||
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
|
||||
| Cardio Web visualization | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS`: one bounded factual heart-rate timeline read model/HTTP endpoint powers Web History and Sleep with raw BPM/RR, real session/exercise/Sleep markers, guided targets/instructions and calibration facts; no sleep-stage or medical inference |
|
||||
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: complete native 111/111, Android 300 tests (295 pass + 5 historical skips), Web 183/183 + typecheck/build, vc43 in-place private update with schema v31/integrity/data preservation and live CYCPLUS 94–95 BPM foreground capture validated while locked |
|
||||
| Current operational cursor | `TRAINLOG_0_1_5_DEVICE_VISUAL_REVIEW_V1`: unlock the private phone and visually smoke-test the Android Sommeil, Calibration cardio and active guided-cardio presentation before any stable release decision |
|
||||
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: native 111/111; Android 303 tests (298 pass + 5 historical skips) and debug APK build; Web 183/183 + typecheck/build baseline. The vc45 device review proved that a Training → Cardio draft transition stranded the already-active HR capture; repository persistence now reconciles that capture atomically with sample continuity, and a vc46 re-smoke remains required. |
|
||||
| Current operational cursor | `TRAINLOG_0_1_5_DEVICE_VISUAL_REVIEW_V1`: deploy the separately built vc46 candidate to the private phone and re-smoke Sommeil, Calibration cardio and the corrected active guided-cardio Training → Cardio transition before any stable release decision |
|
||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@ Bluetooth synchronization transport contract.
|
|||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_0_1_5_DEVICE_VISUAL_REVIEW_V1
|
||||
```
|
||||
|
||||
The vc45 real-device smoke found that selecting Cardio after live capture had
|
||||
already started on the default Training draft left the durable capture tagged
|
||||
as `session`, starving Cardio guidance despite fresh global BPM. Repository
|
||||
persistence now reconciles the same capture atomically while retaining its
|
||||
identity, sample order and raw BPM/RR facts; automated coverage reproduces the
|
||||
transition through an active exercise and guidance event. The cursor remains
|
||||
pending until the corrected flow is re-smoked on the separately deployed vc46
|
||||
candidate.
|
||||
|
||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||
cursor: complete predecessor state, imported built-in authorization,
|
||||
cross-platform draft revision identity and resource-bound admission are closed.
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
self.assertIn("heart-rate-v1", inventory["protocols"])
|
||||
self.assertIn("session-timeline-v1", inventory["protocols"])
|
||||
self.assertIn("bluetooth-files-v1", inventory["protocols"])
|
||||
self.assertIn("generation-ack-recovery-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())
|
||||
|
|
|
|||
|
|
@ -116,6 +116,8 @@ class DriveTransportTest(unittest.TestCase):
|
|||
source = Path(raw) / "source"
|
||||
source.mkdir()
|
||||
self.generation(source)
|
||||
recovery = source / "android-archive-acknowledgements-v1.json"
|
||||
recovery.write_text('{"format":"trainlog-sync-archive-acknowledgements","version":1}')
|
||||
remote = MemoryDrive()
|
||||
drive.push(remote, source)
|
||||
destination = Path(raw) / "destination"
|
||||
|
|
@ -124,6 +126,10 @@ class DriveTransportTest(unittest.TestCase):
|
|||
(destination / "desktop-objects/generations/gen_one/history.json").read_bytes(),
|
||||
b"{}\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
(destination / "android-archive-acknowledgements-v1.json").read_bytes(),
|
||||
recovery.read_bytes(),
|
||||
)
|
||||
|
||||
remote.downloads.clear()
|
||||
drive.pull(remote, destination)
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ def main() -> int:
|
|||
"generation-manifest-v1",
|
||||
"generation-ack-v1",
|
||||
"generation-archive-v1",
|
||||
"generation-ack-recovery-v1",
|
||||
"bluetooth-files-v1",
|
||||
"session-preparations-v2",
|
||||
"ai-session-drafts-v2",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ COORDINATION = (
|
|||
"android-peer-v1.json",
|
||||
"android-generation-v1.json",
|
||||
"android-consumption-ack-v1.json",
|
||||
"android-archive-acknowledgements-v1.json",
|
||||
"android-generation-error-v1.json",
|
||||
"request-v1.json",
|
||||
"desktop-archive-acknowledgements-v1.json",
|
||||
|
|
|
|||
|
|
@ -791,8 +791,7 @@ def validate_ack(raw: bytes, pending: sqlite3.Row | tuple) -> dict:
|
|||
return value
|
||||
|
||||
|
||||
def accept_ack(database: Path, path: Path) -> str:
|
||||
raw = path.read_bytes()
|
||||
def accept_ack_bytes(database: Path, raw: bytes) -> str:
|
||||
with closing(connect_database(database)) as db:
|
||||
require_schema(db); db.execute("BEGIN IMMEDIATE")
|
||||
value = strict_json(raw, MAX_MANIFEST)
|
||||
|
|
@ -862,6 +861,10 @@ def accept_ack(database: Path, path: Path) -> str:
|
|||
db.commit(); return status
|
||||
|
||||
|
||||
def accept_ack(database: Path, path: Path) -> str:
|
||||
return accept_ack_bytes(database, path.read_bytes())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="mode", required=True)
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ def mirror_current_exchange(transport_root: Path, remote: str, timeout: int) ->
|
|||
"android-peer-v1.json",
|
||||
"android-generation-v1.json",
|
||||
"android-consumption-ack-v1.json",
|
||||
"android-archive-acknowledgements-v1.json",
|
||||
"android-generation-error-v1.json",
|
||||
"request-v1.json",
|
||||
"desktop-archive-acknowledgements-v1.json",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ CAPS = {
|
|||
"session-preparations-v2",
|
||||
}
|
||||
|
||||
ACK_RECOVERY_CAP = "generation-ack-recovery-v1"
|
||||
|
||||
MTP_OPERATION_TIMEOUT_SECONDS = 30.0
|
||||
MTP_POLL_INTERVAL_SECONDS = 3.0
|
||||
|
||||
|
|
@ -115,6 +117,67 @@ def recovery_acknowledgements(db, producer_peer_id: str, consumer_peer_id: str)
|
|||
]
|
||||
|
||||
|
||||
def recovery_acknowledgement_envelope(
|
||||
raw: bytes,
|
||||
run_id: str,
|
||||
android_peer_id: str,
|
||||
desktop_peer_id: str,
|
||||
) -> list[dict]:
|
||||
"""Validate the Android consumer's bounded ACK recovery envelope."""
|
||||
value = generation.strict_json(raw, 65536)
|
||||
expected = {
|
||||
"format",
|
||||
"version",
|
||||
"run_id",
|
||||
"android_peer_id",
|
||||
"desktop_peer_id",
|
||||
"acknowledgements",
|
||||
}
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or set(value) != expected
|
||||
or value.get("format") != "trainlog-sync-archive-acknowledgements"
|
||||
or type(value.get("version")) is not int
|
||||
or value.get("version") != 1
|
||||
or value.get("run_id") != run_id
|
||||
or value.get("android_peer_id") != android_peer_id
|
||||
or value.get("desktop_peer_id") != desktop_peer_id
|
||||
):
|
||||
raise RuntimeError("Android archive acknowledgement envelope is not correlated")
|
||||
acknowledgements = value.get("acknowledgements")
|
||||
if not isinstance(acknowledgements, list) or len(acknowledgements) > 32:
|
||||
raise RuntimeError("invalid Android archive acknowledgement count")
|
||||
if not all(isinstance(ack, dict) for ack in acknowledgements):
|
||||
raise RuntimeError("invalid Android archive acknowledgement")
|
||||
return acknowledgements
|
||||
|
||||
|
||||
def reconcile_recovery_acknowledgements(
|
||||
database: Path,
|
||||
raw: bytes,
|
||||
run_id: str,
|
||||
android_peer_id: str,
|
||||
desktop_peer_id: str,
|
||||
) -> int:
|
||||
"""Repair producer state only from exact ACK evidence retained by Android."""
|
||||
acknowledgements = recovery_acknowledgement_envelope(
|
||||
raw,
|
||||
run_id,
|
||||
android_peer_id,
|
||||
desktop_peer_id,
|
||||
)
|
||||
reconciled = 0
|
||||
for acknowledgement in acknowledgements:
|
||||
result = generation.accept_ack_bytes(
|
||||
database,
|
||||
generation.canonical(acknowledgement),
|
||||
)
|
||||
if result not in ("acknowledged", "rejected", "unchanged"):
|
||||
raise RuntimeError("unexpected recovered ACK result")
|
||||
reconciled += 1
|
||||
return reconciled
|
||||
|
||||
|
||||
def run_adapter(
|
||||
adapter: Path,
|
||||
operation: str,
|
||||
|
|
@ -630,7 +693,7 @@ def main() -> int:
|
|||
"run_id": args.run_id,
|
||||
"desktop_peer_id": desktop_peer,
|
||||
"android_peer_id": peer["peer_id"],
|
||||
"capabilities": sorted(CAPS),
|
||||
"capabilities": sorted(CAPS | {ACK_RECOVERY_CAP}),
|
||||
}
|
||||
request_path = args.transport_root / "request-v1.json"
|
||||
publish_json(request_path, request)
|
||||
|
|
@ -678,12 +741,36 @@ def main() -> int:
|
|||
producer_peer_id=peer["peer_id"],
|
||||
consumer_peer_id=desktop_peer,
|
||||
)
|
||||
inbound_ref = args.transport_root / "android-generation-v1.json"
|
||||
|
||||
def pump_inbound():
|
||||
if pump is not None:
|
||||
pump()
|
||||
correlated_peer_error(args.transport_root, args.run_id)
|
||||
|
||||
if ACK_RECOVERY_CAP in set(peer.get("capabilities", [])):
|
||||
android_archive_ack = args.transport_root / "android-archive-acknowledgements-v1.json"
|
||||
wait_json(
|
||||
android_archive_ack,
|
||||
deadline,
|
||||
lambda value: value.get("run_id") == args.run_id
|
||||
and value.get("android_peer_id") == peer["peer_id"]
|
||||
and value.get("desktop_peer_id") == desktop_peer,
|
||||
pump_inbound,
|
||||
)
|
||||
recovered = reconcile_recovery_acknowledgements(
|
||||
args.database,
|
||||
bounded(android_archive_ack),
|
||||
args.run_id,
|
||||
peer["peer_id"],
|
||||
desktop_peer,
|
||||
)
|
||||
emit(
|
||||
args.run_id,
|
||||
"android_archive_ack_observed",
|
||||
recovered_acknowledgements=recovered,
|
||||
)
|
||||
|
||||
inbound_ref = args.transport_root / "android-generation-v1.json"
|
||||
inbound = wait_json(
|
||||
inbound_ref, deadline, lambda value: value.get("run_id") == args.run_id, pump_inbound
|
||||
)
|
||||
|
|
|
|||
|
|
@ -622,6 +622,14 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
|
|||
status = pull_named_file(
|
||||
io, &selection, selection.root, "android-consumption-ack-v1.json", local, false);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = pull_named_file(io,
|
||||
&selection,
|
||||
selection.root,
|
||||
"android-archive-acknowledgements-v1.json",
|
||||
local,
|
||||
false);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = pull_named_file(
|
||||
io, &selection, selection.root, "android-generation-error-v1.json", local, false);
|
||||
|
|
|
|||
|
|
@ -250,6 +250,12 @@ static int test_pull_and_manifest_last(void) {
|
|||
"trainlog-sync-full-generation-request-v1.json",
|
||||
"{\"format\":\"trainlog-sync-request\",\"version\":1,\"request_id\":\"sr_11111111-"
|
||||
"1111-4111-8111-111111111111\",\"requested_at\":\"2026-09-20T12:00:00Z\"}");
|
||||
add(15,
|
||||
2,
|
||||
false,
|
||||
"android-archive-acknowledgements-v1.json",
|
||||
"{\"format\":\"trainlog-sync-archive-acknowledgements\",\"version\":1,"
|
||||
"\"acknowledgements\":[]}");
|
||||
add(5, 2, true, "android-objects", NULL);
|
||||
add(6, 5, true, "generations", NULL);
|
||||
add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
|
||||
|
|
@ -272,6 +278,9 @@ static int test_pull_and_manifest_last(void) {
|
|||
CHECK(access(path, R_OK) == 0);
|
||||
CHECK(trainlog_test_join_path(path, sizeof(path), root, "trainlog-sync-request-v1.json"));
|
||||
CHECK(access(path, R_OK) == 0);
|
||||
CHECK(trainlog_test_join_path(
|
||||
path, sizeof(path), root, "android-archive-acknowledgements-v1.json"));
|
||||
CHECK(access(path, R_OK) == 0);
|
||||
CHECK(trainlog_test_join_path(path, sizeof(path), root, "out"));
|
||||
CHECK(mkdir(path, 0700) == 0);
|
||||
char gen[512];
|
||||
|
|
|
|||
Loading…
Reference in a new issue