Compare commits

...

2 commits

18 changed files with 418 additions and 68 deletions

View file

@ -4,6 +4,15 @@
Development opened after the stable v0.1.2 release.
- Routed Android foreground and daemon-triggered daily synchronization through
the existing full-generation orchestrator whenever its trusted local
configuration is present. The standalone mobile V3 importer remains strict
and still rejects causally protected live replay; complete generations apply
tombstones before filtering dominated snapshot facts. No schema, exchange
format, causal rule, or synchronization protocol changed. Android backup
manifests now obtain `product_version` from installed package metadata
instead of retaining the historical `0.1.2` literal.
- Unified the Android capture interface around compact dark cards, action
tiles, restrained-radius button variants, wrapping selection grids, and
two-column performed-set entry. Home now focuses on session capture, recent

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

@ -1056,10 +1056,17 @@ coordinator waits for correlated objects, tolerates stale objects left by
interrupted runs, strictly revalidates desktop-retained historical ACKs, and
shows completion only after both durable peer acknowledgements.
Each explicit foreground generation attempt publishes the Android peer before
emitting one fresh legacy request signal for `trainlog-syncd`. If transport is
Each explicit foreground synchronization attempt publishes the Android peer
and enters the full-generation coordinator before emitting one fresh request
signal for `trainlog-syncd`. The signal is only admission/correlation; a
configured daemon routes it to the existing desktop orchestrator rather than
the standalone V3 importer. If transport is
interrupted after generation publication, a later attempt resumes the exact
stored generation instead of recapturing mutable data. An acknowledged Android
generation remains resumable only until the desktop generation for that run is
durably consumed; a fully completed run is then excluded from foreground
selection. This preserves late-ACK recovery without replaying completed runs.
Backup manifests retain format version 1 and the current Android schema. Their
`product_version` is read from installed package metadata, so development
builds record `0.1.3` without a second hard-coded product-version source.

View file

@ -456,10 +456,11 @@ manifest is published after its member objects. The helper runs inside the
orchestrator-owned process group and cannot be selected or parameterized by an
HTTP request.
It polls for a new Android request and invokes the same shared C synchronization
engine used by the TUI.
It does not implement a second synchronization algorithm.
It polls for a new Android request. With trusted full-generation configuration
it admits that request to the existing application orchestrator and peer
worker; without that configuration it retains the shared C/V3 compatibility
engine used by the TUI. It does not implement a second synchronization
algorithm.
## 3. Exercise model

View file

@ -152,15 +152,20 @@ Android inbox/outbox still select V3 and do not move the draft artifact.
boundaries, not active storage choices.
- Desktop synchronization uses direct libudev/libmtp discovery and object
access. No GVFS/FUSE mount is required.
- `trainlog_sync_run()` is shared by the TUI and `trainlog-syncd`.
- `trainlog_sync_run()` remains the strict legacy V3 engine shared with the
TUI and compatibility tooling. When the trusted full-generation
configuration is present, `trainlog-syncd` admits the Android request into
the existing `sync_orchestrator.py`/`sync_peer_worker.py` conversation;
configuration absence retains the legacy fallback.
- The desktop imports Android snapshots, publishes catalog/profile/feedback/AI
companions, and returns a receipt. Stable IDs, artifact idempotence and the
few domain-specific explicit states make defined replay paths idempotent.
Explicit staged generation services now provide coherent V4/companion
capture, immutable manifests, whole-generation SQLite consumption and
durable peer ACKs. A trusted opt-in configuration may select the bounded
durable peer ACKs. The Android foreground trigger now publishes a complete
generation by default. A trusted desktop configuration selects the bounded
production generation MTP adapter; absent configuration leaves V3 as the
default.
compatibility fallback.
- SQLite database files are never synchronized.
- The desktop AI flow uses external `rclone` for Drive inbox/archive and
read-only history export. Android owns no Drive credentials.

View file

@ -135,9 +135,11 @@ Synchronization artifacts are separate from the frozen Trainlog session JSON
v1 format.
V4 history and execution-draft V1 are staged codecs with explicit repository
and command-line entry points. They are deliberately absent from
`trainlog_sync_run()`, request/receipt publication and the Android automatic
inbox/outbox. Deployment must stop the old user worker, replace the installed
and command-line entry points. They remain deliberately absent from
`trainlog_sync_run()`: configured Android/daemon requests now route around that
strict legacy V3 engine into the existing full-generation orchestrator. The
request artifact remains only a correlated admission signal. Deployment must
stop the old user worker, replace the installed
binary/scripts as one unit, migrate only through the new binary, and restart
only after compatibility checks; this ticket performs none of those steps.

View file

@ -1512,6 +1512,18 @@ the `trainlog-syncd` launcher and daemon source, and the generation worker.
This prevents a user unit from silently executing a helper from a different
checkout than the installed desktop candidate.
`syncd_routing` proves that both Android-labelled and daemon-labelled
admissions invoke the packaged `sync_orchestrator.py` full-generation owner
when trusted configuration is present, and never invoke the legacy
`trainlog-sync-once` command in that mode. The generation-MTP boundary test
proves that polling copies the Android request signal without acknowledging or
deleting it. Session-exchange characterization keeps standalone V3 rejection
strict while its complete-envelope case filters a tombstone-dominated live
fact; the causal Android/Desktop round-trip and generation replay/ACK tests
cover restart, idempotence and non-resurrection. `AndroidBackupServiceTest`
injects a product version and verifies that exact value in the ZIP manifest,
preventing a return to a hard-coded release string.
The isolated JDK 17 harness reports **220 Android tests: 215 passed, five
skipped, zero failures/errors**. The skips are optional, externally supplied
copy gates: `BridgeBackupMigrationTest.verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner`,

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