diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/ExchangeSafTree.kt b/android/app/src/main/java/com/labfytools/trainlog/data/ExchangeSafTree.kt index 20a88da..bbfbc38 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/ExchangeSafTree.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/ExchangeSafTree.kt @@ -39,6 +39,7 @@ internal fun canonicalExchangeDirectory(): File = internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) { "trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES" "trainlog-sync-request-v1.json" -> "SYNC_REQUEST" + "trainlog-sync-full-generation-request-v1.json" -> "FULL_GENERATION_SYNC_REQUEST" "trainlog-mobile-export-v3.json" -> "MOBILE_EXPORT" "trainlog-mobile-equipment-definitions-v1.json" -> "EQUIPMENT_DEFINITIONS" "trainlog-equipment-associations-v2.json" -> "EQUIPMENT_ASSOCIATIONS" diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt index 41d78e3..3009c2e 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt @@ -382,9 +382,9 @@ internal class SyncGenerationCoordinator( } var currentRunId: String? = null return try { - val baselineRunId = request(directory)?.optString("run_id") val peer = publishPeer(directory) phase(null, "peer_published", "android-peer-v1.json") + val baselineRunId = request(directory)?.optString("run_id") /* WHY: trainlog-syncd is intentionally driven by the established * Android request artifact, not by polling generation internals. * CONTRACT: each explicit foreground attempt publishes the peer @@ -393,7 +393,11 @@ internal class SyncGenerationCoordinator( * ACK evidence remain immutable; retry creates new correlation. */ afterPeerPublication?.let { it.invoke() - phase(null, "legacy_trigger_published", "trainlog-sync-request-v1.json") + phase( + null, + "full_generation_trigger_published", + FULL_GENERATION_REQUEST_NAME, + ) } val deadline = System.nanoTime() + timeout.toNanos() val requestRaw = diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt index 0df0f0c..9edac49 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt @@ -13,6 +13,23 @@ import java.util.UUID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +internal const val FULL_GENERATION_REQUEST_NAME = + "trainlog-sync-full-generation-request-v1.json" +internal const val LEGACY_SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json" + +internal data class SyncRequestIntent( + val requestId: String, + val requestedAt: String, +) { + fun payload(): String = + JSONObject() + .put("format", "trainlog-sync-request") + .put("version", 1) + .put("request_id", requestId) + .put("requested_at", requestedAt) + .toString() +} + sealed interface SyncRequestResult { /** The full-generation coordinator completed both durable peer ACKs. */ data class Completed( @@ -60,39 +77,39 @@ class SyncRequestOutbox private constructor( return SyncRequestResult.Unsupported } - val requestId = - "sr_" + - UUID.randomUUID() - .toString() + val intent = newIntent() + return publishLegacy(snapshot, intent) + } - val payload = - JSONObject() - .put( - "format", - "trainlog-sync-request", - ) - .put( - "version", - 1, - ) - .put( - "request_id", - requestId, - ) - .put( - "requested_at", - OffsetDateTime.now() - .toString(), - ) - .toString() + internal fun newIntent(): SyncRequestIntent = + SyncRequestIntent( + requestId = "sr_${UUID.randomUUID()}", + requestedAt = OffsetDateTime.now().toString(), + ) - val displayName = - "trainlog-sync-request-v1.json" - val error = snapshot.writeJson(displayName, payload) + internal fun publishFullGeneration( + snapshot: DirectExchangeSnapshot, + intent: SyncRequestIntent, + ): SyncRequestResult = publish(snapshot, FULL_GENERATION_REQUEST_NAME, intent) + + internal fun publishLegacy( + snapshot: DirectExchangeSnapshot, + intent: SyncRequestIntent, + ): SyncRequestResult = publish(snapshot, LEGACY_SYNC_REQUEST_NAME, intent) + + private fun publish( + snapshot: DirectExchangeSnapshot, + displayName: String, + intent: SyncRequestIntent, + ): SyncRequestResult { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return SyncRequestResult.Unsupported + } + val error = snapshot.writeJson(displayName, intent.payload()) return if (error == null) { try { visibility.confirm(listOf(java.io.File(canonicalExchangeDirectory(), displayName))) - SyncRequestResult.Requested(requestId) + SyncRequestResult.Requested(intent.requestId) } catch (error: Exception) { SyncRequestResult.Error(error.message ?: "Publication MTP impossible.") } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt index 11c7c4b..4ed4792 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt @@ -60,29 +60,29 @@ internal suspend fun publishBundleAndRequest( terminalLedger: BackgroundSyncRunLedger? = null, ): SyncRequestResult = withContext(Dispatchers.IO) { try { - val opened = exporter.openStorageSnapshot() - if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) { - return@withContext SyncRequestResult.Error("prepare:${opened.message}") - } - val snapshot = - (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot - /* CONTRACT: the request signal remains compatible with an unconfigured - * desktop, so publish the strict standalone V3 snapshot before admission. - * A configured daemon still routes the signal exclusively to the complete - * generation below; this compatibility publication never selects V3. */ - when (val publication = exporter.exportMobileBundle(snapshot)) { - SyncExportResult.Unsupported -> return@withContext SyncRequestResult.Unsupported - is SyncExportResult.Error -> - return@withContext SyncRequestResult.Error("prepare:${publication.message}") - is SyncExportResult.Exported -> Unit - } val directory = canonicalExchangeDirectory() val coordinator = SyncGenerationForegroundCoordinator(repository, visibility) return@withContext when ( val result = coordinator.run( directory, afterPeerPublication = { - when (val request = requestOutbox.requestSync(snapshot)) { + val opened = exporter.openStorageSnapshot() + if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) { + throw IllegalStateException(opened.message) + } + val snapshot = + (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready) + .snapshot + val requestIntent = requestOutbox.newIntent() + /* WHY: a strict V3 snapshot cannot represent causal + * tombstones, but the complete generation can. CONTRACT: + * publish and confirm the modern trigger before attempting + * optional legacy compatibility. INVARIANT: an old desktop + * sees this intent only after a fresh V3 bundle succeeds. */ + when ( + val request = + requestOutbox.publishFullGeneration(snapshot, requestIntent) + ) { is SyncRequestResult.Requested -> Unit SyncRequestResult.Unsupported -> throw IllegalStateException("Generation synchronization is unsupported.") @@ -90,6 +90,27 @@ internal suspend fun publishBundleAndRequest( is SyncRequestResult.Completed -> throw IllegalStateException("Unexpected completed request signal.") } + when (exporter.exportMobileBundle(snapshot)) { + is SyncExportResult.Exported -> + when (requestOutbox.publishLegacy(snapshot, requestIntent)) { + is SyncRequestResult.Requested -> + logDirectExchange( + "trainlog-sync-request-v1.json", + "legacy_compatibility_published", + ) + else -> + logDirectExchange( + "trainlog-sync-request-v1.json", + "legacy_compatibility_unavailable", + ) + } + SyncExportResult.Unsupported, + is SyncExportResult.Error -> + logDirectExchange( + "trainlog-sync-request-v1.json", + "legacy_compatibility_unavailable", + ) + } }, intent = SyncConversationIntent.NEW_EXPLICIT, explicitClaim = explicitClaim, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt index 8ece9de..063476f 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt @@ -177,7 +177,7 @@ class SyncDeploymentConversationTest { tracePhases.containsAll( listOf( "peer_published", - "legacy_trigger_published", + "full_generation_trigger_published", "generation_request_observed", "archive_ack_observed", "generation_capture_started", diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncRequestOutboxTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncRequestOutboxTest.kt index 2fc5ece..08d946f 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncRequestOutboxTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncRequestOutboxTest.kt @@ -3,6 +3,7 @@ package com.labfytools.trainlog.data import java.nio.file.Files import org.json.JSONObject import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -32,4 +33,27 @@ class SyncRequestOutboxTest { directory.deleteRecursively() } } + + @Test + fun oneIntentUsesSameIdentityAcrossFullGenerationAndLegacyChannels() { + val directory = Files.createTempDirectory("trainlog-dual-request-").toFile() + try { + val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(directory) } + val snapshot = + (DirectExchangePublisher { directExchangeDirectoryForPath(directory) }.snapshot() + as DirectExchangeSnapshotResult.Ready).snapshot + val intent = outbox.newIntent() + + assertTrue(outbox.publishFullGeneration(snapshot, intent) is SyncRequestResult.Requested) + assertTrue(outbox.publishLegacy(snapshot, intent) is SyncRequestResult.Requested) + + val full = JSONObject(java.io.File(directory, FULL_GENERATION_REQUEST_NAME).readText()) + val legacy = JSONObject(java.io.File(directory, LEGACY_SYNC_REQUEST_NAME).readText()) + assertEquals(intent.requestId, full.getString("request_id")) + assertEquals(intent.requestId, legacy.getString("request_id")) + assertEquals(full.toString(), legacy.toString()) + } finally { + directory.deleteRecursively() + } + } } diff --git a/tests/test_syncd_routing.py b/tests/test_syncd_routing.py index c05ec7f..6d90c3b 100644 --- a/tests/test_syncd_routing.py +++ b/tests/test_syncd_routing.py @@ -126,17 +126,65 @@ class SyncdRoutingTest(unittest.TestCase): self.assertEqual(1, len(commands)) self.assertEqual("sy_old", json.loads(state.read_text())["run_id"]) - def test_android_production_trigger_no_longer_uses_opt_in_or_legacy_export(self): + def test_android_production_publishes_full_generation_before_optional_legacy(self): source = (ROOT / "android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt").read_text() production = source.split("internal suspend fun publishBundleAndRequest", 1)[1].split( "internal suspend fun publishLegacyBundleAndRequest", 1 )[0] - self.assertIn( - "SyncGenerationForegroundCoordinator(repository, visibility).run", production - ) + self.assertIn("coordinator.run", production) self.assertNotIn("generation-opt-in", production) self.assertIn("exportMobileBundle", production) - self.assertIn("generation below", production) + self.assertLess( + production.index("publishFullGeneration"), production.index("exportMobileBundle") + ) + self.assertGreater( + production.index("publishLegacy"), production.index("exportMobileBundle") + ) + + def test_full_generation_wins_and_retires_older_legacy(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + old = "sr_11111111-1111-4111-8111-111111111111" + new = "sr_22222222-2222-4222-8222-222222222222" + self._write_request(root / syncd.REQUEST_NAME, old, "2026-09-20T11:00:00Z") + self._write_request( + root / syncd.FULL_GENERATION_REQUEST_NAME, new, "2026-09-20T12:00:00Z" + ) + selected = syncd.select_request(root, []) + self.assertEqual((new, "full_generation", [old]), selected) + self.assertIsNone(syncd.select_request(root, [old, new])) + + def test_dual_channel_same_intent_is_one_request(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + request = "sr_22222222-2222-4222-8222-222222222222" + for name in (syncd.REQUEST_NAME, syncd.FULL_GENERATION_REQUEST_NAME): + self._write_request(root / name, request, "2026-09-20T12:00:00Z") + self.assertEqual( + (request, "full_generation", []), syncd.select_request(root, []) + ) + self.assertIsNone(syncd.select_request(root, [request])) + + def test_newer_legacy_remains_eligible_after_full_generation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + full = "sr_22222222-2222-4222-8222-222222222222" + legacy = "sr_33333333-3333-4333-8333-333333333333" + self._write_request( + root / syncd.FULL_GENERATION_REQUEST_NAME, full, "2026-09-20T12:00:00Z" + ) + self._write_request(root / syncd.REQUEST_NAME, legacy, "2026-09-20T13:00:00Z") + self.assertEqual((full, "full_generation", []), syncd.select_request(root, [])) + self.assertEqual((legacy, "legacy", [full]), syncd.select_request(root, [full])) + + @staticmethod + def _write_request(path, request_id, requested_at): + path.write_text(json.dumps({ + "format": "trainlog-sync-request", + "version": 1, + "request_id": request_id, + "requested_at": requested_at, + })) if __name__ == "__main__": diff --git a/tools/trainlog_syncd.py b/tools/trainlog_syncd.py index 7c36a5e..c35c2f8 100755 --- a/tools/trainlog_syncd.py +++ b/tools/trainlog_syncd.py @@ -19,6 +19,8 @@ from pathlib import Path STOP = False REQUEST_NAME = "trainlog-sync-request-v1.json" +FULL_GENERATION_REQUEST_NAME = "trainlog-sync-full-generation-request-v1.json" +SEEN_REQUEST_LIMIT = 64 REQUEST_ID = re.compile(r"sr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") @@ -90,7 +92,7 @@ def load_generation_config(path: Path) -> dict: return value -def request_id(path: Path) -> str | None: +def request(path: Path) -> tuple[str, datetime] | None: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): @@ -106,7 +108,58 @@ def request_id(path: Path) -> str | None: and isinstance(candidate, str) and REQUEST_ID.fullmatch(candidate) ): - return candidate + requested_at = value.get("requested_at") + if not isinstance(requested_at, str): + return None + try: + timestamp = datetime.fromisoformat(requested_at.replace("Z", "+00:00")) + except ValueError: + return None + if timestamp.tzinfo is None: + return None + return candidate, timestamp + return None + + +def load_request_state(path: Path) -> tuple[dict, list[str]]: + try: + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return {}, [] + if not isinstance(state, dict): + return {}, [] + seen = [item for item in state.get("seen_request_ids", []) + if isinstance(item, str) and REQUEST_ID.fullmatch(item)] + historical = state.get("request_id") + if isinstance(historical, str) and REQUEST_ID.fullmatch(historical) and historical not in seen: + seen.append(historical) + return state, seen[-SEEN_REQUEST_LIMIT:] + + +def select_request(root: Path, seen: list[str]) -> tuple[str, str, list[str]] | None: + """Select one unseen intent while preventing an older cross-channel replay. + + WHY: modern Android may publish the same intent on full-generation and + legacy channels, while stale legacy files remain durable on MTP. CONTRACT: + full-generation wins and an older alternate intent is retired. INVARIANT: + one request_id starts at most one daemon conversation. + """ + full = request(root / FULL_GENERATION_REQUEST_NAME) + legacy = request(root / REQUEST_NAME) + unseen_full = full if full is not None and full[0] not in seen else None + unseen_legacy = legacy if legacy is not None and legacy[0] not in seen else None + if unseen_full is not None: + selected_id, selected_at = unseen_full + retired = list(seen) + if ( + unseen_legacy is not None + and unseen_legacy[0] != selected_id + and unseen_legacy[1] <= selected_at + ): + retired.append(unseen_legacy[0]) + return selected_id, "full_generation", retired + if unseen_legacy is not None: + return unseen_legacy[0], "legacy", list(seen) return None @@ -123,16 +176,13 @@ def run_full_generation(args: argparse.Namespace, config_path: Path) -> int: ) if pull.returncode != 0: return 3 - correlated_request = request_id(root / REQUEST_NAME) - if correlated_request is None: + _, seen = load_request_state(args.state) + selected = select_request(root, seen) + if selected is None: return 3 - if args.state.is_file(): - try: - previous = json.loads(args.state.read_text(encoding="utf-8")) - if previous.get("request_id") == correlated_request: - return 3 - except (OSError, UnicodeError, json.JSONDecodeError): - pass + correlated_request, request_channel, seen = selected + seen.append(correlated_request) + seen = list(dict.fromkeys(seen))[-SEEN_REQUEST_LIMIT:] run_id = "sy_" + str(uuid.uuid4()) now = datetime.now(timezone.utc).isoformat() atomic_json( @@ -142,6 +192,8 @@ def run_full_generation(args: argparse.Namespace, config_path: Path) -> int: "version": 1, "run_id": run_id, "request_id": correlated_request, + "request_channel": request_channel, + "seen_request_ids": seen, "trigger": args.trigger, "requested_mode": "full_generation_v1", "effective_mode": "full_generation_v1", diff --git a/tui/src/generation_mtp.c b/tui/src/generation_mtp.c index d23467f..b030183 100644 --- a/tui/src/generation_mtp.c +++ b/tui/src/generation_mtp.c @@ -602,6 +602,18 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io, status = pull_named_file( io, &selection, selection.root, "trainlog-sync-request-v1.json", local, false); } + if (status == TRAINLOG_STATUS_OK) { + /* WHY: strict legacy V3 may be unavailable while a complete causal + * generation remains valid. CONTRACT: pull the dedicated bounded + * coordination object without scanning the exchange tree. INVARIANT: + * this read never acknowledges, deletes, or rewrites remote state. */ + status = pull_named_file(io, + &selection, + selection.root, + "trainlog-sync-full-generation-request-v1.json", + local, + false); + } if (status == TRAINLOG_STATUS_OK) { status = pull_named_file( io, &selection, selection.root, "android-generation-v1.json", local, false); diff --git a/tui/tests/test_generation_mtp.c b/tui/tests/test_generation_mtp.c index 647a182..1de7bf7 100644 --- a/tui/tests/test_generation_mtp.c +++ b/tui/tests/test_generation_mtp.c @@ -244,6 +244,12 @@ static int test_pull_and_manifest_last(void) { "trainlog-sync-request-v1.json", "{\"format\":\"trainlog-sync-request\",\"version\":1,\"request_id\":\"sr_11111111-" "1111-4111-8111-111111111111\",\"requested_at\":\"2026-09-20T12:00:00Z\"}"); + add(14, + 2, + false, + "trainlog-sync-full-generation-request-v1.json", + "{\"format\":\"trainlog-sync-request\",\"version\":1,\"request_id\":\"sr_11111111-" + "1111-4111-8111-111111111111\",\"requested_at\":\"2026-09-20T12:00:00Z\"}"); add(5, 2, true, "android-objects", NULL); add(6, 5, true, "generations", NULL); add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", NULL); @@ -261,6 +267,9 @@ static int test_pull_and_manifest_last(void) { root, "android-objects/generations/gen_11111111-1111-4111-8111-111111111111/payload.json")); CHECK(access(path, R_OK) == 0); + CHECK(trainlog_test_join_path( + path, sizeof(path), root, "trainlog-sync-full-generation-request-v1.json")); + CHECK(access(path, R_OK) == 0); CHECK(trainlog_test_join_path(path, sizeof(path), root, "trainlog-sync-request-v1.json")); CHECK(access(path, R_OK) == 0); CHECK(trainlog_test_join_path(path, sizeof(path), root, "out"));