fix(sync): route Android requests through full generations
This commit is contained in:
parent
f27ef5af5f
commit
1e9c8aa741
12 changed files with 370 additions and 56 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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") })
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
88
tests/test_syncd_routing.py
Normal file
88
tests/test_syncd_routing.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in a new issue