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
|
coverage messaging, and the right-aligned Settings/Sync action cluster are
|
||||||
also refined.
|
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
|
## 0.1.5 — 2026-09-23
|
||||||
|
|
||||||
- Completed the Sleep Tonight readiness delta and its non-destructive private
|
- 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) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
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")
|
@SuppressLint("MissingPermission")
|
||||||
internal fun bondedBluetoothDesktops(context: Context): List<BondedBluetoothDesktop> {
|
internal fun bondedBluetoothDesktops(context: Context): List<BondedBluetoothDesktop> {
|
||||||
if (!hasBluetoothConnectPermission(context)) return emptyList()
|
if (!hasBluetoothConnectPermission(context)) return emptyList()
|
||||||
|
|
@ -526,9 +539,15 @@ internal class BluetoothSyncClient(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publishArrivalRequest() {
|
private fun publishArrivalRequest() {
|
||||||
if (!BackgroundSyncSettings(context).enabled) return
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (!settings.shouldPublishAutomaticRequest(now)) return
|
if (
|
||||||
|
!shouldPublishBluetoothArrivalRequest(
|
||||||
|
BackgroundSyncSettings(context).enabled,
|
||||||
|
settings.shouldPublishAutomaticRequest(now),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
val directory = canonicalExchangeDirectory()
|
val directory = canonicalExchangeDirectory()
|
||||||
if (!directory.isDirectory) return
|
if (!directory.isDirectory) return
|
||||||
val coordinator =
|
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. */
|
/** Owns the bounded, user-initiated Android side of a generation conversation. */
|
||||||
internal class SyncGenerationCoordinator(
|
internal class SyncGenerationCoordinator(
|
||||||
private val repository: TrainlogRepository,
|
private val repository: TrainlogRepository,
|
||||||
|
|
@ -69,15 +76,9 @@ internal class SyncGenerationCoordinator(
|
||||||
|
|
||||||
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
|
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
|
||||||
repository.inSyncGenerationTransaction { db ->
|
repository.inSyncGenerationTransaction { db ->
|
||||||
db.rawQuery(
|
fun query(sql: String, arguments: Array<String>): CapturedSyncGeneration? = db.rawQuery(
|
||||||
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
|
sql,
|
||||||
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
|
arguments,
|
||||||
"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),
|
|
||||||
).use { cursor ->
|
).use { cursor ->
|
||||||
if (!cursor.moveToFirst()) null
|
if (!cursor.moveToFirst()) null
|
||||||
else {
|
else {
|
||||||
|
|
@ -93,6 +94,29 @@ internal class SyncGenerationCoordinator(
|
||||||
captured
|
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? {
|
private fun request(directory: File): JSONObject? {
|
||||||
|
|
@ -570,12 +594,15 @@ internal class SyncGenerationCoordinator(
|
||||||
afterPublication?.invoke()
|
afterPublication?.invoke()
|
||||||
lease.throwIfYieldRequested()
|
lease.throwIfYieldRequested()
|
||||||
// CONTRACT: durable objects from earlier conversations remain in
|
// CONTRACT: durable objects from earlier conversations remain in
|
||||||
// the exchange directory. Only this run's exact generation ACK
|
// the exchange directory. A cross-run retry preserves the
|
||||||
// can advance its lineage; stale bytes are retained and ignored.
|
// 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 =
|
val desktopAck =
|
||||||
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
|
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
|
||||||
it.optString("run_id") == captured.runId &&
|
acknowledgementMatchesCapturedGeneration(it, captured)
|
||||||
it.optString("generation_id") == captured.generationId
|
|
||||||
}, pollTransport, lease)
|
}, pollTransport, lease)
|
||||||
service.acceptAcknowledgement(desktopAck)
|
service.acceptAcknowledgement(desktopAck)
|
||||||
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
|
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
|
* ledger row. Such terminal rows cannot be archived
|
||||||
* safely because Trainlog must not fabricate evidence,
|
* safely because Trainlog must not fabricate evidence,
|
||||||
* but they must not exhaust the active admission window
|
* but they must not exhaust the active admission window
|
||||||
* forever. Current acknowledgements always have durable
|
* forever. A failed conversation can also leave an
|
||||||
* ledger evidence and remain counted until archived.
|
* unacknowledged branch whose sibling later receives a
|
||||||
* INVARIANT: captured, published and unacknowledged rows
|
* durable ACK; that superseded branch remains retained
|
||||||
* are always retained and never pruned for capacity. */
|
* 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 " +
|
"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 " +
|
"(status='acknowledged' AND EXISTS(SELECT 1 FROM sync_acknowledgements k " +
|
||||||
"WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" +
|
"WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" +
|
||||||
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
|
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,32 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class BluetoothSyncTransportContractTest {
|
class BluetoothSyncTransportContractTest {
|
||||||
|
@Test
|
||||||
|
fun connectedPeerStartsAutoSyncWithoutUserActionButHonorsCooldown() {
|
||||||
|
assertTrue(
|
||||||
|
shouldPublishBluetoothArrivalRequest(
|
||||||
|
autoSyncEnabled = true,
|
||||||
|
cooldownReady = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
shouldPublishBluetoothArrivalRequest(
|
||||||
|
autoSyncEnabled = false,
|
||||||
|
cooldownReady = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
shouldPublishBluetoothArrivalRequest(
|
||||||
|
autoSyncEnabled = true,
|
||||||
|
cooldownReady = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() {
|
fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() {
|
||||||
assertTrue(
|
assertTrue(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.labfytools.trainlog.model.SessionDraft
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
import com.labfytools.trainlog.model.SleepDiaryDraft
|
import com.labfytools.trainlog.model.SleepDiaryDraft
|
||||||
|
import com.labfytools.trainlog.model.SleepMedication
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
|
@ -30,6 +31,238 @@ import org.robolectric.annotation.Config
|
||||||
class SyncGenerationServiceTest {
|
class SyncGenerationServiceTest {
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
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
|
@Test
|
||||||
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
|
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
|
||||||
val repositoryRoot =
|
val repositoryRoot =
|
||||||
|
|
@ -322,6 +555,13 @@ class SyncGenerationServiceTest {
|
||||||
destinationService.peerId(),
|
destinationService.peerId(),
|
||||||
runId,
|
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 =
|
val outboundDirectory =
|
||||||
sourceService.publish(outbound, java.io.File(root, "source-objects"))
|
sourceService.publish(outbound, java.io.File(root, "source-objects"))
|
||||||
sourceService.acceptAcknowledgement(
|
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
|
@Test
|
||||||
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
|
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
|
||||||
val databaseName = "generation-actionable-${UUID.randomUUID()}.db"
|
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
|
remains available to its legacy mirror/fallback workflow. No transport copies
|
||||||
SQLite.
|
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
|
Android synchronization paths share the same concurrency-guarded generation
|
||||||
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
||||||
arbiter: explicit foreground intent has priority over automatic background
|
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
|
uploaded recursively. Failure or interruption leaves admission closed rather
|
||||||
than fabricating an ACK or discarding evidence.
|
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,
|
Before Android captures for a negotiated `generation-archive-v1` conversation,
|
||||||
the desktop consumer publishes
|
the desktop consumer publishes
|
||||||
`desktop-archive-acknowledgements-v1.json`. Its strictly correlated envelope
|
`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
|
and retransmission because generation membership lives only in
|
||||||
`sync_causal_publications`.
|
`sync_causal_publications`.
|
||||||
|
|
||||||
The foreground Android coordinator correlates both durable desktop ACKs and
|
The foreground Android coordinator correlates desktop generation references
|
||||||
desktop generation references by the active run (and the Android generation
|
by the active run. It correlates a durable desktop ACK for an ordinary attempt
|
||||||
for its ACK). Objects retained from an interrupted conversation are ignored
|
by the generation and manifest of that run; for a cross-run resume, those same
|
||||||
until atomically replaced by matching objects. After both peer ACKs, Android
|
two immutable identities deliberately recognize the exact ACK whose embedded
|
||||||
reports completion directly; it does not enter the legacy receipt wait state.
|
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
|
The peer publication precedes one fresh
|
||||||
`trainlog-sync-full-generation-request-v1` signal on
|
`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
|
deterministic `getRandomValues` bytes, and completes the protected merge HTTP
|
||||||
request; native merge tests retain their transaction/rollback coverage.
|
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
|
UI regressions assert the Sleep labels at exactly 0/25/50/75/100%, persisted
|
||||||
Dashboard sizing and reset behavior, English preference persistence across a
|
Dashboard sizing and reset behavior, English preference persistence across a
|
||||||
remount, centralized Dashboard English labels, non-stretched chart aspect
|
remount, centralized Dashboard English labels, non-stretched chart aspect
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,47 @@ class SleepExchangeTest(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(ValueError, "concurrent"):
|
with self.assertRaisesRegex(ValueError, "concurrent"):
|
||||||
exchange.apply(self.db, sibling)
|
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):
|
def test_rejects_concurrent_sibling_and_negative_absolute_interval(self):
|
||||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first))))
|
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):
|
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):
|
def test_sqlite_backup_captures_committed_wal_and_restores_invariants(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,10 @@ class OrchestratorTest(unittest.TestCase):
|
||||||
"assert sys.argv[sys.argv.index('--mode')+1]=='bt'\n"
|
"assert sys.argv[sys.argv.index('--mode')+1]=='bt'\n"
|
||||||
"assert '--bt-adapter' in sys.argv\n"
|
"assert '--bt-adapter' in sys.argv\n"
|
||||||
"v=" + repr(report) +
|
"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_marker = root / "bt-probe"
|
||||||
bt_adapter = sandbox / "trainlog_generation_bt_adapter.py"
|
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",
|
bluetooth_device_address="AA:BB:CC:DD:EE:FF",
|
||||||
)
|
)
|
||||||
result = self.invoke(sandbox / "sync_orchestrator.py", values)
|
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.assertTrue(bt_marker.is_file())
|
||||||
self.assertFalse(mtp_marker.exists())
|
self.assertFalse(mtp_marker.exists())
|
||||||
final = json.loads(values[1].read_text())
|
final = json.loads(values[1].read_text())
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,18 @@ done
|
||||||
|
|
||||||
mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user"
|
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 "$SYNC_ONCE" "$HOME/.local/bin/trainlog-sync-once"
|
||||||
ln -sfn "$DAEMON" "$HOME/.local/bin/trainlog-syncd"
|
ln -sfn "$DAEMON" "$HOME/.local/bin/trainlog-syncd"
|
||||||
ln -sfn "$BT_AGENT" "$HOME/.local/bin/trainlog-btd"
|
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]:
|
if local is not None and not remote_descends_from_local and medication["parent_revision_id"] != local[0]:
|
||||||
fail("concurrent medication revision")
|
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 not None and local[1] and not medication["deleted"]: fail("medication resurrection")
|
||||||
if local is None:
|
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"])))
|
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:]
|
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]:
|
if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]:
|
||||||
fail("concurrent sleep diary revision")
|
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")
|
fail("sleep diary revision has an unknown parent")
|
||||||
if local is not None and local[1] and not item["deleted"]:
|
if local is not None and local[1] and not item["deleted"]:
|
||||||
fail("sleep diary deletion cannot be resurrected")
|
fail("sleep diary deletion cannot be resurrected")
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ MAX_DIAGNOSTIC = 1024
|
||||||
PHASES = {
|
PHASES = {
|
||||||
"requested",
|
"requested",
|
||||||
"waiting_android_publication",
|
"waiting_android_publication",
|
||||||
|
"android_archive_ack_observed",
|
||||||
"running",
|
"running",
|
||||||
"local_import_committed",
|
"local_import_committed",
|
||||||
"published",
|
"published",
|
||||||
|
|
@ -297,6 +298,7 @@ def read_worker(
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
allowed = [
|
allowed = [
|
||||||
"waiting_android_publication",
|
"waiting_android_publication",
|
||||||
|
"android_archive_ack_observed",
|
||||||
"running",
|
"running",
|
||||||
"local_import_committed",
|
"local_import_committed",
|
||||||
"published",
|
"published",
|
||||||
|
|
@ -338,6 +340,7 @@ def read_worker(
|
||||||
"inbound_generation_id",
|
"inbound_generation_id",
|
||||||
"outbound_generation_id",
|
"outbound_generation_id",
|
||||||
"manifest_sha256",
|
"manifest_sha256",
|
||||||
|
"recovered_acknowledgements",
|
||||||
}
|
}
|
||||||
or value.get("version") != 1
|
or value.get("version") != 1
|
||||||
or value.get("run_id") != run_id
|
or value.get("run_id") != run_id
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue