fix(sync): restore automatic sleep bluetooth synchronization
This commit is contained in:
parent
2755d29f2d
commit
3a51211f01
15 changed files with 548 additions and 29 deletions
15
CHANGELOG.md
15
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
|
||||
|
|
|
|||
|
|
@ -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<BondedBluetoothDesktop> {
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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<String>): 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)
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue