From 1e9c8aa7412f6e3dbb12dfc9eddf07889adc4ac5 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 20 Sep 2026 14:35:42 +0200 Subject: [PATCH] fix(sync): route Android requests through full generations --- .../trainlog/data/AndroidBackupService.kt | 10 +- .../com/labfytools/trainlog/ui/SyncScreen.kt | 66 +++---- .../trainlog/data/AndroidBackupServiceTest.kt | 26 +++ .../trainlog/ui/SyncBundlePublicationTest.kt | 4 +- tests/test_sync_deployment_tools.py | 4 + tests/test_syncd_routing.py | 88 +++++++++ tools/install_syncd_user.sh | 9 +- tools/package_sync_candidate.py | 2 +- tools/trainlog_syncd.py | 183 ++++++++++++++++-- tui/meson.build | 6 + tui/src/generation_mtp.c | 20 +- tui/tests/test_generation_mtp.c | 8 + 12 files changed, 370 insertions(+), 56 deletions(-) create mode 100644 tests/test_syncd_routing.py diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index 16457d8..70f872d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -26,7 +26,13 @@ internal data class VerifiedAndroidBackup( ) /** Complete same-installation backup container; plaintext is explicit by design. */ -internal class AndroidBackupService(private val context: Context) { +internal class AndroidBackupService( + private val context: Context, + private val productVersion: String = + checkNotNull( + context.packageManager.getPackageInfo(context.packageName, 0).versionName, + ), +) { companion object { const val FORMAT = "trainlog-android-backup" const val VERSION = 1 @@ -140,7 +146,7 @@ internal class AndroidBackupService(private val context: Context) { .put("version", VERSION) .put("package", context.packageName) .put("schema", schema) - .put("product_version", "0.1.2") + .put("product_version", productVersion) .put("plaintext", true) .put("created_at", OffsetDateTime.now().toString()) .put( 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 5fe69df..e925e3c 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 @@ -50,39 +50,37 @@ internal suspend fun publishBundleAndRequest( exporter: SyncExporter, requestOutbox: SyncRequestOutbox, ): SyncRequestResult = withContext(Dispatchers.IO) { - val optIn = java.io.File(canonicalExchangeDirectory(), "trainlog-sync-generation-opt-in-v1.json") - if (optIn.isFile) { - val enabled = try { - val value = org.json.JSONObject(optIn.readText()) - value.keys().asSequence().toSet() == setOf("format", "version", "enabled") && - value.optString("format") == "trainlog-sync-generation-opt-in" && - value.opt("version") is Int && value.getInt("version") == 1 && value.opt("enabled") == true - } catch (_: Exception) { false } - if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.") - 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 - return@withContext when ( - val result = SyncGenerationForegroundCoordinator(repository).run( - canonicalExchangeDirectory(), - afterPeerPublication = { - when (val request = requestOutbox.requestSync(snapshot)) { - is SyncRequestResult.Requested -> Unit - SyncRequestResult.Unsupported -> - throw IllegalStateException("Generation synchronization is unsupported.") - is SyncRequestResult.Error -> throw IllegalStateException(request.message) - is SyncRequestResult.Completed -> - throw IllegalStateException("Unexpected completed request signal.") - } - }, - ) - ) { - is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId) - is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message) - } + 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 + return@withContext when ( + val result = SyncGenerationForegroundCoordinator(repository).run( + canonicalExchangeDirectory(), + afterPeerPublication = { + when (val request = requestOutbox.requestSync(snapshot)) { + is SyncRequestResult.Requested -> Unit + SyncRequestResult.Unsupported -> + throw IllegalStateException("Generation synchronization is unsupported.") + is SyncRequestResult.Error -> throw IllegalStateException(request.message) + is SyncRequestResult.Completed -> + throw IllegalStateException("Unexpected completed request signal.") + } + }, + ) + ) { + is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId) + is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message) + } +} + +/* Compatibility tooling keeps the standalone V3 publisher strict and + * available, but production Android triggers no longer select this path. */ +internal suspend fun publishLegacyBundleAndRequest( + exporter: SyncExporter, + requestOutbox: SyncRequestOutbox, +): SyncRequestResult = withContext(Dispatchers.IO) { logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin") when (val opened = exporter.openStorageSnapshot()) { is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error -> @@ -90,9 +88,7 @@ internal suspend fun publishBundleAndRequest( is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready -> when (val publication = exporter.exportMobileBundle(opened.snapshot)) { SyncExportResult.Unsupported -> SyncRequestResult.Unsupported - is SyncExportResult.Error -> SyncRequestResult.Error( - "prepare:${publication.message}", - ) + is SyncExportResult.Error -> SyncRequestResult.Error("prepare:${publication.message}") is SyncExportResult.Exported -> { logDirectExchange("SYNC_BUNDLE", "coordinator.export.success") logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin") diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidBackupServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidBackupServiceTest.kt index 6bcda71..0079bb6 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidBackupServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidBackupServiceTest.kt @@ -12,6 +12,8 @@ import com.labfytools.trainlog.model.TrackingMode import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.util.UUID +import java.util.zip.ZipInputStream +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -24,6 +26,30 @@ import org.robolectric.annotation.Config class AndroidBackupServiceTest { private val context: Context = ApplicationProvider.getApplicationContext() + @Test + fun manifestUsesInjectedCanonicalProductVersion() { + val name = "backup-version-${UUID.randomUUID()}.db" + val repository = TrainlogRepository(context, name) + try { + val output = ByteArrayOutputStream() + val service = AndroidBackupService(context, "0.1.3-test") + assertTrue(service.create(repository, output) is AndroidBackupResult.Success) + var manifest: JSONObject? = null + ZipInputStream(ByteArrayInputStream(output.toByteArray())).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + if (entry.name == "manifest.json") { + manifest = JSONObject(zip.readBytes().toString(Charsets.UTF_8)) + } + } + } + assertEquals("0.1.3-test", checkNotNull(manifest).getString("product_version")) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + @Test fun completeBackupRestoresDraftPeerLedgersAndPreferences() { val name = "backup-${UUID.randomUUID()}.db" diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt index 8c1a070..cdcb7ec 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt @@ -100,7 +100,7 @@ class SyncBundlePublicationTest { assertTrue(feedback is SaveFeedbackResult.Saved) val writesBeforeFirstClick = directory.writeCounts.toMap() - assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested) + assertTrue(publishLegacyBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested) assertEquals(2, directory.enumerationCount) val required = listOf( "trainlog-mobile-export-v3.json", @@ -138,7 +138,7 @@ class SyncBundlePublicationTest { ShadowLog.clear() val bodyCreatesBefore = directory.createCount("trainlog-exercise-body-zones-v1.json") val writesBeforeSecondClick = directory.writeCounts.toMap() - assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested) + assertTrue(publishLegacyBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested) assertEquals(3, directory.enumerationCount) val phases = ShadowLog.getLogsForTag(DIRECT_EXCHANGE_LOG_TAG).map { it.msg } assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") }) diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index adf277d..2892239 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -75,6 +75,10 @@ class DeploymentToolsTest(unittest.TestCase): self.assertTrue((output / "bin/trainlog-sync-once").is_file()) self.assertTrue((output / "bin/trainlog-syncd").is_file()) self.assertTrue((output / "libexec/trainlog-sync-once").is_file()) + self.assertIn( + 'TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"', + (output / "bin/trainlog-syncd").read_text(), + ) stable_bin = Path(directory) / "stable-bin" stable_bin.mkdir() (stable_bin / "trainlog").symlink_to(output / "bin/trainlog") diff --git a/tests/test_syncd_routing.py b/tests/test_syncd_routing.py new file mode 100644 index 0000000..cdd7209 --- /dev/null +++ b/tests/test_syncd_routing.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Regression coverage for Android/daemon full-generation admission.""" + +import argparse +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("trainlog_syncd", ROOT / "tools/trainlog_syncd.py") +syncd = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(syncd) + + +class SyncdRoutingTest(unittest.TestCase): + def test_android_and_daemon_triggers_route_to_existing_orchestrator(self): + for trigger in ("android", "daemon"): + with self.subTest(trigger=trigger), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + transport = root / "transport" + transport.mkdir() + config = root / "config.json" + config.write_text( + json.dumps( + { + "format": "trainlog-sync-orchestrator-config", + "version": 1, + "enabled": True, + "mode": "mtp", + "expected_peer_id": "peer_11111111-1111-4111-8111-111111111111", + "transport_root": str(transport), + "owned_root": str(root / "owned"), + "timeout_seconds": 30, + } + ) + ) + args = argparse.Namespace( + mtp_adapter=root / "adapter", + state=root / "trainlog.db.sync-run.json", + trigger=trigger, + orchestrator=root / "sync_orchestrator.py", + database=root / "trainlog.db", + ) + commands = [] + + def execute(command, **_kwargs): + commands.append(command) + if command[0] == str(args.mtp_adapter): + (transport / syncd.REQUEST_NAME).write_text( + json.dumps( + { + "format": "trainlog-sync-request", + "version": 1, + "request_id": "sr_22222222-2222-4222-8222-222222222222", + "requested_at": "2026-09-20T12:00:00Z", + } + ) + ) + return subprocess.CompletedProcess(command, 0, "PASS\n", "") + + with mock.patch.object(syncd.subprocess, "run", side_effect=execute), mock.patch.object( + syncd, "append_log" + ): + self.assertEqual(0, syncd.run_full_generation(args, config)) + self.assertEqual(str(args.mtp_adapter), commands[0][0]) + self.assertEqual(str(args.orchestrator), commands[1][1]) + self.assertNotIn("trainlog-sync-once", " ".join(commands[1])) + state = json.loads(args.state.read_text()) + self.assertEqual(trigger, state["trigger"]) + self.assertEqual("full_generation_v1", state["effective_mode"]) + + def test_android_production_trigger_no_longer_uses_opt_in_or_legacy_export(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).run", production) + self.assertNotIn("generation-opt-in", production) + self.assertNotIn("exportMobileBundle", production) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/install_syncd_user.sh b/tools/install_syncd_user.sh index 6524d05..13e5c57 100755 --- a/tools/install_syncd_user.sh +++ b/tools/install_syncd_user.sh @@ -9,6 +9,7 @@ ROOT="$( )" SYNC_ONCE="$ROOT/build/tui/trainlog-sync-once" +MTP_ADAPTER="$ROOT/build/tui/trainlog-generation-mtp-adapter" DAEMON="$ROOT/tools/trainlog_syncd.py" if [[ ! -x "$SYNC_ONCE" ]]; then @@ -17,6 +18,12 @@ if [[ ! -x "$SYNC_ONCE" ]]; then exit 1 fi +if [[ ! -x "$MTP_ADAPTER" ]]; then + echo "missing executable: $MTP_ADAPTER" >&2 + echo "run meson compile -C build first" >&2 + exit 1 +fi + mkdir -p \ "$HOME/.local/bin" \ "$HOME/.config/systemd/user" @@ -37,7 +44,7 @@ Description=Trainlog Android MTP synchronization agent [Service] Type=simple -ExecStart=/usr/bin/python3 "$DAEMON" --sync-once "$SYNC_ONCE" --interval 3 +ExecStart=/usr/bin/python3 "$DAEMON" --sync-once "$SYNC_ONCE" --mtp-adapter "$MTP_ADAPTER" --interval 3 Restart=on-failure RestartSec=2 diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index ef5fa90..46d45f9 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -94,7 +94,7 @@ def main() -> int: ) syncd_launcher = args.output / "bin/trainlog-syncd" syncd_launcher.write_text( - '#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n' + '#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n' ) files = sorted(path for path in args.output.rglob("*") if path.is_file()) inventory = { diff --git a/tools/trainlog_syncd.py b/tools/trainlog_syncd.py index 0632660..7c36a5e 100755 --- a/tools/trainlog_syncd.py +++ b/tools/trainlog_syncd.py @@ -4,13 +4,22 @@ from __future__ import annotations import argparse +import json +import os +import re import signal import subprocess +import sys +import tempfile import time +import uuid +from datetime import datetime, timezone from pathlib import Path STOP = False +REQUEST_NAME = "trainlog-sync-request-v1.json" +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}") def request_stop( @@ -46,6 +55,126 @@ def append_log( handle.write("\n") +def default_database() -> Path: + data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) + return data_home / "trainlog/trainlog.db" + + +def atomic_json(path: Path, value: dict) -> None: + raw = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode() + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + descriptor, temporary = tempfile.mkstemp(prefix=".sync-run-", dir=path.parent) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def load_generation_config(path: Path) -> dict: + value = json.loads(path.read_text(encoding="utf-8")) + if ( + not isinstance(value, dict) + or value.get("format") != "trainlog-sync-orchestrator-config" + or value.get("enabled") is not True + or value.get("mode") not in ("mtp", "auto") + ): + raise RuntimeError("trusted full-generation MTP configuration is invalid") + return value + + +def request_id(path: Path) -> str | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + if not isinstance(value, dict): + return None + candidate = value.get("request_id") + if ( + set(value) == {"format", "version", "request_id", "requested_at"} + and value.get("format") == "trainlog-sync-request" + and type(value.get("version")) is int + and value.get("version") == 1 + and isinstance(candidate, str) + and REQUEST_ID.fullmatch(candidate) + ): + return candidate + return None + + +def run_full_generation(args: argparse.Namespace, config_path: Path) -> int: + config = load_generation_config(config_path) + root = Path(config["transport_root"]) + expected_peer = config["expected_peer_id"] + adapter = args.mtp_adapter + pull = subprocess.run( + [str(adapter), "pull", expected_peer, str(root)], + capture_output=True, + text=True, + check=False, + ) + if pull.returncode != 0: + return 3 + correlated_request = request_id(root / REQUEST_NAME) + if correlated_request 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 + run_id = "sy_" + str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + atomic_json( + args.state, + { + "format": "trainlog-sync-run-report", + "version": 1, + "run_id": run_id, + "request_id": correlated_request, + "trigger": args.trigger, + "requested_mode": "full_generation_v1", + "effective_mode": "full_generation_v1", + "phase": "requested", + "progress_revision": 1, + "result": "running", + "started_at": now, + "updated_at": now, + }, + ) + result = subprocess.run( + [ + sys.executable, + str(args.orchestrator), + "--database", + str(args.database), + "--state", + str(args.state), + "--config", + str(config_path), + "--run-id", + run_id, + "--request-id", + correlated_request, + ], + capture_output=True, + text=True, + check=False, + ) + append_log(result.stdout if result.returncode == 0 else result.stderr or result.stdout) + return result.returncode + + def main() -> int: parser = argparse.ArgumentParser() @@ -55,6 +184,25 @@ def main() -> int: type=Path, ) + parser.add_argument("--trigger", choices=("android", "daemon"), default="android") + parser.add_argument( + "--generation-config", + type=Path, + default=os.environ.get("TRAINLOG_SYNC_GENERATION_CONFIG"), + ) + parser.add_argument( + "--orchestrator", + type=Path, + default=Path(__file__).with_name("sync_orchestrator.py"), + ) + parser.add_argument("--database", type=Path, default=default_database()) + parser.add_argument("--state", type=Path) + parser.add_argument( + "--mtp-adapter", + type=Path, + default=Path(os.environ.get("TRAINLOG_SYNC_MTP_ADAPTER", "")), + ) + parser.add_argument( "--interval", type=float, @@ -63,11 +211,20 @@ def main() -> int: args = parser.parse_args() + if args.state is None: + # The Web adapter owns .sync-run.json. The daemon keeps independent + # durable request replay evidence while sharing the database lock. + args.state = Path(str(args.database) + ".syncd-run.json") + if not args.sync_once.exists(): raise SystemExit( f"sync executable missing: {args.sync_once}" ) + if args.generation_config is not None: + if not args.orchestrator.is_file() or not args.mtp_adapter.is_file(): + raise SystemExit("full-generation runtime is incomplete") + signal.signal( signal.SIGTERM, request_stop, @@ -83,17 +240,21 @@ def main() -> int: ) while not STOP: - result = subprocess.run( - [ - str(args.sync_once), - "--request-only", - "--trigger", - "android", - ], - capture_output=True, - text=True, - check=False, - ) + if args.generation_config is not None: + code = run_full_generation(args, args.generation_config) + result = subprocess.CompletedProcess([], code, "", "") + else: + result = subprocess.run( + [ + str(args.sync_once), + "--request-only", + "--trigger", + args.trigger, + ], + capture_output=True, + text=True, + check=False, + ) if result.returncode == 0: append_log( diff --git a/tui/meson.build b/tui/meson.build index 9d5e59b..e97f20e 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -653,6 +653,12 @@ test_generation_mtp = executable( ) test('generation_mtp', test_generation_mtp) +test( + 'syncd_routing', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_syncd_routing.py'], +) + generation_mtp_adapter_double = executable( 'generation-mtp-adapter-double', 'tests/generation_mtp_adapter_double.c', diff --git a/tui/src/generation_mtp.c b/tui/src/generation_mtp.c index 22e39e0..d23467f 100644 --- a/tui/src/generation_mtp.c +++ b/tui/src/generation_mtp.c @@ -338,14 +338,16 @@ static TrainlogStatus pull_referenced_generation(const TrainlogGenerationMtpIo * TrainlogStatus status; status = one_named(io, selection, selection->root, "android-objects", true, &android_objects); if (status == TRAINLOG_STATUS_OK) { - status = one_named(io, selection, android_objects.item_id, "generations", true, &generations); + status = + one_named(io, selection, android_objects.item_id, "generations", true, &generations); } if (status == TRAINLOG_STATUS_OK) { status = one_named(io, selection, generations.item_id, generation, true, &selected); } if (status != TRAINLOG_STATUS_OK || snprintf(path, sizeof(path), "%s/android-objects/generations/%s", local, generation) < 0 || - strlen(local) + strlen("/android-objects/generations/") + strlen(generation) >= sizeof(path)) { + strlen(local) + strlen("/android-objects/generations/") + strlen(generation) >= + sizeof(path)) { return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status; } char parent[1024]; @@ -357,7 +359,8 @@ static TrainlogStatus pull_referenced_generation(const TrainlogGenerationMtpIo * char objects_path[1024]; if (snprintf(objects_path, sizeof(objects_path), "%s/android-objects", local) < 0 || strlen(local) + strlen("/android-objects") >= sizeof(objects_path) || - mkdir_private(objects_path) != TRAINLOG_STATUS_OK || mkdir_private(parent) != TRAINLOG_STATUS_OK) { + mkdir_private(objects_path) != TRAINLOG_STATUS_OK || + mkdir_private(parent) != TRAINLOG_STATUS_OK) { return TRAINLOG_STATUS_SYSTEM_ERROR; } return pull_folder(io, selection, selected.item_id, path, 0U, &total, &objects); @@ -587,8 +590,17 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io, * remain a normal polling state for the Python conversation. * INVARIANT: no remote object, ACK, tombstone, or retained generation is * removed or rewritten by this bounded read. */ + status = + pull_named_file(io, &selection, selection.root, "android-peer-v1.json", local, true); + } + if (status == TRAINLOG_STATUS_OK) { + /* WHY: the Android request remains the explicit user/foreground + * admission signal even when the conversation uses full generations. + * CONTRACT: copy the bounded signal only; the orchestrator, not this + * transport adapter, owns correlation and all domain mutations. + * INVARIANT: reading the request never acknowledges or removes it. */ status = pull_named_file( - io, &selection, selection.root, "android-peer-v1.json", local, true); + io, &selection, selection.root, "trainlog-sync-request-v1.json", local, false); } if (status == TRAINLOG_STATUS_OK) { status = pull_named_file( diff --git a/tui/tests/test_generation_mtp.c b/tui/tests/test_generation_mtp.c index 6f0632e..647a182 100644 --- a/tui/tests/test_generation_mtp.c +++ b/tui/tests/test_generation_mtp.c @@ -238,6 +238,12 @@ static int test_pull_and_manifest_last(void) { "android-generation-v1.json", "{\"relative_path\":\"android-objects/generations/gen_11111111-1111-4111-8111-" "111111111111\"}"); + add(13, + 2, + false, + "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(5, 2, true, "android-objects", NULL); add(6, 5, true, "generations", NULL); add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", NULL); @@ -255,6 +261,8 @@ 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-request-v1.json")); + CHECK(access(path, R_OK) == 0); CHECK(trainlog_test_join_path(path, sizeof(path), root, "out")); CHECK(mkdir(path, 0700) == 0); char gen[512];