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. */ /** 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 { companion object {
const val FORMAT = "trainlog-android-backup" const val FORMAT = "trainlog-android-backup"
const val VERSION = 1 const val VERSION = 1
@ -140,7 +146,7 @@ internal class AndroidBackupService(private val context: Context) {
.put("version", VERSION) .put("version", VERSION)
.put("package", context.packageName) .put("package", context.packageName)
.put("schema", schema) .put("schema", schema)
.put("product_version", "0.1.2") .put("product_version", productVersion)
.put("plaintext", true) .put("plaintext", true)
.put("created_at", OffsetDateTime.now().toString()) .put("created_at", OffsetDateTime.now().toString())
.put( .put(

View file

@ -50,39 +50,37 @@ internal suspend fun publishBundleAndRequest(
exporter: SyncExporter, exporter: SyncExporter,
requestOutbox: SyncRequestOutbox, requestOutbox: SyncRequestOutbox,
): SyncRequestResult = withContext(Dispatchers.IO) { ): SyncRequestResult = withContext(Dispatchers.IO) {
val optIn = java.io.File(canonicalExchangeDirectory(), "trainlog-sync-generation-opt-in-v1.json") val opened = exporter.openStorageSnapshot()
if (optIn.isFile) { if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
val enabled = try { return@withContext SyncRequestResult.Error("prepare:${opened.message}")
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 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") logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
when (val opened = exporter.openStorageSnapshot()) { when (val opened = exporter.openStorageSnapshot()) {
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error -> is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
@ -90,9 +88,7 @@ internal suspend fun publishBundleAndRequest(
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready -> is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready ->
when (val publication = exporter.exportMobileBundle(opened.snapshot)) { when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
is SyncExportResult.Error -> SyncRequestResult.Error( is SyncExportResult.Error -> SyncRequestResult.Error("prepare:${publication.message}")
"prepare:${publication.message}",
)
is SyncExportResult.Exported -> { is SyncExportResult.Exported -> {
logDirectExchange("SYNC_BUNDLE", "coordinator.export.success") logDirectExchange("SYNC_BUNDLE", "coordinator.export.success")
logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin") 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.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.UUID import java.util.UUID
import java.util.zip.ZipInputStream
import org.json.JSONObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -24,6 +26,30 @@ import org.robolectric.annotation.Config
class AndroidBackupServiceTest { class AndroidBackupServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext() 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 @Test
fun completeBackupRestoresDraftPeerLedgersAndPreferences() { fun completeBackupRestoresDraftPeerLedgersAndPreferences() {
val name = "backup-${UUID.randomUUID()}.db" val name = "backup-${UUID.randomUUID()}.db"

View file

@ -100,7 +100,7 @@ class SyncBundlePublicationTest {
assertTrue(feedback is SaveFeedbackResult.Saved) assertTrue(feedback is SaveFeedbackResult.Saved)
val writesBeforeFirstClick = directory.writeCounts.toMap() val writesBeforeFirstClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested) assertTrue(publishLegacyBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(2, directory.enumerationCount) assertEquals(2, directory.enumerationCount)
val required = listOf( val required = listOf(
"trainlog-mobile-export-v3.json", "trainlog-mobile-export-v3.json",
@ -138,7 +138,7 @@ class SyncBundlePublicationTest {
ShadowLog.clear() ShadowLog.clear()
val bodyCreatesBefore = directory.createCount("trainlog-exercise-body-zones-v1.json") val bodyCreatesBefore = directory.createCount("trainlog-exercise-body-zones-v1.json")
val writesBeforeSecondClick = directory.writeCounts.toMap() val writesBeforeSecondClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested) assertTrue(publishLegacyBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(3, directory.enumerationCount) assertEquals(3, directory.enumerationCount)
val phases = ShadowLog.getLogsForTag(DIRECT_EXCHANGE_LOG_TAG).map { it.msg } val phases = ShadowLog.getLogsForTag(DIRECT_EXCHANGE_LOG_TAG).map { it.msg }
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") }) 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-sync-once").is_file())
self.assertTrue((output / "bin/trainlog-syncd").is_file()) self.assertTrue((output / "bin/trainlog-syncd").is_file())
self.assertTrue((output / "libexec/trainlog-sync-once").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 = Path(directory) / "stable-bin"
stable_bin.mkdir() stable_bin.mkdir()
(stable_bin / "trainlog").symlink_to(output / "bin/trainlog") (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" SYNC_ONCE="$ROOT/build/tui/trainlog-sync-once"
MTP_ADAPTER="$ROOT/build/tui/trainlog-generation-mtp-adapter"
DAEMON="$ROOT/tools/trainlog_syncd.py" DAEMON="$ROOT/tools/trainlog_syncd.py"
if [[ ! -x "$SYNC_ONCE" ]]; then if [[ ! -x "$SYNC_ONCE" ]]; then
@ -17,6 +18,12 @@ if [[ ! -x "$SYNC_ONCE" ]]; then
exit 1 exit 1
fi 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 \ mkdir -p \
"$HOME/.local/bin" \ "$HOME/.local/bin" \
"$HOME/.config/systemd/user" "$HOME/.config/systemd/user"
@ -37,7 +44,7 @@ Description=Trainlog Android MTP synchronization agent
[Service] [Service]
Type=simple 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 Restart=on-failure
RestartSec=2 RestartSec=2

View file

@ -94,7 +94,7 @@ def main() -> int:
) )
syncd_launcher = args.output / "bin/trainlog-syncd" syncd_launcher = args.output / "bin/trainlog-syncd"
syncd_launcher.write_text( 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()) files = sorted(path for path in args.output.rglob("*") if path.is_file())
inventory = { inventory = {

View file

@ -4,13 +4,22 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import os
import re
import signal import signal
import subprocess import subprocess
import sys
import tempfile
import time import time
import uuid
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
STOP = False 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( def request_stop(
@ -46,6 +55,126 @@ def append_log(
handle.write("\n") 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: def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@ -55,6 +184,25 @@ def main() -> int:
type=Path, 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( parser.add_argument(
"--interval", "--interval",
type=float, type=float,
@ -63,11 +211,20 @@ def main() -> int:
args = parser.parse_args() 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(): if not args.sync_once.exists():
raise SystemExit( raise SystemExit(
f"sync executable missing: {args.sync_once}" 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.signal(
signal.SIGTERM, signal.SIGTERM,
request_stop, request_stop,
@ -83,17 +240,21 @@ def main() -> int:
) )
while not STOP: while not STOP:
result = subprocess.run( if args.generation_config is not None:
[ code = run_full_generation(args, args.generation_config)
str(args.sync_once), result = subprocess.CompletedProcess([], code, "", "")
"--request-only", else:
"--trigger", result = subprocess.run(
"android", [
], str(args.sync_once),
capture_output=True, "--request-only",
text=True, "--trigger",
check=False, args.trigger,
) ],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0: if result.returncode == 0:
append_log( append_log(

View file

@ -653,6 +653,12 @@ test_generation_mtp = executable(
) )
test('generation_mtp', test_generation_mtp) 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 = executable(
'generation-mtp-adapter-double', 'generation-mtp-adapter-double',
'tests/generation_mtp_adapter_double.c', 'tests/generation_mtp_adapter_double.c',

View file

@ -338,14 +338,16 @@ static TrainlogStatus pull_referenced_generation(const TrainlogGenerationMtpIo *
TrainlogStatus status; TrainlogStatus status;
status = one_named(io, selection, selection->root, "android-objects", true, &android_objects); status = one_named(io, selection, selection->root, "android-objects", true, &android_objects);
if (status == TRAINLOG_STATUS_OK) { 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) { if (status == TRAINLOG_STATUS_OK) {
status = one_named(io, selection, generations.item_id, generation, true, &selected); status = one_named(io, selection, generations.item_id, generation, true, &selected);
} }
if (status != TRAINLOG_STATUS_OK || if (status != TRAINLOG_STATUS_OK ||
snprintf(path, sizeof(path), "%s/android-objects/generations/%s", local, generation) < 0 || 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; return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status;
} }
char parent[1024]; char parent[1024];
@ -357,7 +359,8 @@ static TrainlogStatus pull_referenced_generation(const TrainlogGenerationMtpIo *
char objects_path[1024]; char objects_path[1024];
if (snprintf(objects_path, sizeof(objects_path), "%s/android-objects", local) < 0 || if (snprintf(objects_path, sizeof(objects_path), "%s/android-objects", local) < 0 ||
strlen(local) + strlen("/android-objects") >= sizeof(objects_path) || 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 TRAINLOG_STATUS_SYSTEM_ERROR;
} }
return pull_folder(io, selection, selected.item_id, path, 0U, &total, &objects); 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. * remain a normal polling state for the Python conversation.
* INVARIANT: no remote object, ACK, tombstone, or retained generation is * INVARIANT: no remote object, ACK, tombstone, or retained generation is
* removed or rewritten by this bounded read. */ * 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( 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) { if (status == TRAINLOG_STATUS_OK) {
status = pull_named_file( status = pull_named_file(

View file

@ -238,6 +238,12 @@ static int test_pull_and_manifest_last(void) {
"android-generation-v1.json", "android-generation-v1.json",
"{\"relative_path\":\"android-objects/generations/gen_11111111-1111-4111-8111-" "{\"relative_path\":\"android-objects/generations/gen_11111111-1111-4111-8111-"
"111111111111\"}"); "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(5, 2, true, "android-objects", NULL);
add(6, 5, true, "generations", NULL); add(6, 5, true, "generations", NULL);
add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", 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, root,
"android-objects/generations/gen_11111111-1111-4111-8111-111111111111/payload.json")); "android-objects/generations/gen_11111111-1111-4111-8111-111111111111/payload.json"));
CHECK(access(path, R_OK) == 0); 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(trainlog_test_join_path(path, sizeof(path), root, "out"));
CHECK(mkdir(path, 0700) == 0); CHECK(mkdir(path, 0700) == 0);
char gen[512]; char gen[512];