diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f2e14..c7da0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,21 @@ Dashboard sizing, Program progress contrast, chart proportions, duration coverage messaging, and the right-aligned Settings/Sync action cluster are also refined. +- Restored automatic Sleep-only Bluetooth synchronization after a real-night + trace exposed several restart and first-import gaps. The user-service + installer now removes only its obsolete `syncd` ExecStart override and + installs the RFCOMM agent; + Android resumes the same unsuperseded immutable generation across a new + conversation run and accepts its exact generation/digest ACK even though + the immutable ACK retains the original run identity. Ledger-proven + superseded branches remain retained without exhausting admission. Desktop + progress validation accepts the existing ACK-recovery phase, and a fresh + desktop can seed a non-root Sleep V2 current snapshot when its validated + ancestry is present. Regression + coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR + through import, ACK, replay and a second Sleep-only generation with no + Android workout session. + ## 0.1.5 — 2026-09-23 - Completed the Sleep Tonight readiness delta and its non-destructive private diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt b/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt index 66eb3d1..47f656f 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt @@ -101,6 +101,19 @@ internal fun hasBluetoothConnectPermission(context: Context): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED +/** + * Decide whether a newly connected RFCOMM peer may publish an automatic request. + * + * WHY: Sleep-only changes have no workout event to wake the desktop path, so + * Bluetooth arrival itself must remain the trigger. CONTRACT: user opt-in and + * the existing cooldown are both mandatory. INVARIANT: reconnection never + * bypasses the bounded request cadence or requires an explicit UI action. + */ +internal fun shouldPublishBluetoothArrivalRequest( + autoSyncEnabled: Boolean, + cooldownReady: Boolean, +): Boolean = autoSyncEnabled && cooldownReady + @SuppressLint("MissingPermission") internal fun bondedBluetoothDesktops(context: Context): List { if (!hasBluetoothConnectPermission(context)) return emptyList() @@ -526,9 +539,15 @@ internal class BluetoothSyncClient( } private fun publishArrivalRequest() { - if (!BackgroundSyncSettings(context).enabled) return val now = System.currentTimeMillis() - if (!settings.shouldPublishAutomaticRequest(now)) return + if ( + !shouldPublishBluetoothArrivalRequest( + BackgroundSyncSettings(context).enabled, + settings.shouldPublishAutomaticRequest(now), + ) + ) { + return + } val directory = canonicalExchangeDirectory() if (!directory.isDirectory) return val coordinator = diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt index 2f6e18f..efff13d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt @@ -45,6 +45,13 @@ internal object AndroidSyncGenerationTrace : SyncGenerationTrace { } } +internal fun acknowledgementMatchesCapturedGeneration( + acknowledgement: JSONObject, + captured: CapturedSyncGeneration, +): Boolean = + acknowledgement.optString("generation_id") == captured.generationId && + acknowledgement.optString("manifest_sha256") == captured.manifestSha256 + /** Owns the bounded, user-initiated Android side of a generation conversation. */ internal class SyncGenerationCoordinator( private val repository: TrainlogRepository, @@ -69,15 +76,9 @@ internal class SyncGenerationCoordinator( internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? = repository.inSyncGenerationTransaction { db -> - db.rawQuery( - "SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " + - "WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " + - "OR (status='acknowledged' AND NOT EXISTS(" + - "SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " + - "AND c.result IN('consumed','rejected')))) " + - (if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") + - "LIMIT 2", - if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId), + fun query(sql: String, arguments: Array): CapturedSyncGeneration? = db.rawQuery( + sql, + arguments, ).use { cursor -> if (!cursor.moveToFirst()) null else { @@ -93,6 +94,29 @@ internal class SyncGenerationCoordinator( captured } } + query( + "SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " + + "WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " + + "OR (status='acknowledged' AND NOT EXISTS(" + + "SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " + + "AND c.result IN('consumed','rejected')))) " + + (if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") + + "LIMIT 2", + if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId), + ) ?: if (consumerPeerId == null) null else query( + /* WHY: an RFCOMM reset creates a new conversation run while + * the immutable generation still awaits its correlated ACK. + * CONTRACT: resume only the unsuperseded generation for the + * same peer; the reference adopts the new run while manifest + * and generation identity remain unchanged. */ + "SELECT g.generation_id,g.manifest_sha256,g.staging_path FROM sync_generations g " + + "WHERE g.consumer_peer_id=? AND g.status IN('captured','published','waiting_acknowledgement') " + + "AND NOT EXISTS(SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements a " + + "ON a.generation_id=sibling.generation_id WHERE sibling.parent_generation_id=g.parent_generation_id " + + "AND sibling.generation_id<>g.generation_id AND sibling.status='acknowledged') " + + "ORDER BY g.generated_at DESC LIMIT 2", + arrayOf(consumerPeerId), + ) } private fun request(directory: File): JSONObject? { @@ -570,12 +594,15 @@ internal class SyncGenerationCoordinator( afterPublication?.invoke() lease.throwIfYieldRequested() // CONTRACT: durable objects from earlier conversations remain in - // the exchange directory. Only this run's exact generation ACK - // can advance its lineage; stale bytes are retained and ignored. + // the exchange directory. A cross-run retry preserves the + // immutable manifest's original run_id, so correlate its ACK by + // generation identity and manifest digest. acceptAcknowledgement + // then revalidates the complete durable ACK against SQLite. + // INVARIANT: unrelated or same-ID/different-bytes ACKs remain + // ignored; an exact replay is deliberately accepted. val desktopAck = awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, { - it.optString("run_id") == captured.runId && - it.optString("generation_id") == captured.generationId + acknowledgementMatchesCapturedGeneration(it, captured) }, pollTransport, lease) service.acceptAcknowledgement(desktopAck) phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId) diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index fa099ed..cec70eb 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -299,12 +299,18 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) * ledger row. Such terminal rows cannot be archived * safely because Trainlog must not fabricate evidence, * but they must not exhaust the active admission window - * forever. Current acknowledgements always have durable - * ledger evidence and remain counted until archived. - * INVARIANT: captured, published and unacknowledged rows - * are always retained and never pruned for capacity. */ + * forever. A failed conversation can also leave an + * unacknowledged branch whose sibling later receives a + * durable ACK; that superseded branch remains retained + * for audit but can never be the causal tip. INVARIANT: + * only a ledger-backed acknowledged sibling proves that + * an unacknowledged branch no longer consumes admission. */ "SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND " + - "(status IN('captured','published','waiting_acknowledgement') OR " + + "((status IN('captured','published','waiting_acknowledgement') AND NOT EXISTS(" + + "SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements sibling_ack " + + "ON sibling_ack.generation_id=sibling.generation_id WHERE " + + "sibling.parent_generation_id=g.parent_generation_id AND " + + "sibling.generation_id<>g.generation_id AND sibling.status='acknowledged')) OR " + "(status='acknowledged' AND EXISTS(SELECT 1 FROM sync_acknowledgements k " + "WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" + "SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)", diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BluetoothSyncTransportContractTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BluetoothSyncTransportContractTest.kt index 55aef0c..46e09b1 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BluetoothSyncTransportContractTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BluetoothSyncTransportContractTest.kt @@ -1,9 +1,32 @@ package com.labfytools.trainlog.data +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class BluetoothSyncTransportContractTest { + @Test + fun connectedPeerStartsAutoSyncWithoutUserActionButHonorsCooldown() { + assertTrue( + shouldPublishBluetoothArrivalRequest( + autoSyncEnabled = true, + cooldownReady = true, + ) + ) + assertFalse( + shouldPublishBluetoothArrivalRequest( + autoSyncEnabled = false, + cooldownReady = true, + ) + ) + assertFalse( + shouldPublishBluetoothArrivalRequest( + autoSyncEnabled = true, + cooldownReady = false, + ) + ) + } + @Test fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() { assertTrue( diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index a6faded..54b19de 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -11,6 +11,7 @@ import com.labfytools.trainlog.model.SessionDraft import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SleepDiaryDraft +import com.labfytools.trainlog.model.SleepMedication import com.labfytools.trainlog.model.TrackingMode import java.io.File import java.nio.file.Files @@ -30,6 +31,238 @@ import org.robolectric.annotation.Config class SyncGenerationServiceTest { private val context: Context = ApplicationProvider.getApplicationContext() + @Test + fun sleepOnlyNightCrossesDesktopAckReplayAndSecondRevisionWithoutSession() { + val sourceName = "sleep-only-generation-${UUID.randomUUID()}.db" + val root = Files.createTempDirectory("trainlog-sleep-only-generation-").toFile() + val desktopDb = java.io.File(root, "desktop.sqlite") + val repositoryRoot = + generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) { + it.parentFile + } + .first { java.io.File(it, "tools/sync_generation_exchange.py").isFile } + val generationTool = + java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath + var source = TrainlogRepository(context, sourceName) + + fun run(vararg command: String): String { + val process = + ProcessBuilder(*command) + .directory(repositoryRoot) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + assertEquals( + "command failed: ${command.joinToString(" ")}\n$output", + 0, + process.waitFor(), + ) + return output + } + + try { + run( + java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath, + desktopDb.absolutePath, + ) + val desktopPeer = + Regex("PEER_ID=(peer_[^\\s]+)") + .find( + run( + "python3", + generationTool, + "peer-id", + "--database", + desktopDb.absolutePath, + ) + )!! + .groupValues[1] + val medicationCreated = + source.saveSleepMedication( + SleepMedication( + medicationId = "", + revisionId = "", + createdAt = "2026-09-23T20:00:00+02:00", + updatedAt = "2026-09-23T20:00:00+02:00", + name = "Synthetic sleep-only medication", + defaultDoseValue = 5.0, + defaultDoseUnit = "mg", + form = "tablet", + note = "", + active = true, + ) + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + val preBed = + source.quickSleepMedication( + medicationCreated.entryId, + "2026-09-23T22:20:00+02:00", + quantity = 2, + ) as TrainlogRepository.SleepQuickActionResult.Applied + val bed = + source.quickSleepBedTime("2026-09-23T22:30:00+02:00") + as TrainlogRepository.SleepQuickActionResult.Applied + assertEquals(preBed.receipt.entryId, bed.receipt.entryId) + val firstMeasurement = + ParsedHeartRateMeasurement( + bpm = 61, + sensorContactDetected = true, + energyExpended = 7, + rrIntervals1024 = listOf(1000), + ) + assertTrue( + source.recordLiveHeartRateForActiveSleep( + "Synthetic HR", + "2026-09-23T22:30:01+02:00", + firstMeasurement, + ) is TrainlogRepository.HeartRateMutationResult.Applied + ) + assertTrue( + source.quickSleepWake("2026-09-24T02:17:00+02:00") + is TrainlogRepository.SleepQuickActionResult.Applied + ) + assertTrue( + source.recordLiveHeartRateForActiveSleep( + "Synthetic HR", + "2026-09-24T02:17:01+02:00", + firstMeasurement.copy(bpm = 65, rrIntervals1024 = listOf(980)), + ) is TrainlogRepository.HeartRateMutationResult.Applied + ) + assertTrue( + source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00") + is TrainlogRepository.SleepQuickActionResult.Applied + ) + assertTrue(source.listSessions().isEmpty()) + + val night = source.listSleepDiary().single() + val capture = + JSONObject(source.buildHeartRateV1Json()) + .getJSONArray("captures") + .getJSONObject(0) + assertEquals(night.entryId, capture.getString("context_id")) + assertEquals(2, capture.getJSONArray("samples").length()) + assertEquals(2, night.intakes.single().quantity) + + val service = SyncGenerationService(source) + val captured = service.capture(root, desktopPeer) + val diaryArtifact = + JSONObject(java.io.File(captured.stagingDirectory, "sleep-diary-v2.json").readText()) + val heartArtifact = + JSONObject(java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText()) + val historyArtifact = + JSONObject(java.io.File(captured.stagingDirectory, "history-v4.json").readText()) + assertEquals(0, historyArtifact.getJSONArray("sessions").length()) + val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0) + val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0) + assertEquals(night.entryId, publishedNight.getString("entry_id")) + assertEquals( + 2, + publishedNight.getJSONArray("intakes").getJSONObject(0).getInt("quantity"), + ) + assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id")) + + val published = service.publish(captured, java.io.File(root, "android-objects")) + val ack = java.io.File(root, "sleep-only-ack.json") + run( + "python3", + generationTool, + "consume-desktop", + published.absolutePath, + "--database", + desktopDb.absolutePath, + "--ack-output", + ack.absolutePath, + ) + val ackDocument = JSONObject(ack.readText()) + assertEquals( + "desktop ACK diagnostic: ${ackDocument.getString("diagnostic")}", + "consumed", + ackDocument.getString("result"), + ) + assertEquals("acknowledged", service.acceptAcknowledgement(ack.readBytes())) + assertEquals("unchanged", service.acceptAcknowledgement(ack.readBytes())) + run( + "python3", + generationTool, + "consume-desktop", + published.absolutePath, + "--database", + desktopDb.absolutePath, + "--ack-output", + java.io.File(root, "sleep-only-replay-ack.json").absolutePath, + ) + assertEquals( + // The desktop fixture owns one reserved calibration session; + // the empty Android history must not add a workout. + "1|1|2|2|2|1", + run( + "sqlite3", + desktopDb.absolutePath, + "SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " + + "(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " + + "(SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " + + "(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.getString("capture_id")}') || '|' || " + + "(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " + + "(SELECT count(*) FROM sessions);", + ) + .trim(), + ) + + val revised = + source.saveSleepDiary( + SleepDiaryDraft( + entryId = night.entryId, + expectedRevision = night.revisionId, + nightStartDate = night.nightStartDate, + nightEndDate = night.nightEndDate, + createdAt = night.createdAt, + updatedAt = "2026-09-24T07:00:00+02:00", + sleepQuality = night.sleepQuality, + wakeQuality = night.wakeQuality, + dayForm = night.dayForm, + treatmentAndNotes = "Sleep-only revision two", + events = night.events, + intakes = night.intakes, + ) + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + assertTrue( + source.validateSleepDiary( + revised.entryId, + revised.revisionId, + "2026-09-24T07:01:00+02:00", + ) is TrainlogRepository.SaveSleepDiaryResult.Saved + ) + val second = service.capture(root, desktopPeer) + val secondPublished = + service.publish(second, java.io.File(root, "android-objects-second")) + val secondAck = java.io.File(root, "sleep-only-second-ack.json") + run( + "python3", + generationTool, + "consume-desktop", + secondPublished.absolutePath, + "--database", + desktopDb.absolutePath, + "--ack-output", + secondAck.absolutePath, + ) + assertEquals("acknowledged", service.acceptAcknowledgement(secondAck.readBytes())) + assertEquals( + revised.revisionId, + run( + "sqlite3", + desktopDb.absolutePath, + "SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id='${night.entryId}';", + ) + .trim(), + ) + assertTrue(source.listSessions().isEmpty()) + } finally { + source.close() + context.deleteDatabase(sourceName) + root.deleteRecursively() + } + } + @Test fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() { val repositoryRoot = @@ -322,6 +555,13 @@ class SyncGenerationServiceTest { destinationService.peerId(), runId, ) + val retryRun = "sy_${UUID.randomUUID()}" + val crossRunResume = + SyncGenerationCoordinator(source) + .resumableGeneration(retryRun, destinationService.peerId()) + assertEquals(outbound.generationId, crossRunResume?.generationId) + assertEquals(retryRun, crossRunResume?.runId) + assertEquals(outbound.manifestSha256, crossRunResume?.manifestSha256) val outboundDirectory = sourceService.publish(outbound, java.io.File(root, "source-objects")) sourceService.acceptAcknowledgement( @@ -353,6 +593,74 @@ class SyncGenerationServiceTest { } } + @Test + fun crossRunResumeAcceptsExactImmutableAcknowledgementFromOriginalRun() { + val generationId = "gen_${UUID.randomUUID()}" + val originalRunId = "sy_${UUID.randomUUID()}" + val retryRunId = "sy_${UUID.randomUUID()}" + val digest = "a".repeat(64) + val captured = + CapturedSyncGeneration(generationId, retryRunId, digest, java.io.File("unused")) + val acknowledgement = + JSONObject() + .put("run_id", originalRunId) + .put("generation_id", generationId) + .put("manifest_sha256", digest) + + assertTrue(acknowledgementMatchesCapturedGeneration(acknowledgement, captured)) + acknowledgement.put("manifest_sha256", "b".repeat(64)) + assertTrue(!acknowledgementMatchesCapturedGeneration(acknowledgement, captured)) + } + + @Test + fun ledgerBackedSiblingMakesRetainedWaitingBranchesCapacityTerminal() { + val name = "generation-superseded-capacity-${UUID.randomUUID()}.db" + val root = Files.createTempDirectory("trainlog-generation-superseded-").toFile() + val repository = TrainlogRepository(context, name) + try { + val service = SyncGenerationService(repository) + val peer = "peer_11111111-1111-4111-8111-111111111111" + val branches = (0 until 4).map { service.capture(root, peer) } + val acknowledged = branches.last() + repository.inSyncGenerationTransaction { db -> + db.execSQL( + "UPDATE sync_generations SET parent_generation_id=? WHERE consumer_peer_id=?", + arrayOf("gen_22222222-2222-4222-8222-222222222222", peer), + ) + db.execSQL( + "UPDATE sync_generations SET status='acknowledged',acknowledged_at=? " + + "WHERE generation_id=?", + arrayOf("2026-09-24T07:00:00+02:00", acknowledged.generationId), + ) + db.execSQL( + "INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)", + arrayOf( + "ack_${UUID.randomUUID()}", + acknowledged.generationId, + acknowledged.runId, + service.peerId(), + peer, + acknowledged.manifestSha256, + "consumed", + "sqlite-commit-full", + "2026-09-24T07:00:00+02:00", + "", + "a".repeat(64), + ), + ) + } + + // The three sibling branches remain durable, but the ledger-backed + // acknowledged sibling proves that none can be the causal tip. + assertEquals(3, branches.dropLast(1).size) + assertTrue(service.capture(root, peer).generationId.startsWith("gen_")) + } finally { + repository.close() + context.deleteDatabase(name) + root.deleteRecursively() + } + } + @Test fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() { val databaseName = "generation-actionable-${UUID.randomUUID()}.db" diff --git a/docs/current_state.md b/docs/current_state.md index c057a35..74f4323 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -10,6 +10,16 @@ the wired recovery path. The separately configured private Drive transport remains available to its legacy mirror/fallback workflow. No transport copies SQLite. +Automatic Bluetooth restart recovery preserves the immutable generation +identity across conversation-run changes and recognizes the exact original-run +ACK by immutable generation ID and manifest digest before full SQLite +validation. An unacknowledged branch is excluded +from the bounded admission count only when a different sibling has a durable, +ledger-backed consumed acknowledgement; the superseded bytes and row remain +retained. The desktop service installer retires the exact obsolete private +ExecStart override that omitted required runtime paths and installs both +`trainlog-syncd` and the BlueZ RFCOMM agent. + Android synchronization paths share the same concurrency-guarded generation coordinator as SyncScreen. That coordinator has one process-wide ownership arbiter: explicit foreground intent has priority over automatic background diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 9c1556a..dcff46e 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -953,6 +953,20 @@ active. Archive directories are outside bounded MTP polling and are never uploaded recursively. Failure or interruption leaves admission closed rather than fabricating an ACK or discarding evidence. +An unacknowledged generation whose parent also has a distinct, ledger-backed +acknowledged child is a retained superseded branch: it stays available for +audit and is never deleted or assigned a fabricated ACK, but it no longer +consumes active admission because it cannot become the causal tip. After a +transport reset, the next run republishes the newest unsuperseded immutable +generation for that peer before attempting a new capture. The mutable +generation reference uses the new conversation run; generation ID, manifest, +artifact bytes and eventual ACK correlation remain unchanged. Because the +immutable manifest and its durable ACK retain the original run identity, a +cross-run retry recognizes that ACK by the exact generation ID and manifest +digest; the producer's SQLite acknowledgement validator still verifies the +complete peer, result, durability and original-run contract before advancing +state. + Before Android captures for a negotiated `generation-archive-v1` conversation, the desktop consumer publishes `desktop-archive-acknowledgements-v1.json`. Its strictly correlated envelope @@ -999,11 +1013,14 @@ idempotently. Causal V1 operation bytes remain unchanged across first emission and retransmission because generation membership lives only in `sync_causal_publications`. -The foreground Android coordinator correlates both durable desktop ACKs and -desktop generation references by the active run (and the Android generation -for its ACK). Objects retained from an interrupted conversation are ignored -until atomically replaced by matching objects. After both peer ACKs, Android -reports completion directly; it does not enter the legacy receipt wait state. +The foreground Android coordinator correlates desktop generation references +by the active run. It correlates a durable desktop ACK for an ordinary attempt +by the generation and manifest of that run; for a cross-run resume, those same +two immutable identities deliberately recognize the exact ACK whose embedded +run remains the original capture run. Objects retained from an interrupted +conversation are ignored until atomically replaced by matching objects. After +both peer ACKs, Android reports completion directly; it does not enter the +legacy receipt wait state. The peer publication precedes one fresh `trainlog-sync-full-generation-request-v1` signal on diff --git a/docs/tests.md b/docs/tests.md index 1435da0..c5ee695 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -25,6 +25,18 @@ still conflicts. The equipment client test removes `crypto.randomUUID`, uses deterministic `getRandomValues` bytes, and completes the protected merge HTTP request; native merge tests retain their transaction/rollback coverage. +The real Sleep Bluetooth recovery regression starts with no Android session, +records a pre-bed medication with `quantity=2`, Couché, two BPM/RR samples +separated by Réveil, and Levé. It captures and publishes the production V2 +Sleep and V1 heart-rate artifacts, imports them with the desktop consumer, +accepts and replays the ACK without duplication, then admits a second +Sleep-only revision. Separate tests prove connection-arrival auto-request +policy with its cooldown, cross-run reuse of an unacknowledged immutable +generation and recognition of its original-run exact generation/digest ACK, +ledger-backed superseded-branch capacity, fresh-peer non-root Sleep ancestry, +service-installer upgrade behavior, and ACK-recovery progress frames over the +Bluetooth-selected orchestrator path. + UI regressions assert the Sleep labels at exactly 0/25/50/75/100%, persisted Dashboard sizing and reset behavior, English preference persistence across a remount, centralized Dashboard English labels, non-stretched chart aspect diff --git a/tests/test_sleep_diary_exchange.py b/tests/test_sleep_diary_exchange.py index 3090456..6ecbb4e 100644 --- a/tests/test_sleep_diary_exchange.py +++ b/tests/test_sleep_diary_exchange.py @@ -186,6 +186,47 @@ class SleepExchangeTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, "concurrent"): exchange.apply(self.db, sibling) + def test_current_snapshot_seeds_fresh_peer_from_non_root_revisions(self): + parent = "slr_20000000-0000-4000-8000-000000000002" + tip = "slr_40000000-0000-4000-8000-000000000004" + document = self.document(entry(tip, parent)) + document["version"] = 2 + document["entries"][0]["intakes"][0]["quantity"] = 2 + document["entries"][0]["ancestry"] = [tip, parent] + + medication = document["medications"][0] + medication_parent = medication["revision_id"] + medication_tip = "medr_a0000000-0000-4000-8000-00000000000a" + medication["revision_id"] = medication_tip + medication["parent_revision_id"] = medication_parent + medication["ancestry"] = [medication_tip, medication_parent] + medication["updated_at"] = "2026-10-25T18:05:00+01:00" + + rejected = self.document(entry( + "slr_50000000-0000-4000-8000-000000000005", + "slr_60000000-0000-4000-8000-000000000006", + )) + rejected["version"] = 2 + rejected["entries"][0]["intakes"][0]["quantity"] = 2 + with self.assertRaisesRegex(ValueError, "unknown parent"): + exchange.apply(self.db, rejected) + self.db.rollback() + + self.assertEqual((1, 0), exchange.apply(self.db, document)) + self.assertEqual( + (tip, parent), + self.db.execute( + "SELECT revision_id,parent_revision_id FROM sleep_diary_revisions" + ).fetchone(), + ) + self.assertEqual( + (medication_tip, medication_parent), + self.db.execute( + "SELECT revision_id,parent_revision_id FROM sleep_medication_revisions" + ).fetchone(), + ) + + def test_rejects_concurrent_sibling_and_negative_absolute_interval(self): first = "slr_20000000-0000-4000-8000-000000000002" self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first)))) diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index e971a9c..142a348 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -13,6 +13,17 @@ import sync_generation_exchange as generation class DeploymentToolsTest(unittest.TestCase): + def test_user_service_installer_retires_only_known_legacy_exec_override(self): + installer = (ROOT / "tools/install_syncd_user.sh").read_text() + self.assertIn( + "grep -Fxq 'ExecStart=%h/.local/bin/trainlog-syncd --interval 3'", + installer, + ) + self.assertIn('rm -f -- "$LEGACY_ROLLOUT"', installer) + self.assertIn('--sync-once "$SYNC_ONCE"', installer) + self.assertIn('--bt-adapter "$BT_ADAPTER"', installer) + self.assertIn('--mtp-adapter "$MTP_ADAPTER"', installer) + def test_sqlite_backup_captures_committed_wal_and_restores_invariants(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_sync_orchestrator.py b/tests/test_sync_orchestrator.py index ecc849e..4cffc8e 100644 --- a/tests/test_sync_orchestrator.py +++ b/tests/test_sync_orchestrator.py @@ -156,7 +156,10 @@ class OrchestratorTest(unittest.TestCase): "assert sys.argv[sys.argv.index('--mode')+1]=='bt'\n" "assert '--bt-adapter' in sys.argv\n" "v=" + repr(report) + - "\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\nprint(json.dumps(v))\n" + "\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\n" + "p={'format':'trainlog-sync-progress','version':1,'run_id':v['run_id']," + "'phase':'android_archive_ack_observed','recovered_acknowledgements':1}\n" + "print(json.dumps(p))\nprint(json.dumps(v))\n" ) bt_marker = root / "bt-probe" bt_adapter = sandbox / "trainlog_generation_bt_adapter.py" @@ -175,7 +178,7 @@ class OrchestratorTest(unittest.TestCase): bluetooth_device_address="AA:BB:CC:DD:EE:FF", ) result = self.invoke(sandbox / "sync_orchestrator.py", values) - self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.returncode, 0, result.stderr or values[1].read_text()) self.assertTrue(bt_marker.is_file()) self.assertFalse(mtp_marker.exists()) final = json.loads(values[1].read_text()) diff --git a/tools/install_syncd_user.sh b/tools/install_syncd_user.sh index 64e0064..d2a545b 100755 --- a/tools/install_syncd_user.sh +++ b/tools/install_syncd_user.sh @@ -24,6 +24,18 @@ done mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user" +# WHY: private pre-orchestrator rollouts installed this exact drop-in to call +# syncd without its now-required bounded runtime paths. Leaving it in place +# silently overrides the canonical unit below and creates an endless restart +# loop. CONTRACT: remove only the known obsolete Trainlog-owned override; +# preserve every unrecognized administrator customization. INVARIANT: the +# effective ExecStart always owns sync-once plus both transport adapters. +LEGACY_ROLLOUT="$HOME/.config/systemd/user/trainlog-syncd.service.d/rollout.conf" +if [[ -f "$LEGACY_ROLLOUT" ]] && + grep -Fxq 'ExecStart=%h/.local/bin/trainlog-syncd --interval 3' "$LEGACY_ROLLOUT"; then + rm -f -- "$LEGACY_ROLLOUT" +fi + ln -sfn "$SYNC_ONCE" "$HOME/.local/bin/trainlog-sync-once" ln -sfn "$DAEMON" "$HOME/.local/bin/trainlog-syncd" ln -sfn "$BT_AGENT" "$HOME/.local/bin/trainlog-btd" diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index 84cafea..888a193 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -328,7 +328,13 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: ) if local is not None and not remote_descends_from_local and medication["parent_revision_id"] != local[0]: fail("concurrent medication revision") - if local is None and medication["parent_revision_id"] is not None: fail("unknown medication parent") + # CONTRACT: this artifact is a current-state companion, not a revision + # log. A fresh peer may seed a non-root tip only when the validated + # complete ancestry accompanies it; legacy payloads without ancestry + # retain the strict unknown-parent rejection. + if (local is None and medication["parent_revision_id"] is not None and + "ancestry" not in medication): + fail("unknown medication parent") if local is not None and local[1] and not medication["deleted"]: fail("medication resurrection") if local is None: db.execute("INSERT INTO sleep_medications VALUES(?,?,?,?,?)", (medication["medication_id"], medication["created_at"], medication["updated_at"], medication["revision_id"], int(medication["deleted"]))) @@ -379,7 +385,13 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:] if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]: fail("concurrent sleep diary revision") - if local is None and item["parent_revision_id"] is not None: + # WHY: one-tap Sleep creates several local revisions before its first + # synchronization. The current snapshot carries their bounded lineage + # by identity, so a new desktop must be able to seed the tip without + # inventing historical payloads. INVARIANT: missing ancestry still + # rejects a non-root first snapshot. + if (local is None and item["parent_revision_id"] is not None and + "ancestry" not in item): fail("sleep diary revision has an unknown parent") if local is not None and local[1] and not item["deleted"]: fail("sleep diary deletion cannot be resurrected") diff --git a/tools/sync_orchestrator.py b/tools/sync_orchestrator.py index ae87b5e..3669837 100644 --- a/tools/sync_orchestrator.py +++ b/tools/sync_orchestrator.py @@ -30,6 +30,7 @@ MAX_DIAGNOSTIC = 1024 PHASES = { "requested", "waiting_android_publication", + "android_archive_ack_observed", "running", "local_import_committed", "published", @@ -297,6 +298,7 @@ def read_worker( deadline = time.monotonic() + timeout allowed = [ "waiting_android_publication", + "android_archive_ack_observed", "running", "local_import_committed", "published", @@ -338,6 +340,7 @@ def read_worker( "inbound_generation_id", "outbound_generation_id", "manifest_sha256", + "recovered_acknowledgements", } or value.get("version") != 1 or value.get("run_id") != run_id