/` and the two disposable databases.
+Revoke the Android folder grant and remove the disposable rclone configuration.
+Retain the redacted run report, generation/ACK identities and SHA-256 evidence.
+
+Live status for this implementation checkout: `NOT_RUN`; no private Drive,
+physical phone or real user data was accessed by automated validation.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index b97fae6..957400d 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -3,7 +3,10 @@
The controlled private rollout is complete for the paired daily installation:
physical libmtp, verified backups, signing continuity, migrations, correlated
ACKs, restart, and idempotent replay passed. Generation mode remains an
-explicit per-installation opt-in; Drive is still unconfigured and unvalidated.
+explicit per-installation opt-in. The additive private Drive transport and
+Android background software are implemented and fake-transport validated;
+private live Drive and post-reboot/manual device validation remain explicit
+deployment gates.
This document owns future work. The implemented baseline is summarized in
[current_state.md](current_state.md); completed narratives and evidence belong
diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md
index 83ffd88..9257caa 100644
--- a/docs/sync_exchange.md
+++ b/docs/sync_exchange.md
@@ -1,5 +1,41 @@
# Synchronization exchange
+## USB-priority and private Drive transport
+
+Full-generation synchronization has one business engine and two byte
+transports. Automatic desktop admission acquires the existing global lock,
+probes the configured Android MTP peer, selects USB when that peer is actually
+usable, and otherwise selects the configured private Drive namespace. A cable
+or retained peer advertisement is not availability evidence.
+
+After a successful USB conversation, desktop attempts to mirror the exact
+current coordination objects and referenced immutable generations to the
+separate configured `Trainlog/Sync/v1` namespace. Artifacts are uploaded and
+read back for size/SHA-256 verification; a generation manifest is uploaded
+after its artifacts and its mutable reference last. Mirror failure is reported
+but never rolls back the already committed USB import or ACK. With Drive
+disabled, historical USB behavior remains independent of rclone.
+
+Drive carries only existing versioned artifacts, manifests, references and
+ACKs. Desktop and Android SQLite files are never eligible transport objects.
+`Trainlog/Sync` is independent from `Trainlog/AI`; the rclone remote is trusted
+installation configuration, not a product constant or an HTTP parameter.
+
+Android uses the system document provider: the user explicitly selects the
+private `Trainlog/Sync/v1` folder, Trainlog persists access to that folder only,
+and Disconnect revokes it. No rclone password, OAuth token or authorization
+header is stored or logged by Trainlog. WorkManager checks Drive immediately
+after connection and then under Android scheduling constraints; periodic work
+has a 15-minute minimum and is not an instant push channel.
+
+The separately enabled USB background mode uses a visible `dataSync`
+foreground-service notification and the same `SyncGenerationCoordinator` as
+SyncScreen. A process-wide guard rejects concurrent UI/service conversations.
+Android 15+ limits `dataSync` foreground-service time and forbids starting it
+from `BOOT_COMPLETED`; after reboot the user must reopen Trainlog and reactivate
+the listener. A filesystem/provider event is only a prompt to validate the
+full run, generation, peer, hash and ACK envelope and never starts a workout.
+
## Manual session preparations V2
`trainlog-session-preparations` V2 is a separate optional desktop-to-Android
diff --git a/docs/tests.md b/docs/tests.md
index 61ee30c..1c45bfd 100644
--- a/docs/tests.md
+++ b/docs/tests.md
@@ -1,5 +1,14 @@
# Tests and validation
+`test_sync_drive_transport` proves Drive artifact-before-manifest and
+manifest-before-reference publication, rejects unsafe/AI namespaces, and keeps
+a partial generation invisible. Orchestrator coverage proves that Drive mirror
+failure cannot undo a completed primary transport. `web_sessions` additionally
+proves duplicate-delivery rejection and stable-identity active-list removal
+after the reserved execution session appears in completed history. Android's
+normal unit suite builds the shared generation coordinator, foreground service
+and WorkManager transport rather than a test-only protocol copy.
+
`web_sessions` exercises production preparation creation, durable request
replay, immutable successor revisions, stale-revision conflict, duplicate
exercise occurrences, list search, persisted detail serialization, revision-
diff --git a/docs/web_sessions.md b/docs/web_sessions.md
index 6ff3365..20a7f49 100644
--- a/docs/web_sessions.md
+++ b/docs/web_sessions.md
@@ -1,5 +1,20 @@
# Web Sessions V1
+Preparation list rows expose independent `editing_state` and `delivery_state`.
+A local draft action saves one new ready revision on the same preparation and
+then creates its delivery; a local ready revision is delivered directly.
+Pending, acknowledged and remote-unknown preparations never create a second
+delivery. The action is disabled while its request is running and every state
+has visible text in addition to color.
+
+Successful delivery admission immediately starts the configured common sync
+orchestrator. If transport is unavailable, the durable pending delivery remains
+retryable; the preparation is not reverted or recreated. When completed
+history contains the delivery's exact `execution_session_id`, the preparation
+leaves the active list while all preparation revisions, entries and delivery
+provenance remain stored. Program completion continues to derive from the Core
+`execution_state`, never from list disappearance.
+
The Sessions route implements four distinct views:
- Preparation: manual preparations and immutable AI proposals;
diff --git a/tests/test_sync_drive_transport.py b/tests/test_sync_drive_transport.py
new file mode 100644
index 0000000..2cf5aee
--- /dev/null
+++ b/tests/test_sync_drive_transport.py
@@ -0,0 +1,145 @@
+import importlib.util
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location(
+ "sync_drive_transport", ROOT / "tools/sync_drive_transport.py"
+)
+drive = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(drive)
+
+
+class MemoryDrive:
+ def __init__(self):
+ self.objects = {}
+ self.uploads = []
+ self.downloads = []
+
+ def upload_verified(self, source, relative):
+ self.uploads.append(relative)
+ self.objects[relative] = Path(source).read_bytes()
+
+ def download(self, relative, destination, optional=False):
+ self.downloads.append(relative)
+ if relative not in self.objects:
+ if optional:
+ return False
+ raise drive.DriveTransportError("missing object")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_bytes(self.objects[relative])
+ return True
+
+
+class DriveTransportTest(unittest.TestCase):
+ def generation(self, root):
+ generation = root / "desktop-objects/generations/gen_one"
+ generation.mkdir(parents=True)
+ artifact = generation / "history.json"
+ artifact.write_bytes(b"{}\n")
+ manifest = {
+ "generation_id": "gen_one",
+ "artifacts": [
+ {
+ "filename": "generations/gen_one/history.json",
+ "sha256": drive.digest(artifact),
+ }
+ ],
+ }
+ (generation / "manifest.json").write_text(json.dumps(manifest))
+ (root / "desktop-generation-v1.json").write_text(
+ json.dumps(
+ {
+ "generation_id": "gen_one",
+ "relative_path": "desktop-objects/generations/gen_one",
+ }
+ )
+ )
+
+ def test_manifest_is_uploaded_after_artifacts_and_reference_is_last(self):
+ with tempfile.TemporaryDirectory() as raw:
+ root = Path(raw)
+ self.generation(root)
+ remote = MemoryDrive()
+ drive.push(remote, root)
+ self.assertLess(
+ remote.uploads.index("desktop-objects/generations/gen_one/history.json"),
+ remote.uploads.index("desktop-objects/generations/gen_one/manifest.json"),
+ )
+ self.assertLess(
+ remote.uploads.index("desktop-objects/generations/gen_one/manifest.json"),
+ remote.uploads.index("desktop-generation-v1.json"),
+ )
+
+ def test_generation_reference_accepts_its_producer_namespace(self):
+ with tempfile.TemporaryDirectory() as raw:
+ root = Path(raw)
+ self.generation(root)
+ self.assertEqual(
+ drive.generation_from_reference(root, "desktop-generation-v1.json"),
+ "desktop-objects/generations/gen_one",
+ )
+
+ value = json.loads((root / "desktop-generation-v1.json").read_text())
+ value["relative_path"] = "android-objects/generations/gen_one"
+ (root / "desktop-generation-v1.json").write_text(json.dumps(value))
+ with self.assertRaises(drive.DriveTransportError):
+ drive.generation_from_reference(root, "desktop-generation-v1.json")
+
+ def test_partial_generation_never_becomes_visible_locally(self):
+ with tempfile.TemporaryDirectory() as raw:
+ root = Path(raw)
+ remote = MemoryDrive()
+ remote.objects["android-generation-v1.json"] = json.dumps(
+ {
+ "generation_id": "gen_one",
+ "relative_path": "android-objects/generations/gen_one",
+ }
+ ).encode()
+ remote.objects["android-objects/generations/gen_one/manifest.json"] = json.dumps(
+ {
+ "generation_id": "gen_one",
+ "artifacts": [
+ {"filename": "generations/gen_one/missing.json"}
+ ],
+ }
+ ).encode()
+ with self.assertRaises(drive.DriveTransportError):
+ drive.pull(remote, root)
+ self.assertFalse((root / "android-objects/generations/gen_one").exists())
+
+ def test_complete_generation_is_pulled_into_its_producer_namespace(self):
+ with tempfile.TemporaryDirectory() as raw:
+ source = Path(raw) / "source"
+ source.mkdir()
+ self.generation(source)
+ remote = MemoryDrive()
+ drive.push(remote, source)
+ destination = Path(raw) / "destination"
+ drive.pull(remote, destination)
+ self.assertEqual(
+ (destination / "desktop-objects/generations/gen_one/history.json").read_bytes(),
+ b"{}\n",
+ )
+
+ remote.downloads.clear()
+ drive.pull(remote, destination)
+ self.assertNotIn(
+ "desktop-objects/generations/gen_one/history.json",
+ remote.downloads,
+ )
+
+ def test_unsafe_or_ai_namespaces_are_rejected(self):
+ for remote in ("", "remote:Trainlog/AI", "remote:Trainlog/Sync/v1\nother"):
+ with self.subTest(remote=remote), self.assertRaises(drive.DriveTransportError):
+ drive.RcloneDrive("rclone", remote)
+ for relative in ("../db", "/absolute", "a\\b"):
+ with self.subTest(relative=relative), self.assertRaises(drive.DriveTransportError):
+ drive.safe_relative(relative)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_sync_orchestrator.py b/tests/test_sync_orchestrator.py
index 533fa91..7ba5245 100644
--- a/tests/test_sync_orchestrator.py
+++ b/tests/test_sync_orchestrator.py
@@ -134,6 +134,42 @@ class OrchestratorTest(unittest.TestCase):
os.kill(pid, 0)
self.assertEqual(json.loads(state.read_text())["phase"], "interrupted")
+ def test_committed_primary_run_survives_drive_mirror_failure(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ sandbox = root / "program"
+ sandbox.mkdir()
+ (sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
+ (sandbox / "sync_drive_transport.py").write_text(
+ "import sys\nprint('injected Drive failure',file=sys.stderr)\nraise SystemExit(1)\n"
+ )
+ peer, desktop = identity("peer_"), identity("peer_")
+ inbound, outbound = identity("gen_"), identity("gen_")
+ report = {
+ "format": "trainlog-sync-worker-report", "version": 1,
+ "run_id": "RUN", "producer_peer_id": peer, "consumer_peer_id": desktop,
+ "inbound_generation_id": inbound, "outbound_generation_id": outbound,
+ "manifest_sha256": "a" * 64, "result": "completed",
+ "sessions_reconciled": 1, "domains": {"history-v4": True}, "drafts": [],
+ "ai_midpoint": {"result": "not_configured"},
+ "ai_post_sync": {"result": "not_configured"},
+ }
+ (sandbox / "sync_peer_worker.py").write_text(
+ "import json,sys\nv=" + repr(report) + "\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\nprint(json.dumps(v))\n"
+ )
+ values = self.fixture(
+ root,
+ version=2,
+ drive_enabled=True,
+ drive_remote="fake:Trainlog/Sync/v1",
+ )
+ result = self.invoke(sandbox / "sync_orchestrator.py", values)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ final = json.loads(values[1].read_text())
+ self.assertEqual(final["phase"], "completed")
+ self.assertEqual(final["drive_state"], "failed")
+ self.assertIn("injected Drive failure", final["drive_diagnostic"])
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_sync_peer_worker.py b/tests/test_sync_peer_worker.py
new file mode 100644
index 0000000..6afab48
--- /dev/null
+++ b/tests/test_sync_peer_worker.py
@@ -0,0 +1,35 @@
+import importlib.util
+import subprocess
+import time
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location(
+ "sync_peer_worker", ROOT / "tools/sync_peer_worker.py"
+)
+worker = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(worker)
+
+
+class SyncPeerWorkerTest(unittest.TestCase):
+ def test_drive_adapter_uses_remaining_conversation_deadline(self):
+ completed = subprocess.CompletedProcess([], 0, b"", b"")
+ with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
+ worker.run_drive_adapter(
+ Path("/fixed/adapter.py"),
+ "pull",
+ "remote:Trainlog/Sync-Test/run",
+ Path("/private/transport"),
+ time.monotonic() + 180,
+ )
+ timeout = run.call_args.kwargs["timeout"]
+ self.assertGreaterEqual(timeout, 178)
+ self.assertLessEqual(timeout, 180)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/sync_drive_transport.py b/tools/sync_drive_transport.py
new file mode 100644
index 0000000..8e39806
--- /dev/null
+++ b/tools/sync_drive_transport.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""Bounded byte transport for the Trainlog generation namespace on rclone.
+
+Business validation, causality, import and acknowledgement remain owned by the
+existing generation services. This adapter only moves immutable bytes and
+mutable run-correlated coordination objects.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import subprocess
+import tempfile
+from pathlib import Path, PurePosixPath
+
+COORDINATION = (
+ "android-peer-v1.json",
+ "android-generation-v1.json",
+ "android-consumption-ack-v1.json",
+ "android-generation-error-v1.json",
+ "request-v1.json",
+ "desktop-archive-acknowledgements-v1.json",
+ "desktop-consumption-ack-v1.json",
+ "desktop-generation-v1.json",
+)
+MAX_FILE = 64 * 1024 * 1024
+MAX_GENERATION = 256 * 1024 * 1024
+
+
+class DriveTransportError(RuntimeError):
+ pass
+
+
+def safe_relative(value: str) -> Path:
+ candidate = PurePosixPath(value)
+ if (
+ not value
+ or value.startswith("/")
+ or "\\" in value
+ or "\x00" in value
+ or any(part in ("", ".", "..") for part in candidate.parts)
+ or len(candidate.parts) > 4
+ ):
+ raise DriveTransportError("unsafe Drive object path")
+ return Path(*candidate.parts)
+
+
+def digest(path: Path) -> str:
+ value = hashlib.sha256()
+ with path.open("rb") as stream:
+ while chunk := stream.read(1024 * 1024):
+ value.update(chunk)
+ return value.hexdigest()
+
+
+class RcloneDrive:
+ def __init__(self, executable: str, remote: str):
+ if not remote or "\n" in remote or "\r" in remote or "Trainlog/AI" in remote:
+ raise DriveTransportError("invalid or AI Drive namespace")
+ self.executable = executable
+ self.remote = remote.rstrip("/")
+
+ def target(self, relative: str) -> str:
+ safe_relative(relative)
+ return f"{self.remote}/{relative}"
+
+ def run(self, *arguments: str, timeout: int = 60) -> subprocess.CompletedProcess[bytes]:
+ try:
+ return subprocess.run(
+ [self.executable, *arguments],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=timeout,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired) as error:
+ raise DriveTransportError(f"Drive transport unavailable: {error}") from error
+
+ def download(self, relative: str, destination: Path, optional: bool = False) -> bool:
+ destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with tempfile.TemporaryDirectory(prefix=".trainlog-drive-pull-", dir=destination.parent) as raw:
+ temporary = Path(raw) / "object"
+ result = self.run("copyto", self.target(relative), str(temporary))
+ if result.returncode != 0:
+ message = result.stderr.decode("utf-8", "replace")[:1024]
+ if optional and any(token in message.lower() for token in ("not found", "doesn't exist", "directory not found")):
+ return False
+ raise DriveTransportError(message.strip() or "Drive download failed")
+ if not temporary.is_file() or temporary.stat().st_size > MAX_FILE:
+ raise DriveTransportError("Drive object exceeds local bound")
+ os.replace(temporary, destination)
+ return True
+
+ def upload_verified(self, source: Path, relative: str) -> None:
+ if source.is_symlink() or not source.is_file() or source.stat().st_size > MAX_FILE:
+ raise DriveTransportError("invalid Drive upload source")
+ result = self.run("copyto", str(source), self.target(relative))
+ if result.returncode != 0:
+ raise DriveTransportError(result.stderr.decode("utf-8", "replace")[:1024].strip())
+ with tempfile.TemporaryDirectory(prefix="trainlog-drive-verify-") as raw:
+ verification = Path(raw) / "object"
+ self.download(relative, verification)
+ if verification.stat().st_size != source.stat().st_size or digest(verification) != digest(source):
+ raise DriveTransportError("Drive upload verification failed")
+
+
+def generation_from_reference(root: Path, name: str) -> str | None:
+ path = root / name
+ if not path.is_file() or path.stat().st_size > 65536:
+ return None
+ value = json.loads(path.read_text(encoding="utf-8"))
+ relative = value.get("relative_path")
+ if not isinstance(relative, str):
+ raise DriveTransportError("invalid generation reference")
+ safe = safe_relative(relative)
+ expected_root = (
+ "android-objects" if name == "android-generation-v1.json" else "desktop-objects"
+ )
+ # CONTRACT: references point only at the immutable generation namespace
+ # owned by their producer. The generation identity is the third path
+ # component; the middle component is the literal ``generations``.
+ if (
+ len(safe.parts) != 3
+ or safe.parts[0] != expected_root
+ or safe.parts[1] != "generations"
+ or safe.parts[2] != value.get("generation_id")
+ ):
+ raise DriveTransportError("uncorrelated generation reference")
+ return relative
+
+
+def pull_generation(drive: RcloneDrive, root: Path, relative: str) -> None:
+ generation_path = safe_relative(relative)
+ producer_root = generation_path.parts[0]
+ generation_relative = Path(*generation_path.parts[1:])
+ destination = root / relative
+ # WHY: polling must remain bounded even when Drive retains many immutable
+ # generations. CONTRACT: os.replace below makes a generation visible
+ # locally only after every listed artifact has downloaded. INVARIANT: a
+ # committed local generation is immutable and is validated by the shared
+ # consumer before import; an incomplete pre-existing path is never hidden.
+ if destination.is_dir() and (destination / "manifest.json").is_file():
+ return
+ if destination.exists():
+ raise DriveTransportError("incomplete local Drive generation")
+ manifest_relative = f"{relative}/manifest.json"
+ with tempfile.TemporaryDirectory(prefix=".trainlog-drive-generation-", dir=root) as raw:
+ staging = Path(raw) / relative
+ drive.download(manifest_relative, staging / "manifest.json")
+ manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8"))
+ artifacts = manifest.get("artifacts")
+ if not isinstance(artifacts, list) or len(artifacts) > 32:
+ raise DriveTransportError("invalid Drive generation manifest")
+ total = (staging / "manifest.json").stat().st_size
+ for artifact in artifacts:
+ filename = artifact.get("filename") if isinstance(artifact, dict) else None
+ if not isinstance(filename, str):
+ raise DriveTransportError("artifact outside Drive generation")
+ artifact_path = safe_relative(filename)
+ if artifact_path.parent != generation_relative:
+ raise DriveTransportError("artifact outside Drive generation")
+ artifact_destination = staging / artifact_path.name
+ drive.download(f"{producer_root}/{filename}", artifact_destination)
+ total += artifact_destination.stat().st_size
+ if total > MAX_GENERATION:
+ raise DriveTransportError("Drive generation exceeds bound")
+ destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ os.replace(staging, destination)
+
+
+def pull(drive: RcloneDrive, root: Path) -> None:
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
+ for name in COORDINATION:
+ drive.download(name, root / name, optional=True)
+ for name in ("android-generation-v1.json", "desktop-generation-v1.json"):
+ relative = generation_from_reference(root, name)
+ if relative is not None:
+ pull_generation(drive, root, relative)
+
+
+def ordered_files(root: Path) -> list[Path]:
+ files = [path for path in root.rglob("*") if path.is_file()]
+ if any(path.is_symlink() for path in files):
+ raise DriveTransportError("Drive outbox contains a symbolic link")
+ # CONTRACT: a generation is consumable only after its manifest exists;
+ # coordination references are published after the referenced directory.
+ return sorted(
+ files,
+ key=lambda path: (
+ 2 if path.name.endswith("generation-v1.json") else
+ 1 if path.name == "manifest.json" else 0,
+ path.relative_to(root).as_posix(),
+ ),
+ )
+
+
+def push(drive: RcloneDrive, root: Path) -> None:
+ if not root.is_dir():
+ raise DriveTransportError("Drive outbox is unavailable")
+ for source in ordered_files(root):
+ drive.upload_verified(source, source.relative_to(root).as_posix())
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("operation", choices=("pull", "push"))
+ parser.add_argument("remote")
+ parser.add_argument("root", type=Path)
+ parser.add_argument("--rclone", default=os.environ.get("TRAINLOG_RCLONE", "rclone"))
+ args = parser.parse_args()
+ drive = RcloneDrive(args.rclone, args.remote)
+ if args.operation == "pull":
+ pull(drive, args.root)
+ else:
+ push(drive, args.root)
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except (DriveTransportError, json.JSONDecodeError, OSError) as error:
+ print(f"Drive transport failed: {error}", file=os.sys.stderr)
+ raise SystemExit(1) from None
diff --git a/tools/sync_orchestrator.py b/tools/sync_orchestrator.py
index cd1fd23..2a0bb72 100644
--- a/tools/sync_orchestrator.py
+++ b/tools/sync_orchestrator.py
@@ -20,6 +20,7 @@ import sys
import tempfile
import time
import ctypes
+import shutil
from datetime import datetime, timezone
from pathlib import Path
@@ -182,6 +183,57 @@ def validate_result(value: dict, run_id: str) -> dict:
return value
+def drive_adapter_path() -> Path:
+ return Path(__file__).with_name("sync_drive_transport.py")
+
+
+def mirror_current_exchange(transport_root: Path, remote: str, timeout: int) -> None:
+ """Mirror only current protocol objects; never traverse application data."""
+ coordination = (
+ "android-peer-v1.json",
+ "android-generation-v1.json",
+ "android-consumption-ack-v1.json",
+ "android-generation-error-v1.json",
+ "request-v1.json",
+ "desktop-archive-acknowledgements-v1.json",
+ "desktop-consumption-ack-v1.json",
+ "desktop-generation-v1.json",
+ )
+ with tempfile.TemporaryDirectory(prefix="trainlog-drive-mirror-", dir=transport_root.parent) as raw:
+ outbox = Path(raw)
+ for name in coordination:
+ source = transport_root / name
+ if source.is_file() and not source.is_symlink():
+ shutil.copy2(source, outbox / name)
+ for name in ("android-generation-v1.json", "desktop-generation-v1.json"):
+ reference = outbox / name
+ if not reference.is_file():
+ continue
+ value = load_json(reference, MAX_REPORT)
+ relative = value.get("relative_path")
+ if not isinstance(relative, str):
+ raise RuntimeError("invalid generation reference during Drive mirror")
+ source = (transport_root / relative).resolve()
+ if not source.is_relative_to(transport_root.resolve()) or not source.is_dir():
+ raise RuntimeError("generation reference escapes transport root")
+ destination = outbox / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copytree(source, destination, copy_function=shutil.copy2)
+ result = subprocess.run(
+ [sys.executable, str(drive_adapter_path()), "push", remote, str(outbox)],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=timeout,
+ check=False,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(
+ result.stderr.decode("utf-8", "replace")[:MAX_DIAGNOSTIC].strip()
+ or "Drive mirror failed"
+ )
+
+
def read_worker(
child: subprocess.Popen[bytes], state_path: Path, state: dict, run_id: str, timeout: int
) -> tuple[dict, bytes]:
@@ -276,7 +328,7 @@ def run(args: argparse.Namespace) -> int:
config = load_json(config_path, MAX_CONFIG)
if state.get("run_id") != args.run_id or state.get("request_id") != args.request_id:
raise RuntimeError("admission identity mismatch")
- if set(config) != {
+ base_keys = {
"format",
"version",
"enabled",
@@ -285,16 +337,24 @@ def run(args: argparse.Namespace) -> int:
"transport_root",
"owned_root",
"timeout_seconds",
- }:
+ }
+ drive_keys = {"drive_enabled", "drive_remote"}
+ if set(config) not in (base_keys, base_keys | drive_keys):
raise RuntimeError("invalid trusted sync configuration")
if (
config["format"] != "trainlog-sync-orchestrator-config"
- or config["version"] != 1
+ or config["version"] not in (1, 2)
or config["enabled"] is not True
):
raise RuntimeError("full-generation synchronization is disabled")
- if config["mode"] not in ("directory", "mtp"):
+ if config["mode"] not in ("directory", "mtp", "drive", "auto"):
raise RuntimeError("invalid transport mode")
+ drive_enabled = config.get("drive_enabled", False)
+ drive_remote = config.get("drive_remote", "")
+ if type(drive_enabled) is not bool or (drive_enabled and (not isinstance(drive_remote, str) or not drive_remote)):
+ raise RuntimeError("invalid Drive configuration")
+ if config["mode"] in ("drive", "auto") and not drive_enabled:
+ raise RuntimeError("Drive transport is not configured")
for key in ("transport_root", "owned_root"):
if not isinstance(config[key], str) or not Path(config[key]).is_absolute():
raise RuntimeError("trusted paths must be absolute")
@@ -333,6 +393,28 @@ def run(args: argparse.Namespace) -> int:
"TRAINLOG_SYNC_DATABASE": args.database,
}
)
+ selected_mode = config["mode"]
+ adapter = Path(
+ os.environ.get(
+ "TRAINLOG_SYNC_MTP_ADAPTER",
+ str(Path(__file__).resolve().parents[1] / "build/tui/trainlog-generation-mtp-adapter"),
+ )
+ )
+ if selected_mode == "auto":
+ # WHY: cable presence is not peer availability. A successful pull
+ # is the bounded proof that the configured Trainlog peer is usable.
+ try:
+ probe = subprocess.run(
+ [str(adapter), "pull", config["expected_peer_id"], config["transport_root"]],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=min(timeout, 30),
+ check=False,
+ ) if adapter.is_absolute() and os.access(adapter, os.X_OK) else None
+ except (OSError, subprocess.TimeoutExpired):
+ probe = None
+ selected_mode = "mtp" if probe is not None and probe.returncode == 0 else "drive"
command = [
sys.executable,
str(Path(__file__).with_name("sync_peer_worker.py")),
@@ -349,19 +431,16 @@ def run(args: argparse.Namespace) -> int:
"--timeout",
str(timeout),
"--mode",
- config["mode"],
+ selected_mode,
]
- if config["mode"] == "mtp":
- adapter = os.environ.get(
- "TRAINLOG_SYNC_MTP_ADAPTER",
- str(
- Path(__file__).resolve().parents[1]
- / "build/tui/trainlog-generation-mtp-adapter"
- ),
- )
- if not Path(adapter).is_absolute() or not os.access(adapter, os.X_OK):
+ if selected_mode == "mtp":
+ if not adapter.is_absolute() or not os.access(adapter, os.X_OK):
raise RuntimeError("fixed MTP adapter executable is unavailable")
- command.extend(["--mtp-adapter", adapter])
+ command.extend(["--mtp-adapter", str(adapter)])
+ elif selected_mode == "drive":
+ command.extend(
+ ["--drive-adapter", str(drive_adapter_path()), "--drive-remote", drive_remote]
+ )
child = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
@@ -384,6 +463,20 @@ def run(args: argparse.Namespace) -> int:
raise
finally:
_owned_process_group = None
+ drive_state = "disabled"
+ drive_diagnostic = ""
+ if drive_enabled:
+ if selected_mode == "drive":
+ drive_state = "success"
+ else:
+ try:
+ mirror_current_exchange(Path(config["transport_root"]), drive_remote, timeout)
+ drive_state = "mirrored"
+ except Exception as error:
+ # CONTRACT: committed USB synchronization is never rolled
+ # back because its additive safety mirror is unavailable.
+ drive_state = "failed"
+ drive_diagnostic = str(error)[:MAX_DIAGNOSTIC]
update(
state_path,
state,
@@ -400,6 +493,10 @@ def run(args: argparse.Namespace) -> int:
manifest_sha256=result["manifest_sha256"],
ai_midpoint=result["ai_midpoint"],
ai_post_sync=result["ai_post_sync"],
+ usb_state="success" if selected_mode == "mtp" else "unavailable",
+ drive_state=drive_state,
+ drive_diagnostic=drive_diagnostic,
+ transport=selected_mode,
finished_at=datetime.now(timezone.utc).isoformat(),
)
return 0
diff --git a/tools/sync_peer_worker.py b/tools/sync_peer_worker.py
index e041caf..8bad60c 100644
--- a/tools/sync_peer_worker.py
+++ b/tools/sync_peer_worker.py
@@ -192,6 +192,62 @@ def push_desktop_generation(
)
+def run_drive_adapter(
+ adapter: Path,
+ operation: str,
+ remote: str,
+ root: Path,
+ deadline: float,
+) -> None:
+ """Run the fixed Drive byte adapter without exposing configuration to HTTP."""
+ remaining = max(1, int(deadline - time.monotonic()))
+ try:
+ result = subprocess.run(
+ [sys.executable, str(adapter), operation, remote, str(root)],
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ # CONTRACT: the trusted configuration already bounds the complete
+ # conversation to at most 900 seconds. A full verified Drive pull
+ # may legitimately need more than 60 seconds because every object
+ # is downloaded and hashed before the generation becomes visible.
+ timeout=remaining,
+ check=False,
+ )
+ except subprocess.TimeoutExpired as error:
+ raise RuntimeError("transport_timeout: Drive adapter timed out") from error
+ if result.returncode != 0:
+ raise RuntimeError(
+ result.stderr.decode("utf-8", "replace")[:1024].strip()
+ or "Drive adapter failed"
+ )
+
+
+def push_drive(
+ adapter: Path,
+ remote: str,
+ root: Path,
+ relative_paths: list[str],
+ deadline: float,
+) -> None:
+ """Publish a phase-owned outbox through the shared Drive byte adapter."""
+ with tempfile.TemporaryDirectory(prefix="trainlog-drive-outbox-", dir=root.parent) as directory:
+ outbox = Path(directory)
+ os.chmod(outbox, 0o700)
+ for relative in relative_paths:
+ source = root / relative
+ destination = outbox / relative
+ if source.is_symlink() or not source.exists() or not source.resolve().is_relative_to(root.resolve()):
+ raise RuntimeError(f"invalid Drive outbox source: {relative}")
+ destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ if source.is_dir():
+ if any(path.is_symlink() for path in source.rglob("*")):
+ raise RuntimeError(f"Drive outbox contains a symbolic link: {relative}")
+ shutil.copytree(source, destination, copy_function=shutil.copy2)
+ else:
+ shutil.copy2(source, destination)
+ run_drive_adapter(adapter, "push", remote, outbox, deadline)
+
+
def wait_file(path: Path, deadline: float, pump=None) -> None:
while not path.is_file():
if time.monotonic() >= deadline:
@@ -294,8 +350,10 @@ def main() -> int:
parser.add_argument("--run-id", required=True)
parser.add_argument("--expected-peer", required=True)
parser.add_argument("--timeout", required=True, type=int)
- parser.add_argument("--mode", choices=("directory", "mtp"), default="directory")
+ parser.add_argument("--mode", choices=("directory", "mtp", "drive"), default="directory")
parser.add_argument("--mtp-adapter", type=Path)
+ parser.add_argument("--drive-adapter", type=Path)
+ parser.add_argument("--drive-remote")
args = parser.parse_args()
deadline = time.monotonic() + args.timeout
pump = None
@@ -316,6 +374,27 @@ def main() -> int:
args.transport_root,
deadline,
)
+ elif args.mode == "drive":
+ if args.drive_adapter is None or not args.drive_remote:
+ raise RuntimeError("Drive adapter and remote are required")
+ last_drive_pull = 0.0
+
+ def pump_drive(force: bool = False) -> None:
+ nonlocal last_drive_pull
+ now = time.monotonic()
+ if not force and now - last_drive_pull < 5.0:
+ return
+ run_drive_adapter(
+ args.drive_adapter,
+ "pull",
+ args.drive_remote,
+ args.transport_root,
+ deadline,
+ )
+ last_drive_pull = time.monotonic()
+
+ pump = pump_drive
+ pump_drive(True)
peer = load_peer(args.transport_root, args.expected_peer)
with closing(generation.connect_database(args.database)) as db:
generation.require_schema(db)
@@ -351,6 +430,14 @@ def main() -> int:
["request-v1.json", "desktop-archive-acknowledgements-v1.json"],
deadline,
)
+ elif args.mode == "drive":
+ push_drive(
+ args.drive_adapter,
+ args.drive_remote,
+ args.transport_root,
+ ["request-v1.json", "desktop-archive-acknowledgements-v1.json"],
+ deadline,
+ )
emit(
args.run_id,
"waiting_android_publication",
@@ -378,6 +465,14 @@ def main() -> int:
["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"],
deadline,
)
+ elif args.mode == "drive":
+ push_drive(
+ args.drive_adapter,
+ args.drive_remote,
+ args.transport_root,
+ ["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"],
+ deadline,
+ )
if ack["result"] != "consumed":
raise RuntimeError("desktop rejected Android generation")
emit(
@@ -419,6 +514,26 @@ def main() -> int:
ref["relative_path"],
deadline,
)
+ elif args.mode == "drive":
+ push_drive(
+ args.drive_adapter,
+ args.drive_remote,
+ args.transport_root,
+ [ref["relative_path"]],
+ deadline,
+ )
+ push_drive(
+ args.drive_adapter,
+ args.drive_remote,
+ args.transport_root,
+ [
+ "request-v1.json",
+ "desktop-archive-acknowledgements-v1.json",
+ "desktop-consumption-ack-v1.json",
+ "desktop-generation-v1.json",
+ ],
+ deadline,
+ )
emit(
args.run_id,
"published",
diff --git a/tui/meson.build b/tui/meson.build
index 8875bd8..6f21839 100644
--- a/tui/meson.build
+++ b/tui/meson.build
@@ -856,6 +856,11 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_orchestrator.py'],
)
+test(
+ 'sync_drive_transport',
+ find_program('python3'),
+ args: [meson.project_source_root() / 'tests/test_sync_drive_transport.py'],
+)
test(
'sync_deployment_tools',
find_program('python3'),
diff --git a/tui/src/web_sessions.c b/tui/src/web_sessions.c
index 7a93697..c17e894 100644
--- a/tui/src/web_sessions.c
+++ b/tui/src/web_sessions.c
@@ -19,11 +19,15 @@ typedef struct ListDefinition {
static const ListDefinition LISTS[] = {
{"preparation",
- "SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at "
+ "SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at,"
+ "p.editing_state,p.delivery_state "
"FROM session_preparations p JOIN session_preparation_revisions r ON "
"r.revision_id=p.current_revision_id "
"LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id "
- "WHERE p.withdrawn_at IS NULL AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR "
+ "WHERE p.withdrawn_at IS NULL AND NOT EXISTS(SELECT 1 FROM "
+ "session_preparation_deliveries d JOIN sessions completed ON "
+ "completed.session_id=d.execution_session_id WHERE d.preparation_id=p.preparation_id) "
+ "AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR "
"EXISTS(SELECT 1 FROM "
"session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE "
"pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
@@ -31,7 +35,7 @@ static const ListDefinition LISTS[] = {
"COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?2 OFFSET ?3"},
{"proposal",
"SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,CASE WHEN d.published_at IS NULL THEN "
- "'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at "
+ "'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at,NULL,NULL "
"FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON e.draft_row_id=d.id "
"WHERE d.withdrawn_at IS NULL AND (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR "
"EXISTS(SELECT 1 FROM "
@@ -42,7 +46,7 @@ static const ListDefinition LISTS[] = {
{"draft",
"SELECT "
"d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d."
- "payload_json,'$.exercises'),0),COALESCE(d.started_at,'') "
+ "payload_json,'$.exercises'),0),COALESCE(d.started_at,''),NULL,NULL "
"FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
"f.session_id=d.session_id) "
"AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND "
@@ -52,7 +56,7 @@ static const ListDefinition LISTS[] = {
{"history",
"SELECT "
"s.session_id,s.session_type,substr(s.started_at,1,10),'completed',COUNT(se.entry_id),s."
- "started_at "
+ "started_at,NULL,NULL "
"FROM sessions s LEFT JOIN session_exercises se ON se.session_row_id=s.id "
"WHERE (?1='' OR s.session_type LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
"session_exercises he JOIN exercises x ON x.id=he.exercise_row_id WHERE "
@@ -158,6 +162,8 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
!add_nullable_text(document, item, "state", statement, 3) ||
!yyjson_mut_obj_add_sint(document, item, "occurrence_count", occurrence_count) ||
!add_nullable_text(document, item, "sort_timestamp", statement, 5) ||
+ !add_nullable_text(document, item, "editing_state", statement, 6) ||
+ !add_nullable_text(document, item, "delivery_state", statement, 7) ||
!yyjson_mut_arr_add_val(items, item)) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
@@ -208,6 +214,7 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
"CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END,"
+ "p.editing_state,p.delivery_state,"
"p.source_proposal_id,source.title,p.withdrawn_at,w.withdrawal_id,"
"w.acknowledged_at FROM session_preparations p JOIN "
"session_preparation_revisions r ON r.revision_id=p.current_revision_id "
@@ -257,6 +264,11 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
}
}
if (!proposal) {
+ if (!add_nullable_text(document, root, "editing_state", statement, 8) ||
+ !add_nullable_text(document, root, "delivery_state", statement, 9)) {
+ (void)sqlite3_finalize(statement);
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
static const char *const provenance_keys[] = {"source_proposal_id",
"source_proposal_title",
"withdrawn_at",
@@ -264,7 +276,7 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
"withdrawal_acknowledged_at"};
for (column = 0; column < 5; ++column) {
if (!add_nullable_text(
- document, root, provenance_keys[column], statement, column + 8)) {
+ document, root, provenance_keys[column], statement, column + 10)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
@@ -1274,6 +1286,9 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
database->connection,
"SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND "
"p.current_revision_id=?2 AND p.withdrawn_at IS NULL AND p.editing_state='ready' "
+ "AND p.delivery_state='local' AND NOT EXISTS(SELECT 1 FROM "
+ "session_preparation_deliveries d WHERE d.preparation_id=p.preparation_id AND "
+ "d.state IN('pending','acknowledged','remote_unknown')) "
"AND EXISTS(SELECT 1 FROM "
"session_preparation_entries e WHERE e.revision_id=p.current_revision_id)",
-1,
diff --git a/tui/tests/test_web_sessions.c b/tui/tests/test_web_sessions.c
index fdc59e8..f092185 100644
--- a/tui/tests/test_web_sessions.c
+++ b/tui/tests/test_web_sessions.c
@@ -227,8 +227,7 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
"\"target_sets\":1,\"target_reps\":1,\"target_duration_seconds\":null,"
"\"target_weight_kg\":null,\"notes\":null}]}";
TrainlogDatabase *database = NULL;
- TrainlogWebSessionsPageQuery query = {
- TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 64U, ""};
+ TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 64U, ""};
char *created = NULL;
char *delivery = NULL;
char *withdrawal = NULL;
@@ -245,6 +244,7 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
char revision_id[128];
char local_preparation_id[128];
char local_revision_id[128];
+ char execution_session_id[128];
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
@@ -289,6 +289,14 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
"request-withdraw-deliver",
&delivery,
&delivery_size) == TRAINLOG_STATUS_OK);
+ CHECK(trainlog_web_sessions_deliver_json(database,
+ preparation_id,
+ revision_id,
+ "request-withdraw-deliver-duplicate",
+ &replayed,
+ &replayed_size) == TRAINLOG_STATUS_CONFLICT);
+ CHECK(replayed == NULL && replayed_size == 0U);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 1);
CHECK(trainlog_web_sessions_withdraw_json(database,
preparation_id,
revision_id,
@@ -317,8 +325,7 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM ai_session_drafts") == 1);
CHECK(scalar(database,
- "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'") ==
- 1);
+ "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'") == 1);
CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(page, preparation_id) == NULL);
@@ -353,10 +360,8 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
strlen(LOCAL_READY_BODY),
&replayed,
&replayed_size) == TRAINLOG_STATUS_OK);
- CHECK(json_string(replayed,
- "preparation_id",
- local_preparation_id,
- sizeof(local_preparation_id)));
+ CHECK(json_string(
+ replayed, "preparation_id", local_preparation_id, sizeof(local_preparation_id)));
CHECK(json_string(replayed, "revision_id", local_revision_id, sizeof(local_revision_id)));
free(replayed);
replayed = NULL;
@@ -372,6 +377,57 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM ai_session_drafts") == 1);
+ /* A completed occurrence hides the preparation by its reserved stable
+ * execution identity while retaining every preparation/delivery row. */
+ free(replayed);
+ replayed = NULL;
+ replayed_size = 0U;
+ CHECK(trainlog_web_sessions_save_json(database,
+ NULL,
+ "",
+ "request-completed-create",
+ LOCAL_READY_BODY,
+ strlen(LOCAL_READY_BODY),
+ &replayed,
+ &replayed_size) == TRAINLOG_STATUS_OK);
+ CHECK(json_string(
+ replayed, "preparation_id", local_preparation_id, sizeof(local_preparation_id)));
+ CHECK(json_string(replayed, "revision_id", local_revision_id, sizeof(local_revision_id)));
+ free(replayed);
+ replayed = NULL;
+ replayed_size = 0U;
+ CHECK(trainlog_web_sessions_deliver_json(database,
+ local_preparation_id,
+ local_revision_id,
+ "request-completed-deliver",
+ &replayed,
+ &replayed_size) == TRAINLOG_STATUS_OK);
+ CHECK(json_string(
+ replayed, "execution_session_id", execution_session_id, sizeof(execution_session_id)));
+ free(page);
+ page = NULL;
+ page_size = 0U;
+ CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
+ TRAINLOG_STATUS_OK);
+ CHECK(strstr(page, local_preparation_id) != NULL);
+ {
+ char *sql =
+ sqlite3_mprintf("INSERT INTO sessions(session_id,started_at,ended_at,session_type) "
+ "VALUES(%Q,'2026-09-20T10:00:00Z','2026-09-20T11:00:00Z','training')",
+ execution_session_id);
+ CHECK(sql != NULL);
+ CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
+ sqlite3_free(sql);
+ }
+ free(page);
+ page = NULL;
+ page_size = 0U;
+ CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
+ TRAINLOG_STATUS_OK);
+ CHECK(strstr(page, local_preparation_id) == NULL);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 2);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 3);
+
free(created);
free(delivery);
free(withdrawal);
diff --git a/web/src/api/sessions.ts b/web/src/api/sessions.ts
index 7b291c4..5a861f0 100644
--- a/web/src/api/sessions.ts
+++ b/web/src/api/sessions.ts
@@ -1,4 +1,4 @@
-import { mutationCsrfToken } from './sync'
+import { mutationCsrfToken, newRequestId, startSync } from './sync'
export type SessionCollection = 'preparations' | 'proposals' | 'drafts' | 'history'
@@ -9,6 +9,8 @@ export interface SessionListItem {
state: string
occurrence_count: number
sort_timestamp: string
+ editing_state?: 'draft' | 'ready' | null
+ delivery_state?: 'local' | 'pending' | 'acknowledged' | 'remote_unknown' | 'cancelled' | null
}
export interface SessionPage {
@@ -52,6 +54,8 @@ export interface SessionDetail {
withdrawal_id?: string | null
withdrawal_acknowledged_at?: string | null
state: string
+ editing_state?: 'draft' | 'ready'
+ delivery_state?: 'local' | 'pending' | 'acknowledged' | 'remote_unknown' | 'cancelled'
occurrences: PreparationOccurrence[]
}
@@ -83,7 +87,9 @@ function validListItem(value: unknown): value is SessionListItem {
return typeof value.identity === 'string' && typeof value.title === 'string' &&
(value.date === null || typeof value.date === 'string') && typeof value.state === 'string' &&
Number.isSafeInteger(value.occurrence_count) && Number(value.occurrence_count) >= 0 &&
- typeof value.sort_timestamp === 'string'
+ typeof value.sort_timestamp === 'string' &&
+ (value.editing_state === undefined || value.editing_state === null || value.editing_state === 'draft' || value.editing_state === 'ready') &&
+ (value.delivery_state === undefined || value.delivery_state === null || ['local', 'pending', 'acknowledged', 'remote_unknown', 'cancelled'].includes(String(value.delivery_state)))
}
export async function fetchSessionPage(
@@ -236,6 +242,11 @@ export async function prepareForAndroid(identity: string, revision: string): Pro
const reason = object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
throw new Error(reason)
}
+ /* WHY: a durable delivery must become reachable before the desktop can be
+ * powered down. CONTRACT: admission reuses the one sync orchestrator and
+ * its global lock; failure never removes or duplicates the pending delivery.
+ * INVARIANT: the transport is not part of delivery identity. */
+ await startSync(newRequestId())
}
export interface PreparationWithdrawalResult {
diff --git a/web/src/api/sync.ts b/web/src/api/sync.ts
index e8a0f12..9b61c9f 100644
--- a/web/src/api/sync.ts
+++ b/web/src/api/sync.ts
@@ -37,6 +37,10 @@ export interface SyncStatus {
drafts?: SyncDraftSummary[]
started_at?: string | null
finished_at?: string | null
+ transport?: 'directory' | 'mtp' | 'drive'
+ usb_state?: 'unavailable' | 'skipped' | 'running' | 'success' | 'failed'
+ drive_state?: 'disabled' | 'unavailable' | 'running' | 'mirrored' | 'success' | 'failed'
+ drive_diagnostic?: string
}
let csrfToken = ''
@@ -85,6 +89,10 @@ function parse(value: unknown): SyncStatus {
!optionalString(status.error_code) ||
!optionalString(status.started_at) ||
!optionalString(status.finished_at) ||
+ !optionalString(status.drive_diagnostic) ||
+ (status.transport !== undefined && !['directory', 'mtp', 'drive'].includes(status.transport)) ||
+ (status.usb_state !== undefined && !['unavailable', 'skipped', 'running', 'success', 'failed'].includes(status.usb_state)) ||
+ (status.drive_state !== undefined && !['disabled', 'unavailable', 'running', 'mirrored', 'success', 'failed'].includes(status.drive_state)) ||
(status.progress_revision !== undefined &&
(!Number.isSafeInteger(status.progress_revision) || status.progress_revision < 0))) {
throw new Error('statut sync invalide')
diff --git a/web/src/components/SyncControl.tsx b/web/src/components/SyncControl.tsx
index d3d6859..e96de29 100644
--- a/web/src/components/SyncControl.tsx
+++ b/web/src/components/SyncControl.tsx
@@ -28,7 +28,7 @@ const committedPhases = new Set([
function statusAction(status: SyncStatus | null): string {
if (status?.phase === 'waiting_android_publication') {
- return 'Ouvrez l’écran de synchronisation Android et laissez le téléphone déverrouillé.'
+ return 'Demande envoyée ; Trainlog Android doit être joignable par le service de fond ou Drive.'
}
if (status?.phase === 'failed' && status.error_code === 'device_unavailable') {
return 'Connectez et déverrouillez le téléphone, puis réessayez.'
@@ -135,6 +135,19 @@ export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
{action && {action}
}
{status?.error_code && Code : {status.error_code}
}
{status?.diagnostic && {status.diagnostic}
}
+ {status?.usb_state && Android USB : {{
+ unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours',
+ success: '✓ synchronisé', failed: '⚠ non traité',
+ }[status.usb_state]}
}
+ {status?.drive_state && Google Drive : {{
+ disabled: 'désactivé', unavailable: 'non disponible', running: 'en cours',
+ mirrored: '✓ miroir à jour', success: '✓ synchronisé', failed: '⚠ miroir non mis à jour',
+ }[status.drive_state]}
}
+ {status?.drive_diagnostic && {status.drive_diagnostic}
}
+ {status?.phase === 'waiting_android_publication' && Demande : envoyée · Android : génération attendue
}
+ {status?.inbound_generation_id && PC : import terminé · {status.inbound_generation_id}
}
+ {status?.outbound_generation_id && PC : génération publiée · {status.outbound_generation_id}
}
+ {status?.phase === 'completed' && Android : import terminé · ACK : reçu · Résultat : terminé
}
{status?.sessions_reconciled !== null && status?.sessions_reconciled !== undefined && (
Séances rapprochées : {status.sessions_reconciled}
)}
diff --git a/web/src/routes/SessionsPage.tsx b/web/src/routes/SessionsPage.tsx
index 0b61c45..dd4b3c7 100644
--- a/web/src/routes/SessionsPage.tsx
+++ b/web/src/routes/SessionsPage.tsx
@@ -498,6 +498,7 @@ export function SessionsPage() {
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState('')
const [statusMessage, setStatusMessage] = useState('')
+ const [sendingPreparation, setSendingPreparation] = useState(null)
const deleteReturnFocus = useRef(null)
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const loadRevision = useRef(0)
@@ -583,6 +584,36 @@ export function SessionsPage() {
setDeleting(false)
}
}
+ const sendPreparation = async (item: SessionListEntry) => {
+ if (sendingPreparation !== null || item.collection !== 'preparations' ||
+ item.delivery_state !== 'local') return
+ setSendingPreparation(item.identity)
+ setStatusMessage('')
+ try {
+ const detail = await fetchSessionDetail('preparation', item.identity)
+ let revision = detail.revision_id
+ if (detail.editing_state === 'draft') {
+ const saved = await savePreparation({
+ title: detail.title,
+ session_type: detail.session_type,
+ planned_for: detail.planned_for,
+ notes: detail.notes,
+ editing_state: 'ready',
+ occurrences: detail.occurrences.map(commandOccurrence),
+ }, detail.identity, detail.revision_id)
+ revision = saved.revision_id
+ }
+ await prepareForAndroid(detail.identity, revision)
+ setStatusMessage('Préparation à synchroniser vers Android.')
+ } catch (reason) {
+ setStatusMessage(reason instanceof Error
+ ? `Envoi Android impossible : ${reason.message}`
+ : 'Envoi Android impossible.')
+ } finally {
+ setSendingPreparation(null)
+ load()
+ }
+ }
if (editor) return setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} />
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
@@ -636,6 +667,15 @@ export function SessionsPage() {
{!pending && !failed && visibleItems.length === 0 && Aucun résultat pour cette vue.
}
{visibleItems.map((item) => {
const kind = deletionKind(item.collection)
+ const deliveryLabel = item.collection !== 'preparations' ? null
+ : item.delivery_state === 'pending' ? 'À synchroniser'
+ : item.delivery_state === 'acknowledged' ? 'Reçue sur Android'
+ : item.delivery_state === 'remote_unknown' ? 'État Android inconnu'
+ : item.delivery_state === 'cancelled' ? 'Livraison annulée'
+ : 'Locale'
+ const sendLabel = item.editing_state === 'draft'
+ ? 'Marquer prête et envoyer vers Android'
+ : 'Envoyer vers Android'
return
+ {item.collection === 'preparations' && item.delivery_state === 'local' && }
+ {item.collection === 'preparations' && item.delivery_state !== 'local' && {deliveryLabel}}