Compare commits

...

3 commits

19 changed files with 589 additions and 15 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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