fix(sync): route Android requests through full generations

This commit is contained in:
fy59 2026-09-20 14:35:42 +02:00
parent f27ef5af5f
commit 1e9c8aa741
12 changed files with 370 additions and 56 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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