Compare commits

..

2 commits

Author SHA1 Message Date
fy59
dd991830ac build(sync): add candidate and recovery readiness tools 2026-09-17 13:50:08 +02:00
fy59
7b34ca15d8 feat(sync): wire bounded generation coordination 2026-09-17 13:50:08 +02:00
24 changed files with 747 additions and 78 deletions

View file

@ -2,6 +2,11 @@
## Unreleased — 0.1.2
- Added opt-in foreground generation coordination, peer-evidenced capabilities,
truthful bounded Web progress, descendant cleanup, an integrated Android ↔
desktop Firefox proof, and synthetic candidate/SQLite recovery tooling on the
development branch. Physical MTP and deployment remain pending.
- Completed the grouped `TRAINLOG_SYNC_WEB_END_TO_END_V1` mission: the opt-in
full-generation orchestrator, protected asynchronous Web API, global sync
control, durable reconnect reporting, Dashboard refresh and read-only draft

View file

@ -0,0 +1,61 @@
package com.labfytools.trainlog.data
import java.io.File
import java.io.FileOutputStream
import java.time.Duration
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
internal sealed interface ForegroundGenerationResult {
data class Completed(val runId: String) : ForegroundGenerationResult
data class Error(val message: String) : ForegroundGenerationResult
}
/** Owns the bounded, user-initiated Android side of a generation conversation. */
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
private val service = SyncGenerationService(repository)
private fun publish(path: File, value: String) {
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
FileOutputStream(temporary).use { stream ->
stream.write(value.toByteArray()); stream.flush(); stream.fd.sync()
}
if (!temporary.renameTo(path)) throw SyncGenerationException("cannot publish ${path.name}")
}
private fun await(path: File, deadline: Long): File {
while (!path.isFile) {
if (System.nanoTime() >= deadline) throw SyncGenerationException("timeout waiting for ${path.name}")
Thread.sleep(50)
}
return path
}
fun run(directory: File, timeout: Duration = Duration.ofMinutes(5), afterPublication: (() -> Unit)? = null): ForegroundGenerationResult = try {
if (!directory.isDirectory) throw SyncGenerationException("exchange directory is unavailable")
val peer = service.peerId()
publish(File(directory, "android-peer-v1.json"), JSONObject()
.put("format", "trainlog-sync-peer").put("version", 1).put("peer_id", peer)
.put("capabilities", JSONArray(listOf("generation-manifest-v1", "generation-ack-v1",
"causal-delete-v1", "mobile-history-v4", "execution-draft-v1"))).toString())
val deadline = System.nanoTime() + timeout.toNanos()
val request = JSONObject(await(File(directory, "request-v1.json"), deadline).readText())
if (request.getString("android_peer_id") != peer) throw SyncGenerationException("request targets another Android peer")
val captured = service.capture(directory, request.getString("desktop_peer_id"), request.getString("run_id"))
val published = service.publish(captured, File(directory, "android-objects"))
publish(File(directory, "android-generation-v1.json"), JSONObject()
.put("format", "trainlog-sync-generation-reference").put("version", 1)
.put("run_id", captured.runId).put("generation_id", captured.generationId)
.put("manifest_sha256", captured.manifestSha256)
.put("relative_path", published.relativeTo(directory).path).toString())
afterPublication?.invoke()
service.acceptAcknowledgement(await(File(directory, "desktop-consumption-ack-v1.json"), deadline).readBytes())
val reference = JSONObject(await(File(directory, "desktop-generation-v1.json"), deadline).readText())
val acknowledgement = service.consume(File(directory, reference.getString("relative_path")))
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
ForegroundGenerationResult.Completed(captured.runId)
} catch (error: Exception) {
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
}
}

View file

