fix(sync): restore automatic sleep bluetooth synchronization

This commit is contained in:
fy59 2026-09-24 06:00:29 +02:00
parent 2755d29f2d
commit 3a51211f01
15 changed files with 548 additions and 29 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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