@ -105,6 +105,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
value
}
/* CONTRACT: capability advertisements bind to this installation identity;
* callers cannot supply or replace it. */
internal fun peerId(): String = stablePeerId("android")
fun capture(
ownedRoot: File,
consumerPeerId: String,

View file

@ -4127,6 +4127,12 @@ class TrainlogRepository(
} catch (error: Exception) {
return ExecutionDraftImportResult.Invalid(error.message ?: "Invalid execution draft.")
}
val localPayloads = JSONObject(buildExecutionDraftExportV1Json()).getJSONArray("drafts").let { values ->
buildMap { for (index in 0 until values.length()) {
val value = values.getJSONObject(index)
put(value.getString("session_id"), value)
} }
}
val db = database.writableDatabase
var active = 0; var pending = 0; var unchanged = 0; var stale = 0
return try {
@ -4147,10 +4153,33 @@ class TrainlogRepository(
arrayOf(sessionId, draft.revisionId),
).use { if (!it.moveToFirst()) null else Pair(if (it.isNull(0)) null else it.getString(0), it.getString(1)) }
if (known != null) {
if (known != Pair(draft.parentRevisionId, encoded)) throw SyncLifecycleConflict(sessionId)
/* CONTRACT: active/pending is receiver-owned scheduling state, not
* revision content. A peer may echo the same immutable revision
* with a different local state without causing a conflict. */
val knownPayload = JSONObject(known.second).apply { remove("state") }
val receivedPayload = JSONObject(encoded).apply { remove("state") }
if (known.first != draft.parentRevisionId ||
canonicalCausal(knownPayload) != canonicalCausal(receivedPayload))
throw SyncLifecycleConflict(sessionId)
if (current?.first == draft.revisionId) unchanged += 1 else stale += 1
continue
}
if (current?.first == draft.revisionId) {
val localPayload = localPayloads[sessionId] ?: throw SyncLifecycleConflict(sessionId)
val localCausal = JSONObject(localPayload.toString()).apply { remove("state") }
val receivedCausal = JSONObject(encoded).apply { remove("state") }
if (canonicalCausal(localCausal) != canonicalCausal(receivedCausal))
throw SyncLifecycleConflict(sessionId)
/* Persist the locally-created revision on first peer echo so
* later replays retain the same contradiction protection. */
db.insertOrThrow("execution_draft_revisions", null, ContentValues().apply {
put("session_id", sessionId); put("revision_id", draft.revisionId)
putOptionalString("parent_revision_id", draft.parentRevisionId)
put("payload_json", encoded)
})
unchanged += 1
continue
}
if (current != null && current.first != draft.parentRevisionId) throw SyncLifecycleConflict(sessionId)
if (current?.second == "active") {
persistImportedExecutionDraft(db, draft, payload); active += 1

View file

@ -25,6 +25,10 @@ import com.labfytools.trainlog.data.SyncExportResult
import com.labfytools.trainlog.data.SyncReceiptResult
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ForegroundGenerationResult
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
import com.labfytools.trainlog.data.canonicalExchangeDirectory
import com.labfytools.trainlog.data.logDirectExchange
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@ -37,9 +41,24 @@ import com.labfytools.trainlog.R
/* Package-visible for orchestration tests: this is the production ordering
* boundary, not a duplicate test-only implementation. */
internal suspend fun publishBundleAndRequest(
repository: TrainlogRepository,
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.")
return@withContext when (val result = SyncGenerationForegroundCoordinator(repository).run(canonicalExchangeDirectory())) {
is ForegroundGenerationResult.Completed -> SyncRequestResult.Requested(result.runId)
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
}
}
logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
when (val opened = exporter.openStorageSnapshot()) {
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
@ -78,6 +97,7 @@ internal fun visibleReceiptStatus(
@Composable
fun SyncScreen(
repository: TrainlogRepository,
inbox: SyncCatalogInbox,
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
@ -315,7 +335,7 @@ fun SyncScreen(
syncRunning = true
coroutineScope.launch {
try {
when (val result = publishBundleAndRequest(exporter, requestOutbox)) {
when (val result = publishBundleAndRequest(repository, exporter, requestOutbox)) {
is SyncRequestResult.Requested -> {
success = true
pendingRequestId = result.requestId

View file

@ -139,7 +139,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.Statistics -> StatisticsDashboard(repository)
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(inbox) { exportSnapshot(); catalogRevision++ }
}}
}

View file

@ -0,0 +1,99 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.File
import java.time.Duration
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SyncDeploymentConversationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun awaitFile(file: File): File {
val deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos()
while (!file.isFile) {
if (System.nanoTime() >= deadline) error("timeout waiting for ${file.name}")
Thread.sleep(25)
}
return file
}
private fun publish(file: File, value: String) {
val temporary = File(file.parentFile, ".${file.name}.tmp-${UUID.randomUUID()}")
temporary.outputStream().use { output ->
output.write(value.toByteArray()); output.flush(); output.fd.sync()
}
check(temporary.renameTo(file)) { "cannot publish ${file.name}" }
}
@Test
fun foregroundAndroidServiceCompletesCorrelatedDesktopConversation() {
val suppliedTransport = System.getenv("TRAINLOG_SYNC_TRANSPORT_ROOT")
val transport = suppliedTransport?.let(::File) ?: kotlin.io.path.createTempDirectory("deployment-conversation-").toFile()
val repositoryRoot = generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile }
.first { File(it, "tools/sync_peer_worker.py").isFile }
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, databaseName)
var localWorker: Process? = null
try {
transport.mkdirs()
val exercise = (repository.createExercise(NewExerciseProfile(
"Deployment conversation", RecordingMode.SETS, TrackingMode.REPS, 0,
)) as CreateExerciseResult.Created).exercise
val occurrence = SessionExerciseDraft(exercise = exercise,
sets = listOf(SessionSetDraft(reps = 7, weightKg = 22.5)))
assertEquals(ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(occurrence))))
val coordinator = SyncGenerationForegroundCoordinator(repository)
val release = System.getenv("TRAINLOG_SYNC_TEST_RELEASE_FILE")
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
val result = executor.submit<ForegroundGenerationResult> {
coordinator.run(transport, Duration.ofSeconds(30)) {
release?.let { awaitFile(File(it)) }
}
}
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
if (suppliedTransport == null) {
val desktop = File(transport, "desktop.sqlite")
val fixture = ProcessBuilder(File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktop.absolutePath).directory(repositoryRoot).start()
assertEquals(0, fixture.waitFor())
localWorker = ProcessBuilder("python3", File(repositoryRoot, "tools/sync_peer_worker.py").absolutePath,
"--database", desktop.absolutePath, "--transport-root", transport.absolutePath,
"--owned-root", File(transport, "owned").absolutePath, "--run-id", "sy_${UUID.randomUUID()}",
"--expected-peer", peer.getString("peer_id"), "--timeout", "30").directory(repositoryRoot)
.redirectErrorStream(true).start()
}
assertTrue(result.get(35, java.util.concurrent.TimeUnit.SECONDS) is ForegroundGenerationResult.Completed)
executor.shutdownNow()
repository.close()
repository = TrainlogRepository(context, databaseName)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded)
localWorker?.let { worker ->
val output = worker.inputStream.bufferedReader().readText()
assertEquals("worker failed:\n$output", 0, worker.waitFor())
assertTrue(output.contains("\"result\":\"completed\""))
}
} finally {
localWorker?.destroyForcibly()
repository.close()
context.deleteDatabase(databaseName)
if (suppliedTransport == null) transport.deleteRecursively()
}
}
}

View file

@ -100,7 +100,7 @@ class SyncBundlePublicationTest {
assertTrue(feedback is SaveFeedbackResult.Saved)
val writesBeforeFirstClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertTrue(publishBundleAndRequest(repository, 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(exporter, outbox) is SyncRequestResult.Requested)
assertTrue(publishBundleAndRequest(repository, 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

@ -1,5 +1,12 @@
# Architecture
Full-generation synchronization uses the common admission lock and a fixed
coordinator worker. Browser input cannot select commands, paths, timeouts or
capabilities. Peer identity/capabilities come from Android advertisement;
Android participates only during a foreground user action. Typed progress is
bounded and durable, and completion is corroborated by SQLite generation/ACK
records.
## 1. System boundary
Trainlog Core is the product authority and separates capture, durable history,

View file

@ -1,5 +1,10 @@
# Current implementation state
The development branch has opt-in full-generation coordination with durable Web
progress and a foreground Android conversation. The cross-implementation
directory-I/O-double proof is integrated. Physical MTP, release signing, user
backups, main integration and deployment are not complete.
Snapshot date: **2026-09-17**.
This document owns the current implemented state. Historical checkpoints and

View file

@ -0,0 +1,33 @@
# Sync deployment readiness V1 evidence
Status: **SOFTWARE BLOCKED — physical production MTP adapter not completed**.
This corrective tranche replaces the earlier configurable peer fixture with a
fixed desktop worker and a production Android foreground coordinator. The real
Firefox scenario executes the C HTTP adapter, durable run state, both generation
services, transactional imports, real ACK creation/acceptance and store reopen.
It uses a directory object-I/O double and therefore does not claim USB/MTP.
Decisive targeted evidence:
- `SyncDeploymentConversationTest`: Android generation capture/publication,
desktop consume/ACK, desktop return generation, Android consume/ACK and draft
survival after reopen.
- `test_web_sync_browser.py`: Firefox click, observable desktop commit before the
Android barrier is released, completion, active draft display and reload.
- `test_sync_orchestrator.py`: untrusted-command rejection, pre-accumulation
64-KiB stdout enforcement, and SIGKILL escalation for an owned helper and
descendant that ignore SIGTERM.
- `test_sync_deployment_tools.py`: SQLite backup captures committed WAL state and
passes integrity/foreign-key checks; candidate inventory contains the fixed
helper and hashes.
The tests also exposed and corrected two integration defects: execution-draft
`active`/`pending` state was incorrectly treated as immutable revision content,
and coordination files could become visible before their bytes were complete.
Not performed: physical MTP, real phone access, release signature verification,
real Drive, user backups, main merge, service switch or deployment. The Selenium
`ResourceWarning` remains visible and is associated with an HTTP 405 during the
external Firefox/geckodriver teardown path; no blanket warning suppression is
used. It remains a toolchain limitation until independently fixed or upgraded.

View file

@ -1,5 +1,10 @@
# Roadmap
Before controlled rollout, complete and validate the physical libmtp generation
adapter, establish a legitimate installed-Android backup path, verify release
signing continuity and obtain explicit rollout approval. Do not enable generation
mode or advance `main` before those gates.
This document owns future work. The implemented baseline is summarized in
[current_state.md](current_state.md); completed narratives and evidence belong
in [reviews](reviews/) and [CHANGELOG.md](../CHANGELOG.md).

View file

@ -1,5 +1,46 @@
# Full-generation sync rollout readiness
## Deployment-readiness candidate
The generation path remains disabled unless desktop configuration authorizes a
specific persisted Android peer and Android contains the strict opt-in document
`Documents/Trainlog/trainlog-sync-generation-opt-in-v1.json`. Absence preserves
V3; malformed opt-in fails explicitly. The Web coordinator owns a fixed worker,
bounded typed progress and its descendant process group. The foreground Android
coordinator advertises its real peer identity, publishes manifest-last bytes,
consumes the return generation and emits a durable correlated ACK.
The integrated proof uses a directory object-I/O double. It is not evidence for
physical libmtp behavior. The shipped physical generation adapter is not yet
complete, so generation mode must not be enabled on services.
### Candidate, backup and recovery
1. Build and validate one revision, then run `python3
tools/package_sync_candidate.py --output <new-private-directory>`. Its
inventory records hashes, schemas, protocols, entry points and dependencies.
2. `assembleDebug` is development evidence only. Rollout requires the existing
release signing identity, verification of its public certificate and a valid
increasing `versionCode`; this mission reads no signing secret.
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
foreign-key checks.
4. V3 is not an Android backup and omits active-draft and causal state. No safe
backup path for the installed release was demonstrated without device access.
This is a hard gate: an authorized procedure must capture the app database,
WAL-consistent state and active draft before update.
5. Replacing a binary does not roll back a schema. Before any exchange, a
mutually compatible captured pair may be restored. After causal writes or
ACKs, never restore only one peer: stop synchronization, preserve both stores
and all artifacts, and use an explicit paired recovery plan.
6. The eight-generation-per-peer ceiling is an admission gate. Exhaustion must
preserve pending generations, tombstones, finalizations and ACK ledgers; it
must never trigger hidden eviction.
Actual rollout needs one separate approval for the signed APK, desktop bundle,
verified user backups, coordinated service quiescence, pairing, synthetic smoke
and non-destructive hardware smoke. Drive remains separate and untested here.
This is a non-executed rollout checklist. The development branch implements the
path; the daily installation still uses V3.

View file

@ -1,5 +1,12 @@
# Tests and validation
`SyncDeploymentConversationTest` executes both production generation services
and reopens Android storage. `test_web_sync_browser.py` adds a real Firefox click
through the C adapter, observes the desktop commit at an Android test barrier,
then releases the real ACK. This proves the directory transport double, not
physical MTP. Orchestrator tests cover bounded streams and descendant cleanup;
deployment-tool tests cover consistent SQLite backup and candidate inventory.
The `ai_history_export` test covers an empty database, a simple session,
distinct multi-occurrences of one exercise, ordered sets, exact occurrence
feedback, session-owned follow-up feedback, JSON escaping/UTF-8, MAX history,

View file

@ -0,0 +1,29 @@
import json, sqlite3, subprocess, tempfile, unittest
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
class DeploymentToolsTest(unittest.TestCase):
def test_sqlite_backup_captures_committed_wal_and_restores_invariants(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);source=root/"source.db";backup=root/"backup.db"
db=sqlite3.connect(source);db.execute("PRAGMA journal_mode=WAL");db.execute("PRAGMA foreign_keys=ON")
db.executescript("CREATE TABLE parent(id INTEGER PRIMARY KEY);CREATE TABLE child(parent_id INTEGER REFERENCES parent);INSERT INTO parent VALUES(7);INSERT INTO child VALUES(7);");db.commit()
result=subprocess.run(["python3",str(ROOT/"tools/backup_trainlog_sqlite.py"),str(source),str(backup)],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
with sqlite3.connect(backup) as restored:
self.assertEqual(restored.execute("SELECT * FROM child").fetchall(),[(7,)])
self.assertEqual(restored.execute("PRAGMA integrity_check").fetchone(),("ok",))
db.close()
def test_candidate_is_self_contained_and_hashed(self):
with tempfile.TemporaryDirectory() as directory:
output=Path(directory)/"candidate"
result=subprocess.run(["python3",str(ROOT/"tools/package_sync_candidate.py"),"--output",str(output)],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
inventory=json.loads((output/"candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"],"0.1.2")
self.assertTrue((output/"bin/trainlog").is_file())
self.assertTrue(any(row["path"]=="tools/sync_peer_worker.py" for row in inventory["files"]))
if __name__=="__main__": unittest.main()

View file

@ -1,31 +1,43 @@
import json, os, subprocess, tempfile, unittest, uuid
import json, os, signal, subprocess, tempfile, time, unittest, uuid
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
ORCHESTRATOR=ROOT/"tools/sync_orchestrator.py"
FIXTURE=ROOT/"tests/sync_orchestrator_peer_fixture.py"
def identity(prefix): return prefix+str(uuid.uuid4())
class OrchestratorTest(unittest.TestCase):
def test_correlated_worker_report_is_durable_and_reopenable(self):
for trigger in ("web","android"):
with self.subTest(trigger=trigger), tempfile.TemporaryDirectory() as directory:
root=Path(directory);db=root/"trainlog.db";db.touch();state=Path(str(db)+".sync-run.json")
run="sy_"+str(uuid.uuid4());request="sy_"+str(uuid.uuid4())
state.write_text(json.dumps({"run_id":run,"request_id":request,"trigger":trigger,"phase":"requested","progress_revision":1,"result":"running"}))
config=root/"config.json";config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory-test-double","capabilities":["generation-manifest-v1","generation-ack-v1","causal-delete-v1","mobile-history-v4","execution-draft-v1"],"peer_command":["python3",str(FIXTURE)],"timeout_seconds":30}))
result=subprocess.run(["python3",str(ORCHESTRATOR),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
final=json.loads(state.read_text());self.assertEqual(final["phase"],"completed")
self.assertEqual(final["result"],"completed");self.assertEqual(final["sessions_reconciled"],1)
self.assertTrue(final["domains"]["causal-delete-v1"]);self.assertEqual(final["drafts"][0]["state"],"pending")
def test_missing_capability_is_explicitly_degraded(self):
def fixture(self, root, **overrides):
db=root/"trainlog.db";db.touch();state=Path(str(db)+".sync-run.json")
run=identity("sy_");request=identity("sy_")
state.write_text(json.dumps({"run_id":run,"request_id":request,"trigger":"web","phase":"requested","progress_revision":1,"result":"running"}))
config={"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory","expected_peer_id":identity("peer_"),"transport_root":str(root/"transport"),"owned_root":str(root/"owned"),"timeout_seconds":30}|overrides
(root/"transport").mkdir();(root/"owned").mkdir();path=root/"config.json";path.write_text(json.dumps(config))
return db,state,path,run,request
def invoke(self, executable, values):
db,state,config,run,request=values
return subprocess.run(["python3",str(executable),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request],capture_output=True,text=True)
def test_browser_configuration_cannot_select_command_or_boolean_timeout(self):
for override in ({"peer_command":["evil"]},{"timeout_seconds":True}):
with self.subTest(override=override),tempfile.TemporaryDirectory() as directory:
root=Path(directory);values=self.fixture(root,**override);result=self.invoke(ORCHESTRATOR,values)
self.assertEqual(result.returncode,2);self.assertEqual(json.loads(values[1].read_text())["phase"],"failed")
def test_stdout_bound_is_enforced_before_complete_accumulation(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);db=root/"trainlog.db";db.touch();state=Path(str(db)+".sync-run.json")
run="sy_"+str(uuid.uuid4());request="sy_"+str(uuid.uuid4())
state.write_text(json.dumps({"run_id":run,"request_id":request,"trigger":"web","phase":"requested","progress_revision":1,"result":"running"}))
config=root/"config.json";config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory-test-double","capabilities":[],"peer_command":["python3",str(FIXTURE)],"timeout_seconds":30}))
result=subprocess.run(["python3",str(ORCHESTRATOR),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request])
self.assertEqual(result.returncode,3);self.assertEqual(json.loads(state.read_text())["phase"],"explicitly_degraded")
root=Path(directory);sandbox=root/"program";sandbox.mkdir();(sandbox/"sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
(sandbox/"sync_peer_worker.py").write_text("import sys,time\nsys.stdout.write('x'*70000);sys.stdout.flush();time.sleep(30)\n")
values=self.fixture(root);result=self.invoke(sandbox/"sync_orchestrator.py",values)
self.assertEqual(result.returncode,2);final=json.loads(values[1].read_text());self.assertIn("exceeds 64 KiB",final["diagnostic"])
def test_shutdown_reaps_ignoring_helper_and_descendant(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);sandbox=root/"program";sandbox.mkdir();pidfile=root/"pids";(sandbox/"sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
(sandbox/"sync_peer_worker.py").write_text("import os,signal,time\nsignal.signal(signal.SIGTERM,signal.SIG_IGN)\npid=os.fork()\nif pid==0:\n signal.signal(signal.SIGTERM,signal.SIG_IGN);open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\nelse:\n open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\n"%(str(pidfile),str(pidfile)))
values=self.fixture(root);db,state,config,run,request=values
process=subprocess.Popen(["python3",str(sandbox/"sync_orchestrator.py"),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request])
deadline=time.monotonic()+5
while not pidfile.is_file() and time.monotonic()<deadline: time.sleep(.02)
process.send_signal(signal.SIGTERM);self.assertEqual(process.wait(timeout=10),2)
for pid in map(int,pidfile.read_text().split()):
with self.assertRaises(ProcessLookupError): os.kill(pid,0)
self.assertEqual(json.loads(state.read_text())["phase"],"interrupted")
if __name__=="__main__":unittest.main()
if __name__=="__main__": unittest.main()

View file

@ -1,12 +1,18 @@
#!/usr/bin/env python3
"""Real Firefox click through the production C server and owned worker."""
import json, os, signal, socket, subprocess, tempfile, time, unittest, warnings
"""Real Firefox and Android production services in one correlated conversation."""
import json, os, signal, socket, sqlite3, subprocess, tempfile, time, unittest
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
FIXTURE=ROOT/"tests/sync_orchestrator_peer_fixture.py"
GECKO=Path(os.environ.get("TRAINLOG_GECKODRIVER","/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver"))
warnings.simplefilter("ignore", ResourceWarning)
def await_condition(predicate, seconds=30):
deadline=time.monotonic()+seconds
while time.monotonic()<deadline:
value=predicate()
if value: return value
time.sleep(.025)
raise AssertionError("condition did not become true")
class BrowserSyncTest(unittest.TestCase):
def test_button_starts_worker_reconnects_and_displays_committed_draft(self):
@ -17,7 +23,18 @@ class BrowserSyncTest(unittest.TestCase):
from selenium.webdriver.support.ui import WebDriverWait
with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory:
root=Path(directory);xdg=root/"data";xdg.mkdir();config=root/"config.json"
config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory-test-double","capabilities":["generation-manifest-v1","generation-ack-v1","causal-delete-v1","mobile-history-v4","execution-draft-v1"],"peer_command":["python3",str(FIXTURE)],"timeout_seconds":30}))
transport=root/"transport";transport.mkdir();owned=root/"owned";owned.mkdir()
release=root/"release-android";android_log=(root/"android.log").open("wb")
android_env=os.environ.copy();android_env.update({"JAVA_HOME":"/usr/lib/jvm/java-17-openjdk","TRAINLOG_SYNC_TRANSPORT_ROOT":str(transport),"TRAINLOG_SYNC_TEST_RELEASE_FILE":str(release)})
android=subprocess.Popen(["./gradlew","--no-daemon","testDebugUnitTest","--tests","com.labfytools.trainlog.data.SyncDeploymentConversationTest","--rerun-tasks"],cwd=ROOT/"android",env=android_env,stdout=android_log,stderr=subprocess.STDOUT,start_new_session=True)
try:
peer_file=await_condition(lambda: (transport/"android-peer-v1.json") if (transport/"android-peer-v1.json").is_file() else None,120)
except Exception as error:
if android.poll() is None: os.killpg(android.pid,signal.SIGTERM)
android.wait(timeout=10);android_log.close()
raise AssertionError(f"Android bridge did not advertise:\n{(root/'android.log').read_text(errors='replace')}") from error
peer=json.loads(peer_file.read_text())
config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory","expected_peer_id":peer["peer_id"],"transport_root":str(transport),"owned_root":str(owned),"timeout_seconds":30}))
with socket.socket() as reserved:
reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1]
env=os.environ.copy();env.update({"XDG_DATA_HOME":str(xdg),"XDG_CONFIG_HOME":str(root/"config"),"XDG_CACHE_HOME":str(root/"cache"),"TRAINLOG_SYNC_GENERATION_CONFIG":str(config)})
@ -34,15 +51,27 @@ class BrowserSyncTest(unittest.TestCase):
wait=WebDriverWait(driver,15)
button=wait.until(lambda d:d.find_element(By.XPATH,"//button[normalize-space()='Synchroniser']"))
wait.until(lambda _ : button.is_enabled());button.click()
state_path=Path(str(xdg/"trainlog"/"trainlog.db")+".sync-run.json")
committed=await_condition(lambda: json.loads(state_path.read_text()) if state_path.is_file() and json.loads(state_path.read_text()).get("phase")=="local_import_committed" else None)
self.assertEqual(committed["result"],"running")
with sqlite3.connect(xdg/"trainlog"/"trainlog.db") as database:
self.assertEqual(database.execute("SELECT COUNT(*) FROM execution_drafts").fetchone()[0],1)
release.touch()
final=await_condition(lambda: json.loads(state_path.read_text()) if json.loads(state_path.read_text()).get("phase") in ("completed","failed","interrupted") else None)
self.assertEqual(final["phase"],"completed",f"state={final}\nandroid={((root/'android.log').read_text(errors='replace'))}")
wait.until(lambda d:"Synchronisation confirmée" in d.page_source)
summary=driver.find_element(By.XPATH,"//summary[contains(.,'Synchronisation confirmée')]");summary.click()
wait.until(lambda d:"Brouillons reçus" in d.page_source and "pending" in d.page_source)
wait.until(lambda d:"Brouillons reçus" in d.page_source and "active" in d.page_source)
driver.refresh();wait.until(lambda d:"Synchronisation confirmée" in d.page_source)
state=json.loads(Path(str(xdg/"trainlog"/"trainlog.db")+".sync-run.json").read_text())
self.assertEqual(state["phase"],"completed");self.assertEqual(state["sessions_reconciled"],1)
self.assertEqual(state["phase"],"completed")
self.assertEqual(android.wait(timeout=30),0,(root/"android.log").read_text(errors="replace"))
finally:
if driver is not None: driver.quit()
os.killpg(server.pid,signal.SIGTERM)
server.wait(timeout=10)
if android.poll() is None:
os.killpg(android.pid,signal.SIGTERM);android.wait(timeout=10)
android_log.close()
if __name__=="__main__":unittest.main()

View file

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Create or restore a consistent Trainlog SQLite backup.
This tool never discovers a user database. Both source and destination must be
explicit, and restore refuses to overwrite an existing file. SQLite's backup
API includes committed WAL content without relying on a racy file copy.
"""
import argparse
import sqlite3
from pathlib import Path
def backup(source: Path, destination: Path) -> None:
if not source.is_file() or destination.exists():
raise RuntimeError("source must exist and destination must not exist")
destination.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(f"file:{source}?mode=ro", uri=True) as src, sqlite3.connect(destination) as dst:
src.backup(dst)
if dst.execute("PRAGMA integrity_check").fetchone() != ("ok",):
raise RuntimeError("backup integrity check failed")
violations = dst.execute("PRAGMA foreign_key_check").fetchall()
if violations:
raise RuntimeError(f"backup foreign-key violations: {len(violations)}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
backup(args.source, args.destination)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -167,6 +167,19 @@ def canonical(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def causal_payload(draft):
"""Return immutable revision content without receiver scheduling state.
CONTRACT: ``revision_id`` identifies the draft content and causal parent;
``active`` versus ``pending`` is selected independently by each receiver.
A round trip may therefore rewrite only ``state`` without creating a
contradictory revision.
"""
value = dict(draft)
value.pop("state", None)
return canonical(value)
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21):
@ -182,6 +195,7 @@ def import_document(connection, document, own_transaction=True):
for draft in document["drafts"]:
session_id, revision = draft["session_id"], draft["revision_id"]
payload = canonical(draft)
causal = causal_payload(draft)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=? AND deleted=1", (session_id,)).fetchone():
outcomes.append((session_id, "stale")); continue
if connection.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", (session_id,)).fetchone():
@ -192,7 +206,8 @@ def import_document(connection, document, own_transaction=True):
"SELECT parent_revision_id,payload_json FROM execution_draft_revisions WHERE session_id=? AND revision_id=?",
(session_id, revision)).fetchone()
if known:
if tuple(known) != (draft["parent_revision_id"], payload):
if (known[0] != draft["parent_revision_id"] or
causal_payload(json.loads(known[1])) != causal):
raise LifecycleError("contradictory revision: " + session_id)
outcomes.append((session_id, "unchanged" if current and current[0] == revision else "stale")); continue
if current and draft["parent_revision_id"] != current[0]:

View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Stage a reproducible, non-secret Trainlog desktop synchronization candidate."""
import argparse
import hashlib
import json
import os
import shutil
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNTIME_TOOLS = [
"sync_orchestrator.py", "sync_peer_worker.py", "sync_generation_exchange.py",
"trainlog_sqlite.py", "validate_json.py", "import_mobile_export.py",
"import_exercise_aliases.py", "import_equipment_definitions.py",
"import_equipment_associations.py", "import_exercise_body_zones.py",
"import_training_feedback.py", "execution_draft_exchange.py",
"causal_delete_exchange.py", "import_exercise_profile_state.py",
"export_exercise_profile_state.py",
"export_pc_mobile.py", "export_equipment_definitions.py",
"export_exercise_body_zones.py", "export_training_feedback.py",
]
def digest(path: Path) -> str:
value = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--build", default=ROOT / "build", type=Path)
args = parser.parse_args()
if args.output.exists():
raise RuntimeError("candidate output already exists")
binary = args.build / "tui/trainlog"
if not binary.is_file():
raise RuntimeError("compiled trainlog executable is missing")
(args.output / "bin").mkdir(parents=True)
(args.output / "libexec").mkdir(parents=True)
(args.output / "tools").mkdir(parents=True)
(args.output / "catalog").mkdir(parents=True)
shutil.copy2(binary, args.output / "libexec/trainlog")
for name in RUNTIME_TOOLS:
shutil.copy2(ROOT / "tools" / name, args.output / "tools" / name)
for source in sorted((ROOT / "catalog").glob("*.json")):
shutil.copy2(source, args.output / "catalog" / source.name)
launcher = args.output / "bin/trainlog"
launcher.write_text("#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- \"$(dirname -- \"$0\")/..\" && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR=\"$root/tools\"\nexec \"$root/libexec/trainlog\" \"$@\"\n")
files = sorted(path for path in args.output.rglob("*") if path.is_file())
inventory = {
"format": "trainlog-sync-candidate-inventory", "version": 1,
"source_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
"product_version": "0.1.2", "desktop_schema": 21, "android_schema": 20,
"protocols": ["mobile-export-v3", "mobile-history-v4", "causal-delete-v1",
"execution-draft-v1", "generation-manifest-v1", "generation-ack-v1"],
"entry_points": ["bin/trainlog", "tools/sync_orchestrator.py"],
"runtime_dependencies": ["python>=3.11", "sqlite3", "utf8proc", "libuuid", "libudev", "libmtp", "notcurses"],
"files": [{"path": str(path.relative_to(args.output)), "sha256": digest(path), "size": path.stat().st_size} for path in files],
}
(args.output / "candidate-inventory.json").write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n")
os.chmod(args.output / "bin/trainlog", 0o755)
os.chmod(args.output / "libexec/trainlog", 0o755)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -13,8 +13,12 @@ import json
import os
import subprocess
import signal
import selectors
import re
import sys
import tempfile
import time
import ctypes
from datetime import datetime, timezone
from pathlib import Path
@ -26,6 +30,29 @@ PHASES = {"requested", "waiting_android_publication", "running",
"peer_consumed", "completed", "failed", "interrupted", "explicitly_degraded"}
class OrchestratorInterrupted(RuntimeError):
pass
_owned_process_group: int | None = None
def _forward_termination(signum: int, _frame: object) -> None:
"""Forward shutdown only to the exactly-owned helper process group."""
if _owned_process_group is not None:
try: os.killpg(_owned_process_group, signal.SIGTERM)
except ProcessLookupError: pass
raise OrchestratorInterrupted(f"orchestrator received signal {signum}")
def _child_setup() -> None:
"""Create an owned group and ask Linux to terminate it if this parent dies."""
os.setsid()
libc = ctypes.CDLL(None)
if libc.prctl(1, signal.SIGTERM) != 0: # PR_SET_PDEATHSIG
raise OSError("cannot establish parent-death signal")
def canonical(value: object) -> bytes:
return (json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":")) + "\n").encode()
@ -51,9 +78,10 @@ def atomic_write(path: Path, value: dict) -> None:
def load_json(path: Path, limit: int) -> dict:
raw = path.read_bytes()
with path.open("rb") as stream:
raw = stream.read(limit + 1)
if len(raw) > limit: raise RuntimeError(f"{path.name} exceeds its bound")
value = json.loads(raw)
value = json.loads(raw.decode("utf-8", "strict"))
if not isinstance(value, dict): raise RuntimeError(f"{path.name} must contain an object")
return value
@ -78,9 +106,9 @@ def validate_result(value: dict, run_id: str) -> dict:
(value["consumer_peer_id"], "peer_"),
(value["inbound_generation_id"], "gen_"),
(value["outbound_generation_id"], "gen_")):
if not isinstance(identity, str) or not identity.startswith(prefix) or len(identity) > 64:
if not isinstance(identity, str) or not re.fullmatch(re.escape(prefix)+r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",identity):
raise RuntimeError("invalid worker identity")
if not isinstance(value["sessions_reconciled"], int) or value["sessions_reconciled"] < 0:
if type(value["sessions_reconciled"]) is not int or value["sessions_reconciled"] < 0:
raise RuntimeError("invalid sessions_reconciled")
if not isinstance(value["domains"], dict) or not all(isinstance(k, str) and isinstance(v, bool) for k, v in value["domains"].items()):
raise RuntimeError("invalid domain coverage")
@ -89,26 +117,65 @@ def validate_result(value: dict, run_id: str) -> dict:
return value
def read_worker(child: subprocess.Popen[bytes], state_path: Path, state: dict,
run_id: str, timeout: int) -> tuple[dict, bytes]:
selector = selectors.DefaultSelector()
assert child.stdout is not None and child.stderr is not None
selector.register(child.stdout, selectors.EVENT_READ, "stdout")
selector.register(child.stderr, selectors.EVENT_READ, "stderr")
buffers = {"stdout": bytearray(), "stderr": bytearray()}
line_buffer = bytearray(); final: dict | None = None
deadline = time.monotonic() + timeout
allowed = ["waiting_android_publication","running","local_import_committed",
"published","waiting_acknowledgement","peer_consumed"]
last_index = -1
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0: raise RuntimeError("peer worker timed out")
for key, _ in selector.select(min(remaining, .25)):
chunk = os.read(key.fileobj.fileno(), 4096)
if not chunk: selector.unregister(key.fileobj); continue
target = buffers[key.data]
if len(target) + len(chunk) > MAX_REPORT: raise RuntimeError(f"peer worker {key.data} exceeds 64 KiB")
target.extend(chunk)
if key.data == "stdout":
line_buffer.extend(chunk)
while b"\n" in line_buffer:
raw, _, rest = line_buffer.partition(b"\n"); line_buffer[:] = rest
if len(raw) > 8192: raise RuntimeError("progress frame exceeds 8 KiB")
value = json.loads(raw.decode("utf-8", "strict"))
if value.get("format") == "trainlog-sync-progress":
if set(value)-{"format","version","run_id","phase","producer_peer_id","consumer_peer_id","inbound_generation_id","outbound_generation_id","manifest_sha256"} or value.get("version") != 1 or value.get("run_id") != run_id or value.get("phase") not in allowed:
raise RuntimeError("invalid progress frame")
index=allowed.index(value["phase"])
if index < last_index: raise RuntimeError("progress phase regressed")
last_index=index;update(state_path,state,value["phase"],**{k:v for k,v in value.items() if k not in {"format","version","run_id","phase"}})
else: final=value
child.wait()
if line_buffer.strip(): raise RuntimeError("unterminated worker frame")
if child.returncode != 0:
detail=bytes(buffers["stderr"]).decode("utf-8","replace").strip()[:MAX_DIAGNOSTIC]
raise RuntimeError(f"peer worker exited {child.returncode}: {detail}")
if final is None: raise RuntimeError("peer worker omitted final report")
return validate_result(final,run_id), bytes(buffers["stderr"])
def run(args: argparse.Namespace) -> int:
global _owned_process_group
state_path, config_path = Path(args.state), Path(args.config)
state = load_json(state_path, MAX_REPORT)
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) != {"format", "version", "enabled", "mode", "capabilities", "peer_command", "timeout_seconds"}:
if set(config) != {"format", "version", "enabled", "mode", "expected_peer_id", "transport_root", "owned_root", "timeout_seconds"}:
raise RuntimeError("invalid trusted sync configuration")
if config["format"] != "trainlog-sync-orchestrator-config" or config["version"] != 1 or config["enabled"] is not True:
raise RuntimeError("full-generation synchronization is disabled")
required_caps = {"generation-manifest-v1", "generation-ack-v1", "causal-delete-v1", "mobile-history-v4", "execution-draft-v1"}
caps = config["capabilities"]
if not isinstance(caps, list) or not required_caps.issubset(set(caps)):
update(state_path, state, "explicitly_degraded", result="incompatible",
missing_capabilities=sorted(required_caps - set(caps if isinstance(caps, list) else [])),
diagnostic="Peer lacks mandatory full-generation capabilities", finished_at=datetime.now(timezone.utc).isoformat())
return 3
command = config["peer_command"]
if not isinstance(command, list) or not command or len(command) > 16 or not all(isinstance(part, str) and 0 < len(part) <= 4096 for part in command):
raise RuntimeError("invalid trusted peer command")
if config["mode"] not in ("directory","mtp"):
raise RuntimeError("invalid transport mode")
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")
if not isinstance(config["expected_peer_id"],str) or not re.fullmatch(r"peer_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",config["expected_peer_id"]): raise RuntimeError("invalid expected peer identity")
timeout = config["timeout_seconds"]
if not isinstance(timeout, int) or timeout < 1 or timeout > 900:
raise RuntimeError("invalid worker timeout")
@ -121,38 +188,27 @@ def run(args: argparse.Namespace) -> int:
except BlockingIOError:
update(state_path, state, "failed", result="conflict", diagnostic="Another synchronization owns the common lock", finished_at=datetime.now(timezone.utc).isoformat())
return 4
update(state_path, state, "waiting_android_publication", result="running", diagnostic="")
update(state_path, state, "running")
update(state_path, state, "running", result="running", diagnostic="")
environment = os.environ.copy()
environment.update({"TRAINLOG_SYNC_RUN_ID": args.run_id,
"TRAINLOG_SYNC_TRIGGER": state["trigger"],
"TRAINLOG_SYNC_DATABASE": args.database})
command=[sys.executable,str(Path(__file__).with_name("sync_peer_worker.py")),"--database",args.database,"--transport-root",config["transport_root"],"--owned-root",config["owned_root"],"--run-id",args.run_id,"--expected-peer",config["expected_peer_id"],"--timeout",str(timeout)]
child = subprocess.Popen(command, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=environment,
start_new_session=True)
try:
stdout, stderr = child.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGTERM)
try: stdout, stderr = child.communicate(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL); stdout, stderr = child.communicate()
raise RuntimeError("peer worker timed out")
if len(stdout) > MAX_REPORT or len(stderr) > MAX_REPORT:
raise RuntimeError("peer worker output exceeds 64 KiB")
if child.returncode != 0:
detail = stderr.decode("utf-8", "replace").strip()[:MAX_DIAGNOSTIC]
raise RuntimeError(f"peer worker exited {child.returncode}: {detail}")
result = validate_result(json.loads(stdout), args.run_id)
update(state_path, state, "local_import_committed",
inbound_generation_id=result["inbound_generation_id"],
producer_peer_id=result["producer_peer_id"],
consumer_peer_id=result["consumer_peer_id"],
sessions_reconciled=result["sessions_reconciled"], domains=result["domains"], drafts=result["drafts"])
update(state_path, state, "published", outbound_generation_id=result["outbound_generation_id"])
update(state_path, state, "waiting_acknowledgement")
update(state_path, state, "peer_consumed", manifest_sha256=result["manifest_sha256"])
preexec_fn=_child_setup)
_owned_process_group = child.pid
try: result,_ = read_worker(child,state_path,state,args.run_id,timeout)
except Exception:
if child.poll() is None:
os.killpg(child.pid,signal.SIGTERM)
try: child.wait(5)
except subprocess.TimeoutExpired: os.killpg(child.pid,signal.SIGKILL);child.wait()
raise
finally:
_owned_process_group = None
update(state_path, state, "completed", result="completed", diagnostic="",
inbound_generation_id=result["inbound_generation_id"],outbound_generation_id=result["outbound_generation_id"],producer_peer_id=result["producer_peer_id"],consumer_peer_id=result["consumer_peer_id"],sessions_reconciled=result["sessions_reconciled"],domains=result["domains"],drafts=result["drafts"],manifest_sha256=result["manifest_sha256"],
ai_midpoint=result["ai_midpoint"], ai_post_sync=result["ai_post_sync"],
finished_at=datetime.now(timezone.utc).isoformat())
return 0
@ -166,11 +222,14 @@ def main() -> int:
parser.add_argument("--config", required=True); parser.add_argument("--run-id", required=True)
parser.add_argument("--request-id", required=True)
args = parser.parse_args()
signal.signal(signal.SIGTERM, _forward_termination)
signal.signal(signal.SIGINT, _forward_termination)
try: return run(args)
except (OSError, ValueError, json.JSONDecodeError, RuntimeError, subprocess.TimeoutExpired) as error:
try:
path = Path(args.state); state = load_json(path, MAX_REPORT)
update(path, state, "failed", result="failed", diagnostic=str(error)[:MAX_DIAGNOSTIC],
phase = "interrupted" if isinstance(error, OrchestratorInterrupted) else "failed"
update(path, state, phase, result=phase, diagnostic=str(error)[:MAX_DIAGNOSTIC],
finished_at=datetime.now(timezone.utc).isoformat())
except Exception: pass
return 2

86
tools/sync_peer_worker.py Normal file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Production full-generation peer conversation over a transport directory.
The directory is the object-level seam implemented by the MTP adapter in a
deployed bundle and directly supplied by isolated tests. Business documents are
always produced and consumed by the existing generation services.
"""
from __future__ import annotations
import argparse, hashlib, json, os, sys, time, uuid
from contextlib import closing, redirect_stdout
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import sync_generation_exchange as generation
CAPS={"generation-manifest-v1","generation-ack-v1","causal-delete-v1","mobile-history-v4","execution-draft-v1"}
def emit(run_id:str,phase:str,**values:object)->None:
value={"format":"trainlog-sync-progress","version":1,"run_id":run_id,"phase":phase}|values
print(json.dumps(value,sort_keys=True,separators=(",",":")),flush=True)
def bounded(path:Path,limit:int=65536)->bytes:
with path.open("rb") as stream:
raw=stream.read(limit+1)
if len(raw)>limit: raise RuntimeError(f"{path.name} exceeds bound")
return raw
def publish_json(path:Path,value:dict)->None:
"""Publish a coordination object only after all bytes are durable."""
temporary=path.with_name("."+path.name+".tmp-"+str(os.getpid()))
with temporary.open("wb") as stream:
stream.write(generation.canonical(value));stream.flush();os.fsync(stream.fileno())
os.chmod(temporary,0o600);os.replace(temporary,path)
def wait_file(path:Path,deadline:float)->None:
while not path.is_file():
if time.monotonic()>=deadline: raise RuntimeError(f"timeout waiting for {path.name}")
time.sleep(.05)
def load_peer(root:Path,expected:str)->dict:
value=json.loads(bounded(root/"android-peer-v1.json"))
if set(value)!={"format","version","peer_id","capabilities"} or value["format"]!="trainlog-sync-peer" or type(value["version"]) is not int or value["version"]!=1 or value["peer_id"]!=expected or not isinstance(value["capabilities"],list) or not CAPS.issubset(set(value["capabilities"])):
raise RuntimeError("incompatible or unpaired Android peer advertisement")
return value
def main()->int:
parser=argparse.ArgumentParser();parser.add_argument("--database",required=True,type=Path);parser.add_argument("--transport-root",required=True,type=Path);parser.add_argument("--owned-root",required=True,type=Path);parser.add_argument("--run-id",required=True);parser.add_argument("--expected-peer",required=True);parser.add_argument("--timeout",required=True,type=int)
args=parser.parse_args();deadline=time.monotonic()+args.timeout
peer=load_peer(args.transport_root,args.expected_peer)
with closing(generation.connect_database(args.database)) as db:
generation.require_schema(db);desktop_peer=generation.peer_identity(db,"desktop");db.commit()
request={"format":"trainlog-sync-generation-request","version":1,"run_id":args.run_id,"desktop_peer_id":desktop_peer,"android_peer_id":peer["peer_id"],"capabilities":sorted(CAPS)}
request_path=args.transport_root/"request-v1.json";publish_json(request_path,request)
emit(args.run_id,"waiting_android_publication",producer_peer_id=peer["peer_id"],consumer_peer_id=desktop_peer)
inbound_ref=args.transport_root/"android-generation-v1.json";wait_file(inbound_ref,deadline)
inbound=json.loads(bounded(inbound_ref));inbound_dir=args.transport_root/inbound["relative_path"]
ack=generation.consume_desktop(args.database,inbound_dir)
ack_path=args.transport_root/"desktop-consumption-ack-v1.json";publish_json(ack_path,ack)
if ack["result"]!="consumed": raise RuntimeError("desktop rejected Android generation")
emit(args.run_id,"local_import_committed",inbound_generation_id=ack["generation_id"],manifest_sha256=ack["manifest_sha256"])
outgoing_id="gen_"+str(uuid.uuid4())
# Export codecs retain human diagnostics on stdout when invoked as CLIs.
# The worker stdout contract is typed NDJSON only, so route those bounded
# diagnostics to the separately bounded stderr channel.
with redirect_stdout(sys.stderr):
stage,manifest,digest=generation.capture_desktop(args.database,args.owned_root,peer["peer_id"],args.run_id,outgoing_id)
del stage
published=generation.publish(args.database,outgoing_id,args.transport_root/"desktop-objects")
ref={"format":"trainlog-sync-generation-reference","version":1,"run_id":args.run_id,"generation_id":outgoing_id,"manifest_sha256":digest,"relative_path":str(published.relative_to(args.transport_root))}
publish_json(args.transport_root/"desktop-generation-v1.json",ref)
emit(args.run_id,"published",outbound_generation_id=outgoing_id,manifest_sha256=digest)
emit(args.run_id,"waiting_acknowledgement",outbound_generation_id=outgoing_id)
android_ack=args.transport_root/"android-consumption-ack-v1.json";wait_file(android_ack,deadline)
android_ack_value=json.loads(bounded(android_ack))
result=generation.accept_ack(args.database,android_ack)
if result not in ("acknowledged","unchanged"): raise RuntimeError(f"Android did not durably consume desktop generation: {result}: {android_ack_value.get('diagnostic','')}")
with closing(generation.connect_database(args.database)) as db:
inbound_row=db.execute("SELECT result FROM sync_consumed_generations WHERE generation_id=?",(ack["generation_id"],)).fetchone()
outbound_row=db.execute("SELECT status FROM sync_generations WHERE generation_id=?",(outgoing_id,)).fetchone()
drafts=[{"draft_id":row[0],"state":row[1],"session_type":json.loads(row[2]).get("session_type","training"),"occurrence_count":len(json.loads(row[2]).get("exercises",[]))} for row in db.execute("SELECT session_id,state,payload_json FROM execution_drafts ORDER BY session_id LIMIT 32")]
if inbound_row!=("consumed",) or outbound_row!=("acknowledged",): raise RuntimeError("generation evidence did not corroborate completion")
emit(args.run_id,"peer_consumed",outbound_generation_id=outgoing_id)
report={"format":"trainlog-sync-worker-report","version":1,"run_id":args.run_id,"producer_peer_id":peer["peer_id"],"consumer_peer_id":desktop_peer,"inbound_generation_id":ack["generation_id"],"outbound_generation_id":outgoing_id,"manifest_sha256":digest,"result":"completed","sessions_reconciled":0,"domains":{name:True for name in ("history-v4","execution-draft-v1","causal-delete-v1","equipment","aliases","profiles","body-zones","feedback")},"drafts":drafts,"ai_midpoint":{"result":"not_configured"},"ai_post_sync":{"result":"not_configured"}}
print(json.dumps(report,sort_keys=True,separators=(",",":")),flush=True);return 0
if __name__=="__main__": raise SystemExit(main())

View file

@ -739,6 +739,11 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_orchestrator.py'],
)
test(
'sync_deployment_tools',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_deployment_tools.py'],
)
test(
'schema_v21_migration',
executable('test_schema_v21_migration', 'tests/test_schema_v21_migration.c', dependencies: trainlog_core_dep),

View file

@ -244,11 +244,21 @@ static bool write_initial_sync_state(const TrainlogWebContext *context,
static bool launch_sync_worker(TrainlogWebContext *context, const char *config,
const char *run_id, const char *request_id)
{
char orchestrator[PATH_MAX];
const char *tools = getenv("TRAINLOG_SYNC_TOOLS_DIR");
int path_length;
if (tools == NULL) tools = TRAINLOG_TOOLS_DIR;
/* CONTRACT: deployment may relocate the fixed shipped helper directory,
* but an HTTP request can never select this executable or its arguments. */
path_length = snprintf(orchestrator, sizeof(orchestrator),
"%s/sync_orchestrator.py", tools);
if (tools[0] != '/' || path_length < 0 ||
(size_t)path_length >= sizeof(orchestrator)) return false;
pid_t child = fork();
if (child < 0) return false;
if (child == 0) {
(void)setpgid(0, 0);
execlp("python3", "python3", TRAINLOG_TOOLS_DIR "/sync_orchestrator.py",
execlp("python3", "python3", orchestrator,
"--database", context->database_path, "--state", context->state_path,
"--config", config, "--run-id", run_id, "--request-id", request_id,
(char *)NULL);