Compare commits
2 commits
ca45683813
...
799054ee2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
799054ee2c | ||
|
|
3ad50be247 |
38 changed files with 2716 additions and 102 deletions
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -2,6 +2,26 @@
|
|||
|
||||
## Unreleased — 0.1.2
|
||||
|
||||
- Added USB-priority full-generation synchronization with a separately
|
||||
configured private Drive mirror/fallback transport, verified manifest-last
|
||||
publication, shared multi-transport identities/ACKs, and explicit per-
|
||||
transport Web status without ever transporting SQLite.
|
||||
- Added an Android user-enabled foreground USB listener, private Drive folder
|
||||
connection with bounded WorkManager checks, and one shared concurrency-
|
||||
guarded generation coordinator for UI and background triggers.
|
||||
- Added an accessible preparation-list send action with separate editing and
|
||||
delivery states, durable retry behavior, duplicate-delivery prevention and
|
||||
stable execution-identity transition from Preparation to History/Program
|
||||
completion.
|
||||
|
||||
- Implemented `TRAINLOG_PROGRAM_CALENDAR_V1` as the operational top-level
|
||||
`/programmes` daily active-Program calendar. It consumes typed Core reads and
|
||||
preparation creation, derives Monday–Sunday weeks from Program bounds and
|
||||
real planned dates without inventing rest days, keeps undated sessions
|
||||
separate, and rereads Core state after Prepare. Sessions → Programmes remains
|
||||
the administration/import/list/detail/archive/delete surface; this adds no
|
||||
Core, API, schema, format, synchronization, or Android changes.
|
||||
|
||||
- Fixed Program-to-preparation creation for Program V1 target weights written
|
||||
as integer JSON numbers. Imports now preserve both integer and real number
|
||||
spellings instead of persisting integer weights as zero. Existing affected
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -1,11 +1,18 @@
|
|||
# Trainlog
|
||||
|
||||
Synchronization uses one generation/ACK engine over USB/MTP first and an
|
||||
optional private Google Drive mirror/fallback. Drive contains only versioned
|
||||
Trainlog artifacts under its separate Sync namespace; desktop and Android
|
||||
SQLite databases always remain local. Android can process PC USB requests from
|
||||
an explicitly enabled, visibly notified background service, while Drive checks
|
||||
follow Android's bounded background scheduling.
|
||||
|
||||
Trainlog is a local-first workout and body-data system. Trainlog Core owns
|
||||
business truth and canonical desktop persistence. Its native Android app is the
|
||||
field companion, its C17/Notcurses desktop TUI is the administration and
|
||||
technical surface, and its local browser Web sibling provides the implemented
|
||||
Dashboard while reserving later analysis, program, session, and exercise
|
||||
modules for separately contracted work.
|
||||
Dashboard, Sessions, and Programmes surfaces while reserving Analyse and
|
||||
Exercises for separately contracted work.
|
||||
|
||||
Android and desktop each own a local SQLite database. The desktop database is
|
||||
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
|
||||
|
|
@ -17,15 +24,16 @@ over direct MTP; it never copies SQLite database files between devices.
|
|||
|---|---|
|
||||
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
|
||||
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
|
||||
| Local Web (0.1.2 development) | Display the Dashboard and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and embedded Programs workflow through typed Trainlog Core/API boundaries. Programs has responsive detail, terminal logical deletion, and a privately deployed read-only Android projection. Analyse, the standalone Programmes route, and Exercises remain placeholders. |
|
||||
| Local Web (0.1.2 development) | Display the Dashboard, the operational top-level Programmes daily active-program calendar, and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and Programs administration workflow through typed Trainlog Core/API boundaries. Programs has responsive detail, terminal logical deletion, and a privately deployed read-only Android projection. Analyse and Exercises remain placeholders. |
|
||||
|
||||
No interface reconstructs business truth from SQLite tables. The local Web is
|
||||
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
|
||||
CLI/HTTP adapter, embedded frontend, read-only Dashboard API, factual tiles,
|
||||
visualizations, and private layout persistence are implemented. This does not
|
||||
make the remaining placeholder routes functional or publish version 0.1.2.
|
||||
Programs is intentionally implemented as a Sessions subtab; the standalone
|
||||
top-level Programmes route remains a placeholder.
|
||||
The top-level `/programmes` route is an operational daily calendar for active
|
||||
Programs. Sessions → Programmes remains the technical administration/import,
|
||||
list, detail, archive, and delete surface.
|
||||
|
||||
## Releases
|
||||
|
||||
|
|
@ -333,8 +341,9 @@ As of 2026-09-18:
|
|||
Android-triggered bidirectional smoke test.
|
||||
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
|
||||
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
|
||||
Programs is implemented inside Sessions; Analyse, Exercises, and the
|
||||
standalone top-level Programmes route remain placeholders;
|
||||
`/programmes` is an operational active-Program calendar, while
|
||||
Sessions → Programmes remains the administration surface; Analyse and
|
||||
Exercises remain placeholders;
|
||||
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
|
||||
private desktop/Android deployment, real Firefox presentation, correlated
|
||||
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@ dependencies {
|
|||
implementation(
|
||||
"androidx.activity:activity-compose:1.13.0"
|
||||
)
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.5")
|
||||
|
||||
implementation(
|
||||
"androidx.compose.foundation:foundation"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
<!-- Personal sideloaded app: permanent bidirectional MTP exchange uses one
|
||||
user-visible directory, Documents/Trainlog, without a SAF provider. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
|
|
@ -26,5 +29,10 @@
|
|||
android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".data.SyncBackgroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class DriveSyncSettings(private val context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
val treeUri: Uri? get() = preferences.getString(TREE, null)?.let(Uri::parse)
|
||||
val connected: Boolean get() = treeUri != null
|
||||
|
||||
fun connect(uri: Uri) {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
preferences.edit().putString(TREE, uri.toString()).apply()
|
||||
schedule(context)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
treeUri?.let { uri ->
|
||||
runCatching {
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
}
|
||||
preferences.edit().remove(TREE).apply()
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCES = "trainlog-drive-sync"
|
||||
private const val TREE = "private_tree_uri"
|
||||
private const val WORK = "trainlog-drive-sync-v1"
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<DriveSyncWorker>(15, TimeUnit.MINUTES)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder<DriveSyncWorker>().build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Byte-only adapter over one user-selected private Drive folder. */
|
||||
internal class DriveDocumentTransport(private val context: Context, treeUri: Uri) {
|
||||
private val root = DocumentFile.fromTreeUri(context, treeUri)
|
||||
?: throw IllegalStateException("Drive folder is unavailable")
|
||||
private val resolver = context.contentResolver
|
||||
private val directories = mutableMapOf("" to root)
|
||||
|
||||
private fun segments(relative: String): List<String> {
|
||||
val parts = relative.split('/')
|
||||
require(parts.size in 1..4 && parts.all { it.isNotEmpty() && it != "." && it != ".." && '\\' !in it })
|
||||
return parts
|
||||
}
|
||||
|
||||
private fun find(relative: String): DocumentFile? {
|
||||
val parts = segments(relative)
|
||||
var parent = root
|
||||
var prefix = ""
|
||||
parts.forEachIndexed { index, name ->
|
||||
if (index == parts.lastIndex) return parent.findFile(name)
|
||||
prefix = if (prefix.isEmpty()) name else "$prefix/$name"
|
||||
parent =
|
||||
directories[prefix]
|
||||
?: parent.findFile(name)?.also {
|
||||
if (it.isDirectory) directories[prefix] = it
|
||||
}
|
||||
?: return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun ensureParent(parts: List<String>): DocumentFile {
|
||||
var parent = root
|
||||
var prefix = ""
|
||||
for (name in parts) {
|
||||
prefix = if (prefix.isEmpty()) name else "$prefix/$name"
|
||||
parent =
|
||||
directories[prefix]
|
||||
?: parent.findFile(name)
|
||||
?: parent.createDirectory(name)
|
||||
?: throw IllegalStateException("Cannot create Drive folder")
|
||||
require(parent.isDirectory)
|
||||
directories[prefix] = parent
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { stream ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val count = stream.read(buffer)
|
||||
if (count < 0) break
|
||||
digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun downloadDocument(source: DocumentFile, destination: File): Boolean {
|
||||
if (!source.isFile || source.length() > 64L * 1024L * 1024L) return false
|
||||
destination.parentFile?.mkdirs()
|
||||
val temporary = File(destination.parentFile, ".${destination.name}.drive.tmp-${UUID.randomUUID()}")
|
||||
resolver.openInputStream(source.uri)?.use { input ->
|
||||
FileOutputStream(temporary).use { output -> input.copyTo(output); output.fd.sync() }
|
||||
} ?: return false
|
||||
/* WHY: coordination files are deliberately replaced across runs, and
|
||||
* File.renameTo() does not reliably replace an existing destination.
|
||||
* CONTRACT: consumers observe the old complete file or the newly
|
||||
* downloaded complete file, never a partial provider stream.
|
||||
* INVARIANT: the temporary is unique and on the destination filesystem. */
|
||||
try {
|
||||
Files.move(
|
||||
temporary.toPath(),
|
||||
destination.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(
|
||||
temporary.toPath(),
|
||||
destination.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
} catch (error: Exception) {
|
||||
temporary.delete()
|
||||
throw error
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun download(relative: String, destination: File): Boolean =
|
||||
find(relative)?.let { downloadDocument(it, destination) } ?: false
|
||||
|
||||
private fun upload(relative: String, source: File) {
|
||||
require(source.isFile && !java.nio.file.Files.isSymbolicLink(source.toPath()))
|
||||
val parts = segments(relative)
|
||||
val parent = ensureParent(parts.dropLast(1))
|
||||
val destination = parent.findFile(parts.last())
|
||||
?: parent.createFile("application/json", parts.last())
|
||||
?: throw IllegalStateException("Cannot create Drive object")
|
||||
resolver.openOutputStream(destination.uri, "wt")?.use { output ->
|
||||
source.inputStream().use { it.copyTo(output) }
|
||||
output.flush()
|
||||
} ?: throw IllegalStateException("Cannot write Drive object")
|
||||
val verification = File.createTempFile("trainlog-drive-verify-", ".json", context.cacheDir)
|
||||
try {
|
||||
/* WHY: some SAF providers do not make a newly created child
|
||||
* discoverable by name immediately. CONTRACT: verification reads
|
||||
* the exact object returned by createFile/findFile above.
|
||||
* INVARIANT: size and SHA-256 are checked before publication can
|
||||
* advance to a manifest or coordination reference. */
|
||||
require(downloadDocument(destination, verification))
|
||||
require(verification.length() == source.length() && sha256(verification) == sha256(source))
|
||||
} finally {
|
||||
verification.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun pull(localRoot: File) {
|
||||
localRoot.mkdirs()
|
||||
val coordination = listOf(
|
||||
"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",
|
||||
)
|
||||
coordination.forEach { download(it, File(localRoot, it)) }
|
||||
listOf("android-generation-v1.json", "desktop-generation-v1.json").forEach { name ->
|
||||
val reference = File(localRoot, name)
|
||||
if (!reference.isFile || reference.length() > 64 * 1024) return@forEach
|
||||
val value = JSONObject(reference.readText())
|
||||
val relative = value.getString("relative_path")
|
||||
val generationParts = relative.split('/')
|
||||
require(
|
||||
generationParts.size == 3 &&
|
||||
generationParts[0] in setOf("android-objects", "desktop-objects") &&
|
||||
generationParts[1] == "generations" &&
|
||||
generationParts[2] == value.getString("generation_id")
|
||||
)
|
||||
val manifestPath = "$relative/manifest.json"
|
||||
val manifest = File(localRoot, manifestPath)
|
||||
if (!download(manifestPath, manifest)) return@forEach
|
||||
val artifacts = JSONObject(manifest.readText()).getJSONArray("artifacts")
|
||||
require(artifacts.length() <= 32)
|
||||
val generationRelative = "${generationParts[1]}/${generationParts[2]}"
|
||||
for (index in 0 until artifacts.length()) {
|
||||
val filename = artifacts.getJSONObject(index).getString("filename")
|
||||
require(filename.substringBeforeLast('/', "") == generationRelative)
|
||||
val artifactPath = "${generationParts[0]}/$filename"
|
||||
require(download(artifactPath, File(localRoot, artifactPath)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pushPeer(localRoot: File) {
|
||||
upload("android-peer-v1.json", File(localRoot, "android-peer-v1.json"))
|
||||
}
|
||||
|
||||
fun pushGeneration(localRoot: File) {
|
||||
val reference = File(localRoot, "android-generation-v1.json")
|
||||
require(reference.isFile && reference.length() <= 64 * 1024)
|
||||
val value = JSONObject(reference.readText())
|
||||
val relative = value.getString("relative_path")
|
||||
val parts = relative.split('/')
|
||||
require(
|
||||
parts.size == 3 &&
|
||||
parts[0] == "android-objects" &&
|
||||
parts[1] == "generations" &&
|
||||
parts[2] == value.getString("generation_id")
|
||||
)
|
||||
val generation = File(localRoot, relative)
|
||||
require(generation.isDirectory && !java.nio.file.Files.isSymbolicLink(generation.toPath()))
|
||||
val files =
|
||||
generation.listFiles()?.filter { it.isFile }?.sortedWith(
|
||||
compareBy<File> { it.name == "manifest.json" }
|
||||
.thenBy { it.name },
|
||||
) ?: throw IllegalStateException("Drive generation is unavailable")
|
||||
require(files.lastOrNull()?.name == "manifest.json")
|
||||
/* WHY: a Drive tree may contain downloaded desktop objects, stale
|
||||
* generations, and local staging. Uploading that cache made a single
|
||||
* phase exceed WorkManager's execution window and crossed transport
|
||||
* ownership boundaries. CONTRACT: Android publishes only the current
|
||||
* immutable Android generation. INVARIANT: artifacts precede the
|
||||
* manifest commit marker, and the correlated reference is last. */
|
||||
files.forEach { upload("$relative/${it.name}", it) }
|
||||
upload("android-generation-v1.json", reference)
|
||||
}
|
||||
|
||||
fun pushAcknowledgement(localRoot: File) {
|
||||
upload(
|
||||
"android-consumption-ack-v1.json",
|
||||
File(localRoot, "android-consumption-ack-v1.json"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
||||
CoroutineWorker(context, parameters) {
|
||||
companion object {
|
||||
/* WHY: reconnecting Drive updates periodic work and also enqueues an
|
||||
* immediate pass; Android may start both before either reaches the
|
||||
* Core conversation lock. CONTRACT: only one Drive byte conversation
|
||||
* runs in this process. INVARIANT: a duplicate trigger invents no
|
||||
* generation and the active worker remains the durable authority. */
|
||||
private val running = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val uri = DriveSyncSettings(applicationContext).treeUri ?: return Result.success()
|
||||
if (!running.compareAndSet(false, true)) return Result.success()
|
||||
val local = File(applicationContext.filesDir, "sync-drive-v1")
|
||||
val transport = DriveDocumentTransport(applicationContext, uri)
|
||||
val repository = TrainlogRepository(applicationContext)
|
||||
var stage = "pull"
|
||||
return try {
|
||||
transport.pull(local)
|
||||
stage = "coordinate"
|
||||
val coordinator = SyncGenerationCoordinator(repository)
|
||||
coordinator.publishPeer(local)
|
||||
stage = "peer-publication"
|
||||
transport.pushPeer(local)
|
||||
if (!coordinator.hasActionableRequest(local)) {
|
||||
if (coordinator.hasReplayableAcknowledgement(local)) {
|
||||
stage = "acknowledgement-replay"
|
||||
transport.pushAcknowledgement(local)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
var lastPull = System.nanoTime()
|
||||
val boundedPull = {
|
||||
val now = System.nanoTime()
|
||||
if (now - lastPull >= Duration.ofSeconds(5).toNanos()) {
|
||||
transport.pull(local)
|
||||
lastPull = System.nanoTime()
|
||||
}
|
||||
}
|
||||
val result = coordinator.run(
|
||||
local,
|
||||
Duration.ofMinutes(10),
|
||||
afterPeerPublication = { transport.pushPeer(local) },
|
||||
afterPublication = {
|
||||
stage = "generation-publication"
|
||||
transport.pushGeneration(local)
|
||||
},
|
||||
pollTransport = boundedPull,
|
||||
afterAcknowledgement = {
|
||||
stage = "acknowledgement"
|
||||
transport.pushAcknowledgement(local)
|
||||
},
|
||||
)
|
||||
if (result is ForegroundGenerationResult.Completed) Result.success() else Result.retry()
|
||||
} catch (error: Exception) {
|
||||
/* SECURITY: stage and exception class are fixed application
|
||||
* metadata. Provider messages may contain private URIs and are
|
||||
* deliberately never written to logs. */
|
||||
Log.w("TrainlogDriveSync", "Drive sync failed at $stage (${error.javaClass.simpleName})")
|
||||
Result.retry()
|
||||
} finally {
|
||||
repository.close()
|
||||
running.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.labfytools.trainlog.R
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal class BackgroundSyncSettings(context: Context) {
|
||||
private val preferences = context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
var enabled: Boolean
|
||||
get() = preferences.getBoolean(ENABLED, false)
|
||||
private set(value) { preferences.edit().putBoolean(ENABLED, value).apply() }
|
||||
|
||||
fun enable() { enabled = true }
|
||||
fun disable() { enabled = false }
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCES = "trainlog-background-sync"
|
||||
private const val ENABLED = "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-enabled foreground listener for PC-initiated USB generation requests.
|
||||
*
|
||||
* WHY: a Web request must be consumed while Compose is not visible, but modern
|
||||
* Android does not permit an invisible permanent background loop.
|
||||
* CONTRACT: the service shows a mandatory notification, invokes the same
|
||||
* generation coordinator as SyncScreen, and never starts an exercise.
|
||||
* INVARIANT: filesystem visibility only triggers protocol validation; stable
|
||||
* run/generation/ACK identities remain the sole business authority.
|
||||
*/
|
||||
class SyncBackgroundService : Service() {
|
||||
private val stopped = AtomicBoolean(false)
|
||||
private val launched = AtomicBoolean(false)
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private var repository: TrainlogRepository? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.background_sync_notification_title))
|
||||
.setContentText(getString(R.string.background_sync_notification_text))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
repository = TrainlogRepository(applicationContext)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP || !BackgroundSyncSettings(this).enabled) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
|
||||
executor.execute {
|
||||
val coordinator = SyncGenerationCoordinator(requireNotNull(repository))
|
||||
while (!stopped.get() && BackgroundSyncSettings(this).enabled) {
|
||||
val directory = canonicalExchangeDirectory()
|
||||
/* WHY: waiting inside run() owns the process-wide conversation
|
||||
* lock and would starve Drive while USB is idle. CONTRACT: a
|
||||
* filesystem observation only schedules bounded validation;
|
||||
* the coordinator still validates all protocol evidence.
|
||||
* INVARIANT: no request means no business work and no lock. */
|
||||
coordinator.publishPeer(directory)
|
||||
if (coordinator.hasActionableRequest(directory)) {
|
||||
coordinator.run(directory, Duration.ofMinutes(5))
|
||||
}
|
||||
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopped.set(true)
|
||||
executor.shutdownNow()
|
||||
repository?.close()
|
||||
repository = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
/* Android 15 bounds dataSync foreground time across the application.
|
||||
* Stop immediately rather than risking an ANR; durable generations
|
||||
* and ACKs make a later explicit restart safe. */
|
||||
stopped.set(true)
|
||||
stopSelf(startId)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun createChannel() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL,
|
||||
getString(R.string.background_sync_channel),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL = "trainlog_sync"
|
||||
private const val NOTIFICATION_ID = 1401
|
||||
private const val ACTION_START = "com.labfytools.trainlog.sync.START"
|
||||
private const val ACTION_STOP = "com.labfytools.trainlog.sync.STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
BackgroundSyncSettings(context).enable()
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, SyncBackgroundService::class.java).setAction(ACTION_START),
|
||||
)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
BackgroundSyncSettings(context).disable()
|
||||
context.startService(
|
||||
Intent(context, SyncBackgroundService::class.java).setAction(ACTION_STOP),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@ package com.labfytools.trainlog.data
|
|||
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
|
|
@ -14,9 +17,17 @@ internal sealed interface ForegroundGenerationResult {
|
|||
}
|
||||
|
||||
/** Owns the bounded, user-initiated Android side of a generation conversation. */
|
||||
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
|
||||
internal class SyncGenerationCoordinator(private val repository: TrainlogRepository) {
|
||||
private val service = SyncGenerationService(repository)
|
||||
|
||||
companion object {
|
||||
/* WHY: UI and background triggers share one repository protocol and
|
||||
* must never create concurrent runs. CONTRACT: this process-wide lock
|
||||
* guards only one bounded conversation; SQLite remains the durable
|
||||
* replay authority after process restart. */
|
||||
private val conversationLock = java.util.concurrent.locks.ReentrantLock()
|
||||
}
|
||||
|
||||
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
|
|
@ -45,6 +56,59 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
}
|
||||
}
|
||||
|
||||
internal fun hasActionableRequest(directory: File): Boolean {
|
||||
val path = File(directory, "request-v1.json")
|
||||
if (!path.isFile || path.length() !in 1..64 * 1024) return false
|
||||
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return false
|
||||
val runId = request.optString("run_id")
|
||||
val desktopPeerId = request.optString("desktop_peer_id")
|
||||
if (
|
||||
request.optString("format") != "trainlog-sync-generation-request" ||
|
||||
request.optInt("version", -1) != 1 ||
|
||||
!runId.matches(Regex("^sy_[0-9a-f-]{36}$")) ||
|
||||
!desktopPeerId.matches(Regex("^peer_[0-9a-f-]{36}$")) ||
|
||||
request.optString("android_peer_id") != service.peerId()
|
||||
) return false
|
||||
if (resumableGeneration(runId, desktopPeerId) != null) return true
|
||||
return !repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
|
||||
arrayOf(runId),
|
||||
).use { it.moveToFirst() }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun hasReplayableAcknowledgement(directory: File): Boolean {
|
||||
val request =
|
||||
runCatching { JSONObject(File(directory, "request-v1.json").readText()) }.getOrNull()
|
||||
?: return false
|
||||
val reference =
|
||||
runCatching { JSONObject(File(directory, "desktop-generation-v1.json").readText()) }
|
||||
.getOrNull() ?: return false
|
||||
val acknowledgement =
|
||||
runCatching { JSONObject(File(directory, "android-consumption-ack-v1.json").readText()) }
|
||||
.getOrNull() ?: return false
|
||||
/* WHY: a network failure may occur after Core commits consumption but
|
||||
* before Drive receives its ACK. CONTRACT: only the exact durable ACK
|
||||
* correlated to the current request and desktop reference is replayed.
|
||||
* INVARIANT: replay transports existing evidence and never recaptures
|
||||
* a generation or repeats a domain mutation. */
|
||||
return request.optString("format") == "trainlog-sync-generation-request" &&
|
||||
request.optInt("version", -1) == 1 &&
|
||||
request.optString("android_peer_id") == service.peerId() &&
|
||||
reference.optString("format") == "trainlog-sync-generation-reference" &&
|
||||
reference.optInt("version", -1) == 1 &&
|
||||
acknowledgement.optString("format") == "trainlog-sync-ack" &&
|
||||
acknowledgement.optInt("version", -1) == 1 &&
|
||||
acknowledgement.optString("run_id") == request.optString("run_id") &&
|
||||
reference.optString("run_id") == request.optString("run_id") &&
|
||||
acknowledgement.optString("generation_id") == reference.optString("generation_id") &&
|
||||
acknowledgement.optString("manifest_sha256") == reference.optString("manifest_sha256") &&
|
||||
acknowledgement.optString("producer_peer_id") == request.optString("desktop_peer_id") &&
|
||||
acknowledgement.optString("consumer_peer_id") == service.peerId() &&
|
||||
acknowledgement.optString("result") in setOf("consumed", "rejected")
|
||||
}
|
||||
|
||||
private fun requireExactKeys(value: JSONObject, expected: Set<String>, label: String) {
|
||||
val actual = value.keys().asSequence().toSet()
|
||||
if (actual != expected) throw SyncGenerationException("invalid $label fields")
|
||||
|
|
@ -104,13 +168,78 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
stream.flush()
|
||||
stream.fd.sync()
|
||||
}
|
||||
if (!temporary.renameTo(path)) throw SyncGenerationException("cannot publish ${path.name}")
|
||||
/* WHY: Drive conversations reuse coordination filenames, and
|
||||
* File.renameTo() does not reliably replace an existing destination
|
||||
* on Android private storage. CONTRACT: readers observe either the
|
||||
* previous complete JSON document or its complete replacement.
|
||||
* INVARIANT: the temporary file is in the destination directory, so
|
||||
* the preferred move is same-filesystem and atomic when supported. */
|
||||
try {
|
||||
Files.move(
|
||||
temporary.toPath(),
|
||||
path.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(
|
||||
temporary.toPath(),
|
||||
path.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
temporary.delete()
|
||||
throw SyncGenerationException("cannot publish ${path.name}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun await(path: File, deadline: Long): File {
|
||||
/* WHY: transports must advertise a stable installation before a desktop
|
||||
* can address its first request. CONTRACT: discovery publishes no domain
|
||||
* facts and does not acquire the conversation lock. INVARIANT: run() uses
|
||||
* this exact descriptor, so idle discovery cannot fork capabilities. */
|
||||
internal fun publishPeer(directory: File): String {
|
||||
if (!directory.isDirectory)
|
||||
throw SyncGenerationException("exchange directory is unavailable")
|
||||
val peer = service.peerId()
|
||||
val path = File(directory, "android-peer-v1.json")
|
||||
val descriptor =
|
||||
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",
|
||||
"generation-archive-v1",
|
||||
"session-preparations-v2",
|
||||
"programs-v1",
|
||||
)
|
||||
),
|
||||
)
|
||||
.toString()
|
||||
/* WHY: the foreground listener probes every five seconds, while some
|
||||
* Android MediaProvider implementations cannot atomically replace an
|
||||
* unchanged file that is simultaneously exposed over MTP. CONTRACT:
|
||||
* an identical canonical descriptor is already a complete durable
|
||||
* publication. INVARIANT: changed or unreadable bytes still use the
|
||||
* crash-safe replacement path above. */
|
||||
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false))
|
||||
return peer
|
||||
publish(path, descriptor)
|
||||
return peer
|
||||
}
|
||||
|
||||
private fun await(path: File, deadline: Long, pump: (() -> Unit)? = null): File {
|
||||
while (!path.isFile) {
|
||||
if (System.nanoTime() >= deadline)
|
||||
throw SyncGenerationException("timeout waiting for ${path.name}")
|
||||
pump?.invoke()
|
||||
Thread.sleep(50)
|
||||
}
|
||||
return path
|
||||
|
|
@ -120,6 +249,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
path: File,
|
||||
deadline: Long,
|
||||
predicate: (JSONObject) -> Boolean,
|
||||
pump: (() -> Unit)? = null,
|
||||
): ByteArray {
|
||||
while (true) {
|
||||
if (path.isFile && path.length() <= 64 * 1024) {
|
||||
|
|
@ -133,6 +263,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
}
|
||||
if (System.nanoTime() >= deadline)
|
||||
throw SyncGenerationException("timeout waiting for correlated ${path.name}")
|
||||
pump?.invoke()
|
||||
Thread.sleep(50)
|
||||
}
|
||||
}
|
||||
|
|
@ -142,34 +273,14 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
timeout: Duration = Duration.ofMinutes(5),
|
||||
afterPeerPublication: (() -> Unit)? = null,
|
||||
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",
|
||||
"generation-archive-v1",
|
||||
"session-preparations-v2",
|
||||
"programs-v1",
|
||||
)
|
||||
),
|
||||
)
|
||||
.toString(),
|
||||
)
|
||||
pollTransport: (() -> Unit)? = null,
|
||||
afterAcknowledgement: (() -> Unit)? = null,
|
||||
): ForegroundGenerationResult {
|
||||
if (!conversationLock.tryLock()) {
|
||||
return ForegroundGenerationResult.Error("Synchronization already in progress.")
|
||||
}
|
||||
return try {
|
||||
val peer = publishPeer(directory)
|
||||
/* WHY: trainlog-syncd is intentionally driven by the established
|
||||
* Android request artifact, not by polling generation internals.
|
||||
* CONTRACT: each explicit foreground attempt publishes the peer
|
||||
|
|
@ -179,7 +290,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
afterPeerPublication?.invoke()
|
||||
val deadline = System.nanoTime() + timeout.toNanos()
|
||||
val requestRaw =
|
||||
awaitCorrelated(File(directory, "request-v1.json"), deadline) { candidate ->
|
||||
awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate ->
|
||||
val runId = candidate.optString("run_id")
|
||||
runId.startsWith("sy_") &&
|
||||
(resumableGeneration(runId) != null ||
|
||||
|
|
@ -189,7 +300,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
arrayOf(runId),
|
||||
).use { it.moveToFirst() }
|
||||
})
|
||||
}
|
||||
}, pollTransport)
|
||||
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
|
||||
if (request.getString("android_peer_id") != peer)
|
||||
throw SyncGenerationException("request targets another Android peer")
|
||||
|
|
@ -197,10 +308,12 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
awaitCorrelated(
|
||||
File(directory, "desktop-archive-acknowledgements-v1.json"),
|
||||
deadline,
|
||||
) {
|
||||
it.optString("run_id") == request.getString("run_id") &&
|
||||
it.optString("android_peer_id") == peer
|
||||
}
|
||||
{
|
||||
it.optString("run_id") == request.getString("run_id") &&
|
||||
it.optString("android_peer_id") == peer
|
||||
},
|
||||
pollTransport,
|
||||
)
|
||||
reconcileArchiveAcknowledgements(
|
||||
archiveAckRaw,
|
||||
request.getString("run_id"),
|
||||
|
|
@ -240,19 +353,28 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
// the exchange directory. Only this run's exact generation ACK
|
||||
// can advance its lineage; stale bytes are retained and ignored.
|
||||
val desktopAck =
|
||||
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline) {
|
||||
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
|
||||
it.optString("run_id") == captured.runId &&
|
||||
it.optString("generation_id") == captured.generationId
|
||||
}
|
||||
}, pollTransport)
|
||||
service.acceptAcknowledgement(desktopAck)
|
||||
val referenceRaw =
|
||||
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline) {
|
||||
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
|
||||
it.optString("run_id") == captured.runId
|
||||
}
|
||||
}, pollTransport)
|
||||
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
|
||||
/* WHY: non-atomic transports can expose a newly downloaded
|
||||
* reference locally before the corresponding immutable objects
|
||||
* have reached this process. CONTRACT: correlation validates the
|
||||
* reference first, then one transport refresh stages its committed
|
||||
* generation before Core validates hashes and imports it.
|
||||
* INVARIANT: the refresh moves bytes only; service.consume remains
|
||||
* the sole validation and mutation authority. */
|
||||
pollTransport?.invoke()
|
||||
val acknowledgement =
|
||||
service.consume(File(directory, reference.getString("relative_path")))
|
||||
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
|
||||
afterAcknowledgement?.invoke()
|
||||
ForegroundGenerationResult.Completed(captured.runId)
|
||||
} catch (error: Exception) {
|
||||
if (error.message == "generation retention capacity exhausted") {
|
||||
|
|
@ -275,5 +397,12 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
}
|
||||
}
|
||||
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
|
||||
} finally {
|
||||
conversationLock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Compatibility name for focused tests and callers outside the coordinator
|
||||
* migration. New triggers should depend on SyncGenerationCoordinator. */
|
||||
internal typealias SyncGenerationForegroundCoordinator = SyncGenerationCoordinator
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ package com.labfytools.trainlog.ui
|
|||
/* TRAINLOG_ANDROID_TRIGGERED_SYNC_V1 */
|
||||
|
||||
import android.content.Context
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -28,6 +30,9 @@ 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.BackgroundSyncSettings
|
||||
import com.labfytools.trainlog.data.SyncBackgroundService
|
||||
import com.labfytools.trainlog.data.DriveSyncSettings
|
||||
import com.labfytools.trainlog.data.canonicalExchangeDirectory
|
||||
import com.labfytools.trainlog.data.logDirectExchange
|
||||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||
|
|
@ -164,6 +169,34 @@ fun SyncScreen(
|
|||
}
|
||||
|
||||
var syncRunning by remember { mutableStateOf(false) }
|
||||
val backgroundSettings = remember { BackgroundSyncSettings(context) }
|
||||
var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) }
|
||||
val driveSettings = remember { DriveSyncSettings(context) }
|
||||
var driveConnected by remember { mutableStateOf(driveSettings.connected) }
|
||||
|
||||
val driveFolderLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
runCatching { driveSettings.connect(uri) }
|
||||
.onSuccess {
|
||||
driveConnected = true
|
||||
status = strings.getString(R.string.drive_sync_connected)
|
||||
}
|
||||
.onFailure { status = strings.getString(R.string.drive_sync_connection_failed) }
|
||||
}
|
||||
}
|
||||
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
SyncBackgroundService.start(context)
|
||||
backgroundEnabled = true
|
||||
} else {
|
||||
status = strings.getString(R.string.background_sync_notification_required)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
pendingRequestId
|
||||
|
|
@ -453,6 +486,58 @@ fun SyncScreen(
|
|||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(title = strings.getString(R.string.background_sync_title)) {
|
||||
TrainlogInfo(
|
||||
text = strings.getString(R.string.background_sync_description),
|
||||
color = if (backgroundEnabled) colors.success else colors.warning,
|
||||
)
|
||||
TrainlogAction(
|
||||
label = strings.getString(
|
||||
if (backgroundEnabled) R.string.background_sync_disable
|
||||
else R.string.background_sync_enable,
|
||||
),
|
||||
description = strings.getString(R.string.background_sync_description),
|
||||
accent = if (backgroundEnabled) colors.warning else colors.success,
|
||||
onClick = {
|
||||
if (backgroundEnabled) {
|
||||
SyncBackgroundService.stop(context)
|
||||
backgroundEnabled = false
|
||||
} else if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
SyncBackgroundService.start(context)
|
||||
backgroundEnabled = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(title = strings.getString(R.string.drive_sync_title)) {
|
||||
TrainlogInfo(
|
||||
text = strings.getString(R.string.drive_sync_description),
|
||||
color = if (driveConnected) colors.success else colors.warning,
|
||||
)
|
||||
TrainlogAction(
|
||||
label = strings.getString(
|
||||
if (driveConnected) R.string.drive_sync_disconnect else R.string.drive_sync_connect,
|
||||
),
|
||||
description = strings.getString(R.string.drive_sync_latency),
|
||||
accent = if (driveConnected) colors.warning else colors.success,
|
||||
onClick = {
|
||||
if (driveConnected) {
|
||||
driveSettings.disconnect()
|
||||
driveConnected = false
|
||||
} else {
|
||||
driveFolderLauncher.launch(null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (status != null) {
|
||||
TrainlogFrame(
|
||||
title = strings.getString(R.string.sync_status),
|
||||
|
|
|
|||
|
|
@ -95,6 +95,21 @@
|
|||
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
|
||||
<string name="sync_running">Synchronization in progress…</string>
|
||||
<string name="sync_now">Synchronize now</string>
|
||||
<string name="background_sync_title">Background synchronization</string>
|
||||
<string name="background_sync_description">Allows Trainlog to receive PC synchronizations without opening the app.</string>
|
||||
<string name="background_sync_enable">Enable background synchronization</string>
|
||||
<string name="background_sync_disable">Disable background synchronization</string>
|
||||
<string name="background_sync_channel">Trainlog synchronization</string>
|
||||
<string name="background_sync_notification_title">Trainlog is ready to synchronize</string>
|
||||
<string name="background_sync_notification_text">The service is waiting for a PC request.</string>
|
||||
<string name="background_sync_notification_required">Allow notifications to enable background synchronization.</string>
|
||||
<string name="drive_sync_title">Private Google Drive</string>
|
||||
<string name="drive_sync_description">Select the private Trainlog/Sync/v1 folder. Trainlog only accesses that folder and never copies SQLite into it.</string>
|
||||
<string name="drive_sync_connect">Connect Google Drive</string>
|
||||
<string name="drive_sync_disconnect">Disconnect Google Drive</string>
|
||||
<string name="drive_sync_latency">Background checks are bounded by Android and can take at least 15 minutes.</string>
|
||||
<string name="drive_sync_connected">Drive folder connected. A check has been scheduled.</string>
|
||||
<string name="drive_sync_connection_failed">Unable to retain access to the Drive folder.</string>
|
||||
<string name="sync_flow_description">Android → PC then PC → Android, in one operation.</string>
|
||||
<string name="sync_authorize_first">File access is required. Authorize Documents/Trainlog first.</string>
|
||||
<string name="sync_request_sent">Request sent · waiting for the PC…</string>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,21 @@
|
|||
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
|
||||
<string name="sync_running">Synchronisation en cours…</string>
|
||||
<string name="sync_now">Synchroniser maintenant</string>
|
||||
<string name="background_sync_title">Synchronisation en arrière-plan</string>
|
||||
<string name="background_sync_description">Permet à Trainlog de recevoir les synchronisations du PC sans ouvrir l’application.</string>
|
||||
<string name="background_sync_enable">Activer la synchronisation en arrière-plan</string>
|
||||
<string name="background_sync_disable">Désactiver la synchronisation en arrière-plan</string>
|
||||
<string name="background_sync_channel">Synchronisation Trainlog</string>
|
||||
<string name="background_sync_notification_title">Trainlog est prêt à synchroniser</string>
|
||||
<string name="background_sync_notification_text">Le service attend une demande du PC.</string>
|
||||
<string name="background_sync_notification_required">Autorisez les notifications pour activer la synchronisation en arrière-plan.</string>
|
||||
<string name="drive_sync_title">Google Drive privé</string>
|
||||
<string name="drive_sync_description">Sélectionnez le dossier privé Trainlog/Sync/v1. Trainlog n’accède qu’à ce dossier et n’y copie jamais SQLite.</string>
|
||||
<string name="drive_sync_connect">Connecter Google Drive</string>
|
||||
<string name="drive_sync_disconnect">Déconnecter Google Drive</string>
|
||||
<string name="drive_sync_latency">La vérification en arrière-plan est bornée par Android et peut prendre au moins 15 minutes.</string>
|
||||
<string name="drive_sync_connected">Dossier Drive connecté. Une vérification est planifiée.</string>
|
||||
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Drive.</string>
|
||||
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
|
||||
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
|
||||
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ class SyncDeploymentConversationTest {
|
|||
val coordinator = SyncGenerationForegroundCoordinator(repository)
|
||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
var requestSignals = 0
|
||||
var refreshedAfterDesktopReference = false
|
||||
val result =
|
||||
executor.submit<ForegroundGenerationResult> {
|
||||
coordinator.run(
|
||||
|
|
@ -111,6 +112,14 @@ class SyncDeploymentConversationTest {
|
|||
Duration.ofSeconds(30),
|
||||
afterPeerPublication = { requestSignals += 1 },
|
||||
afterPublication = { release?.let { awaitFile(File(it)) } },
|
||||
pollTransport = {
|
||||
refreshedAfterDesktopReference =
|
||||
runCatching {
|
||||
JSONObject(File(transport, "desktop-generation-v1.json").readText())
|
||||
.optString("format") ==
|
||||
"trainlog-sync-generation-reference"
|
||||
}.getOrDefault(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
|
||||
|
|
@ -143,6 +152,10 @@ class SyncDeploymentConversationTest {
|
|||
completed is ForegroundGenerationResult.Completed,
|
||||
)
|
||||
assertEquals("one fresh request signal per conversation", 1, requestSignals)
|
||||
assertTrue(
|
||||
"transport is refreshed after the correlated desktop reference",
|
||||
refreshedAfterDesktopReference,
|
||||
)
|
||||
var workerOutput = ""
|
||||
localWorker?.let { worker ->
|
||||
workerOutput = worker.inputStream.bufferedReader().readText()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.labfytools.trainlog.model.SessionDraft
|
|||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
|
@ -349,6 +350,113 @@ class SyncGenerationServiceTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
|
||||
val databaseName = "generation-actionable-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-actionable-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
try {
|
||||
val coordinator = SyncGenerationCoordinator(repository)
|
||||
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||
File(root, "request-v1.json").writeText("not-json")
|
||||
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||
|
||||
val peer = SyncGenerationService(repository).peerId()
|
||||
val request =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-generation-request")
|
||||
.put("version", 1)
|
||||
.put("run_id", "sy_${UUID.randomUUID()}")
|
||||
.put("android_peer_id", "peer_${UUID.randomUUID()}")
|
||||
.put("desktop_peer_id", "peer_${UUID.randomUUID()}")
|
||||
File(root, "request-v1.json").writeText(request.toString())
|
||||
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||
|
||||
request.put("android_peer_id", peer)
|
||||
File(root, "request-v1.json").writeText(request.toString())
|
||||
assertEquals(true, coordinator.hasActionableRequest(root))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedPeerPublicationLeavesIdenticalCanonicalFileUntouched() {
|
||||
val databaseName = "generation-peer-publication-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-peer-publication-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
try {
|
||||
val coordinator = SyncGenerationCoordinator(repository)
|
||||
val peer = coordinator.publishPeer(root)
|
||||
val descriptor = File(root, "android-peer-v1.json")
|
||||
val retainedTimestamp = 1_234_000L
|
||||
assertTrue(descriptor.setLastModified(retainedTimestamp))
|
||||
|
||||
assertEquals(peer, coordinator.publishPeer(root))
|
||||
assertEquals(retainedTimestamp, descriptor.lastModified())
|
||||
assertEquals(peer, JSONObject(descriptor.readText()).getString("peer_id"))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coordinatorReplaysOnlyStrictlyCorrelatedDurableAcknowledgement() {
|
||||
val databaseName = "generation-ack-replay-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-ack-replay-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
try {
|
||||
val coordinator = SyncGenerationCoordinator(repository)
|
||||
val androidPeer = SyncGenerationService(repository).peerId()
|
||||
val desktopPeer = "peer_${UUID.randomUUID()}"
|
||||
val runId = "sy_${UUID.randomUUID()}"
|
||||
val generationId = "gen_${UUID.randomUUID()}"
|
||||
val digest = "a".repeat(64)
|
||||
File(root, "request-v1.json").writeText(
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-generation-request")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("android_peer_id", androidPeer)
|
||||
.put("desktop_peer_id", desktopPeer)
|
||||
.toString()
|
||||
)
|
||||
File(root, "desktop-generation-v1.json").writeText(
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-generation-reference")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("generation_id", generationId)
|
||||
.put("manifest_sha256", digest)
|
||||
.toString()
|
||||
)
|
||||
val acknowledgement =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-ack")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("generation_id", generationId)
|
||||
.put("manifest_sha256", digest)
|
||||
.put("producer_peer_id", desktopPeer)
|
||||
.put("consumer_peer_id", androidPeer)
|
||||
.put("result", "consumed")
|
||||
File(root, "android-consumption-ack-v1.json").writeText(acknowledgement.toString())
|
||||
assertEquals(true, coordinator.hasReplayableAcknowledgement(root))
|
||||
|
||||
acknowledgement.put("generation_id", "gen_${UUID.randomUUID()}")
|
||||
File(root, "android-consumption-ack-v1.json").writeText(acknowledgement.toString())
|
||||
assertEquals(false, coordinator.hasReplayableAcknowledgement(root))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v19MigrationPreservesDataAndInventsNoGenerationState() {
|
||||
val name = "generation-migration-${UUID.randomUUID()}.db"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
# Android application
|
||||
|
||||
## Background USB and private Drive synchronization
|
||||
|
||||
SyncScreen and the explicitly enabled background service invoke one
|
||||
`SyncGenerationCoordinator` above `SyncGenerationService`. The shared guard
|
||||
permits one device conversation at a time. The USB listener is a real
|
||||
`dataSync` foreground service with a dedicated visible notification; disabling
|
||||
the setting stops it. It validates a request and generation before any domain
|
||||
mutation and never starts a prepared workout.
|
||||
|
||||
Private Drive access uses a user-selected system document-provider folder and
|
||||
persisted folder-only permission. Disconnect revokes that permission and
|
||||
cancels periodic work. WorkManager performs a connected-network check at
|
||||
connection and periodic checks thereafter; Android's minimum periodic interval
|
||||
is 15 minutes and OEM scheduling can add latency. Drive therefore provides
|
||||
durable fallback, not an undocumented instant push promise. Android 15+ does
|
||||
not permit a `dataSync` foreground service to be launched from
|
||||
`BOOT_COMPLETED`, so the USB listener requires user activation again after
|
||||
reboot. Local data remains usable offline and no SQLite file enters Drive.
|
||||
|
||||
Android schema v22 adds a pending manual-preparation store which is separate
|
||||
from AI proposals and the exactly-one active-session singleton. A received
|
||||
preparation is inert until explicit start; an occupied singleton is never
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# Architecture
|
||||
|
||||
## Synchronization transport boundary
|
||||
|
||||
Generation capture, validation, causal import, publication and ACK acceptance
|
||||
are transport-neutral. `sync_peer_worker.py` owns that conversation; direct MTP
|
||||
and `sync_drive_transport.py` only move its bounded bytes. Automatic selection
|
||||
probes the expected MTP peer before preferring USB. A successful USB run is
|
||||
committed independently, then mirrored to Drive best-effort; Drive failure can
|
||||
never reopen its SQLite transactions. If MTP is unavailable, the same worker
|
||||
runs over the Drive byte adapter.
|
||||
|
||||
Android mirrors that ownership: one `SyncGenerationCoordinator` is called by
|
||||
SyncScreen, the visible USB foreground service, and the private-folder Drive
|
||||
worker. One process guard prevents overlap, while generation and ACK tables
|
||||
remain the restart authority. Transport staging and network/provider waits are
|
||||
outside SQLite transactions. Neither transport can select or traverse an
|
||||
application database.
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
# Current implementation state
|
||||
|
||||
Full-generation Web synchronization now selects an actually usable MTP peer
|
||||
before USB, falls back to a separately configured private Drive generation
|
||||
namespace, and mirrors successful USB conversations to Drive on a best-effort
|
||||
basis without rolling back USB. Both transports move the same generation/ACK
|
||||
bytes; neither transports SQLite. Android has an explicitly enabled foreground
|
||||
USB listener with mandatory notification and a private-folder Drive grant with
|
||||
bounded WorkManager checks. Both use the same concurrency-guarded generation
|
||||
coordinator as SyncScreen.
|
||||
|
||||
Existing preparation rows expose separate editing and Android-delivery states
|
||||
and an accessible send action. Draft/local advances the same preparation to a
|
||||
ready revision before delivery; ready/local delivers the current revision.
|
||||
Completed history hides the active preparation exclusively through the
|
||||
delivery's stable execution-session identity and retains all provenance.
|
||||
|
||||
The opt-in full-generation path is operational on the private daily
|
||||
installation. The loopback Web control and foreground Android coordinator use
|
||||
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
|
||||
|
|
@ -230,9 +245,23 @@ progression series, an original BODY ZONES SVG, and private versioned layout
|
|||
persistence with optimistic conflict and CSRF/Origin protection. It provides no
|
||||
automatic browser launch. Sessions provides complete paged preparation,
|
||||
resume and history views, stable details, optimistic manual-preparation writes,
|
||||
explicit proposal derivation and generation-backed Android delivery. Programmes
|
||||
is implemented as the fourth Sessions subtab; Analyse and Exercises remain
|
||||
explicit placeholders, and no standalone Programs route exists.
|
||||
explicit proposal derivation and generation-backed Android delivery. The
|
||||
top-level `/programmes` route is an operational daily active-Program calendar;
|
||||
Sessions → Programmes remains the technical administration/import, list,
|
||||
detail, archive, and delete surface. Analyse and Exercises remain explicit
|
||||
placeholders.
|
||||
|
||||
The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
||||
`fetchProgram`, and `createPreparationFromProgram`; it derives no local program
|
||||
or execution state. With no active Program it shows an empty state and
|
||||
navigation to Sessions administration; with one it selects it automatically;
|
||||
with several it offers a non-persisted selector. Its continuous Monday–Sunday
|
||||
weeks derive from Program bounds and real `planned_for` dates, do not invent
|
||||
rest-day sessions, and retain undated sessions in a separate section. It shows
|
||||
all five Core execution states; only `todo` offers Prepare. Each Prepare result
|
||||
is followed by an authoritative Core reread and may expose the returned
|
||||
preparation link. This calendar adds no Android delivery, start, or execution
|
||||
behavior.
|
||||
|
||||
Desktop schema v26 owns immutable manual-preparation revisions, stable ordered
|
||||
occurrences, persistent HTTP idempotency keys, delivery-to-execution identity,
|
||||
|
|
@ -487,8 +516,8 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional
|
|||
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
|
||||
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
||||
|
||||
- `WEB_NEXT_MODULE_SELECTION_V1`: Analyse, Programmes and Exercices remain
|
||||
shell placeholders. Exercises is the next proposed Web module.
|
||||
- `WEB_NEXT_MODULE_SELECTION_V1`: Analyse and Exercices remain shell
|
||||
placeholders. Exercises is the next proposed Web module.
|
||||
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
|
||||
- Session Generator V1 is hidden while V2 planning semantics are developed.
|
||||
- Hardware MTP validation requires a connected unlocked Android device.
|
||||
|
|
|
|||
53
docs/reviews/sync_usb_drive_android_v1_manual.md
Normal file
53
docs/reviews/sync_usb_drive_android_v1_manual.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# TRAINLOG_SYNC_USB_DRIVE_ANDROID_V1 manual validation
|
||||
|
||||
Automated tests use isolated directory and fake Drive transports. The first
|
||||
live validation must use a disposable namespace only:
|
||||
|
||||
```text
|
||||
Trainlog/Sync-Test/<run-id>/
|
||||
```
|
||||
|
||||
It must never target `Trainlog/Sync`, `Trainlog/AI`, `Documents/Trainlog` from a
|
||||
real user installation, or either SQLite database.
|
||||
|
||||
## Preconditions
|
||||
|
||||
1. Create a fresh UUIDv4-based `<run-id>` and an empty private Drive folder.
|
||||
2. Configure a disposable rclone remote/path for that exact folder.
|
||||
3. In a debug/test Android installation, select that same folder through the
|
||||
system document picker.
|
||||
4. Use disposable desktop and Android databases containing one synthetic
|
||||
preparation and no real account data.
|
||||
5. Record hashes and stable IDs before transport; never record credentials,
|
||||
URI grants, tokens or authorization headers.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Desktop publishes artifacts, verifies the uploaded bytes, then publishes
|
||||
`manifest.json` and the generation reference last.
|
||||
2. Android consumes the generation, confirms the preparation exactly once and
|
||||
publishes its correlated ACK plus one synthetic completed-session
|
||||
generation.
|
||||
3. Desktop consumes that generation, confirms one History row, one completed
|
||||
Program execution and one acknowledged preparation delivery, then publishes
|
||||
its ACK.
|
||||
4. Replay the same Drive objects and confirm every count is unchanged.
|
||||
5. Repeat the preserved facts over fake/isolated USB and confirm no duplicate
|
||||
delivery, execution session, History row or ACK.
|
||||
6. Interrupt one upload before the manifest and confirm neither peer mutates.
|
||||
7. Replace one artifact with a same-size SHA-mismatching payload and confirm
|
||||
rejection before mutation.
|
||||
8. Stop network access after USB commit and confirm USB remains committed while
|
||||
Web reports the Drive mirror failure.
|
||||
9. With SyncScreen closed and the user-enabled USB foreground notification
|
||||
visible, start a Web run and confirm the full request/generation/import/ACK
|
||||
conversation without touching Android.
|
||||
|
||||
## Cleanup
|
||||
|
||||
Remove only `Trainlog/Sync-Test/<run-id>/` 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -33,9 +36,11 @@ exchange and APP_SHELL_V1 retain their explicit manual validation/review gates.
|
|||
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
|
||||
contract, frozen interactive grid, versioned layout persistence and factual
|
||||
tile rendering exist. The Dashboard's bounded progression and BODY ZONES
|
||||
visualizations and Sessions V1 are implemented. Programs is implemented inside
|
||||
Sessions; Analyse, Exercises and the standalone top-level Programs route remain
|
||||
placeholders or out of scope.
|
||||
visualizations and Sessions V1 are implemented. The top-level `/programmes`
|
||||
route is an operational daily active-Program calendar, while
|
||||
Sessions → Programmes remains the technical administration/import, list,
|
||||
detail, archive, and delete surface. Analyse and Exercises remain placeholders
|
||||
or out of scope.
|
||||
The corrective prepared-item projection, bounded phase-owned MTP outbox,
|
||||
Sessions presentation correction, local date preference and durable preparation
|
||||
withdrawal are implemented. The withdrawal uses the separate versioned
|
||||
|
|
@ -145,10 +150,10 @@ rendering those metrics. Later Dashboard evolution must preserve those frozen
|
|||
boundaries.
|
||||
|
||||
`WEB_NEXT_MODULE_SELECTION_V1` remains a future Web documentary cursor. The current
|
||||
roadmap does not yet establish a contract order between Analyse, Programmes,
|
||||
Séances and Exercices. This cursor must inventory their existing Core services,
|
||||
read/write boundaries and dependencies, then select one bounded module contract;
|
||||
it does not authorize silently implementing a page.
|
||||
roadmap does not yet establish a contract order between the remaining placeholder
|
||||
routes, Analyse and Exercices. This cursor must inventory their existing Core
|
||||
services, read/write boundaries and dependencies, then select one bounded module
|
||||
contract; it does not authorize silently implementing a page.
|
||||
|
||||
## Next
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
145
tests/test_sync_drive_transport.py
Normal file
145
tests/test_sync_drive_transport.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
35
tests/test_sync_peer_worker.py
Normal file
35
tests/test_sync_peer_worker.py
Normal file
|
|
@ -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()
|
||||
228
tools/sync_drive_transport.py
Normal file
228
tools/sync_drive_transport.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ if web_assets_enabled
|
|||
'../web/src/api/sessions.ts',
|
||||
'../web/src/api/programs.ts',
|
||||
'../web/src/routes/ProgramsTab.tsx',
|
||||
'../web/src/routes/ProgramsCalendarPage.tsx',
|
||||
'../web/src/theme/app.css',
|
||||
'../web/src/theme/tokens.css',
|
||||
)
|
||||
|
|
@ -855,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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { App } from './App'
|
||||
import { parseDashboard, type DashboardSnapshot } from '../api/dashboard'
|
||||
|
||||
const programsApi = vi.hoisted(() => ({
|
||||
fetchAllPrograms: vi.fn(),
|
||||
fetchProgram: vi.fn(),
|
||||
createPreparationFromProgram: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/programs', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('../api/programs')>(),
|
||||
...programsApi,
|
||||
}))
|
||||
|
||||
const health = {
|
||||
api_version: 1,
|
||||
status: 'ok',
|
||||
|
|
@ -52,6 +63,13 @@ function mockHealth(value: unknown = health) {
|
|||
}
|
||||
|
||||
describe('shell Trainlog', () => {
|
||||
beforeEach(() => {
|
||||
programsApi.fetchAllPrograms.mockReset()
|
||||
programsApi.fetchProgram.mockReset()
|
||||
programsApi.createPreparationFromProgram.mockReset()
|
||||
programsApi.fetchAllPrograms.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('rend le shell, les sept états vides et le health sans données fictives', async () => {
|
||||
mockHealth()
|
||||
render(<App />)
|
||||
|
|
@ -85,19 +103,29 @@ describe('shell Trainlog', () => {
|
|||
const footer = screen.getByRole('contentinfo')
|
||||
fireEvent.click(screen.getByRole('link', { name: label }))
|
||||
expect(window.location.pathname).toBe(path)
|
||||
expect(screen.getByRole('heading', { level: 1, name: label })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: label })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: label })).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.getByRole('banner')).toBe(header)
|
||||
expect(screen.getByRole('contentinfo')).toBe(footer)
|
||||
})
|
||||
|
||||
it('suit la navigation arrière du navigateur', () => {
|
||||
it('suit la navigation arrière du navigateur', async () => {
|
||||
mockHealth()
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Analyse' }))
|
||||
window.history.pushState(null, '', '/programmes')
|
||||
fireEvent.popState(window)
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Programmes' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Programmes' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('route /programmes vers la page calendrier réelle', async () => {
|
||||
window.history.replaceState(null, '', '/programmes')
|
||||
mockHealth()
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Aucun programme actif.')).toBeInTheDocument()
|
||||
expect(programsApi.fetchAllPrograms).toHaveBeenCalledWith('', 'active', expect.any(AbortSignal))
|
||||
expect(screen.queryByText(/command services Core explicites/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('présente un échec health discret et accessible', async () => {
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import { Footer } from '../components/Footer'
|
|||
import { Header } from '../components/Header'
|
||||
import { DashboardPage } from '../routes/DashboardPage'
|
||||
import { PlaceholderPage } from '../routes/PlaceholderPage'
|
||||
import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage'
|
||||
import { SessionsPage } from '../routes/SessionsPage'
|
||||
import type { RouteId } from './routes'
|
||||
import { useRoute } from './useRoute'
|
||||
import { DatePreferencesProvider } from '../presentation/DatePreferences'
|
||||
|
||||
const placeholderContent: Record<Exclude<RouteId, 'dashboard'>, [string, string, string]> = {
|
||||
const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'programs'>, [string, string, string]> = {
|
||||
analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'],
|
||||
programs: ['Programmes', 'PRÉPARER', 'La préparation des programmes utilisera des command services Core explicites.'],
|
||||
sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'],
|
||||
exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'],
|
||||
}
|
||||
|
|
@ -75,6 +75,7 @@ function AppContent() {
|
|||
preparedItemsFailed={preparedItemsFailed}
|
||||
/>
|
||||
) : route.id === 'sessions' ? <SessionsPage /> :
|
||||
route.id === 'programs' ? <ProgramsCalendarPage onNavigate={navigate} /> :
|
||||
<PlaceholderPage title={placeholderContent[route.id][0]}
|
||||
eyebrow={placeholderContent[route.id][1]}
|
||||
description={placeholderContent[route.id][2]} />
|
||||
|
|
|
|||
|
|
@ -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 && <p>{action}</p>}
|
||||
{status?.error_code && <p>Code : {status.error_code}</p>}
|
||||
{status?.diagnostic && <p>{status.diagnostic}</p>}
|
||||
{status?.usb_state && <p>Android USB : {{
|
||||
unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours',
|
||||
success: '✓ synchronisé', failed: '⚠ non traité',
|
||||
}[status.usb_state]}</p>}
|
||||
{status?.drive_state && <p>Google Drive : {{
|
||||
disabled: 'désactivé', unavailable: 'non disponible', running: 'en cours',
|
||||
mirrored: '✓ miroir à jour', success: '✓ synchronisé', failed: '⚠ miroir non mis à jour',
|
||||
}[status.drive_state]}</p>}
|
||||
{status?.drive_diagnostic && <p>{status.drive_diagnostic}</p>}
|
||||
{status?.phase === 'waiting_android_publication' && <p>Demande : envoyée · Android : génération attendue</p>}
|
||||
{status?.inbound_generation_id && <p>PC : import terminé · <code>{status.inbound_generation_id}</code></p>}
|
||||
{status?.outbound_generation_id && <p>PC : génération publiée · <code>{status.outbound_generation_id}</code></p>}
|
||||
{status?.phase === 'completed' && <p>Android : import terminé · ACK : reçu · Résultat : terminé</p>}
|
||||
{status?.sessions_reconciled !== null && status?.sessions_reconciled !== undefined && (
|
||||
<p>Séances rapprochées : {status.sessions_reconciled}</p>
|
||||
)}
|
||||
|
|
|
|||
315
web/src/routes/ProgramsCalendarPage.test.tsx
Normal file
315
web/src/routes/ProgramsCalendarPage.test.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProgramDetail, ProgramListItem, ProgramSession } from '../api/programs'
|
||||
import * as sessionsApi from '../api/sessions'
|
||||
import { ProgramsCalendarPage } from './ProgramsCalendarPage'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
fetchAllPrograms: vi.fn(),
|
||||
fetchProgram: vi.fn(),
|
||||
createPreparationFromProgram: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/programs', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('../api/programs')>(),
|
||||
...api,
|
||||
}))
|
||||
|
||||
function item(programId = 'pg_one', title = 'Cycle force'): ProgramListItem {
|
||||
return {
|
||||
program_id: programId,
|
||||
title,
|
||||
state: 'active',
|
||||
start_date: '2026-09-16',
|
||||
end_date: '2026-09-24',
|
||||
session_count: 1,
|
||||
provenance: 'trainlog-program',
|
||||
updated_at: '2026-09-19T10:00:00Z',
|
||||
imported_at: '2026-09-19T10:00:00Z',
|
||||
revision_id: 'pgr_one',
|
||||
preparation_count: 0,
|
||||
usage: 'unused',
|
||||
}
|
||||
}
|
||||
|
||||
function session(
|
||||
programSessionId: string,
|
||||
title: string,
|
||||
plannedFor: string | null,
|
||||
executionState: ProgramSession['execution_state'] = 'todo',
|
||||
): ProgramSession {
|
||||
return {
|
||||
program_session_id: programSessionId,
|
||||
position: 0,
|
||||
title,
|
||||
session_type: 'training',
|
||||
planned_for: plannedFor,
|
||||
note: null,
|
||||
execution_state: executionState,
|
||||
execution_session_id: null,
|
||||
occurrences: [],
|
||||
}
|
||||
}
|
||||
|
||||
function detail(overrides: Partial<ProgramDetail> = {}): ProgramDetail {
|
||||
return {
|
||||
api_version: 1,
|
||||
program_id: 'pg_one',
|
||||
title: 'Cycle force',
|
||||
note: null,
|
||||
state: 'active',
|
||||
start_date: '2026-09-16',
|
||||
end_date: '2026-09-24',
|
||||
created_at: '2026-09-19T10:00:00Z',
|
||||
updated_at: '2026-09-19T10:00:00Z',
|
||||
revision_id: 'pgr_one',
|
||||
source_format: 'trainlog-program',
|
||||
source_version: 1,
|
||||
source_payload_sha256: 'a'.repeat(64),
|
||||
sessions: [session('pgs_one', 'Jambes', '2026-09-16')],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('ProgramsCalendarPage', () => {
|
||||
beforeEach(() => {
|
||||
api.fetchAllPrograms.mockReset()
|
||||
api.fetchProgram.mockReset()
|
||||
api.createPreparationFromProgram.mockReset()
|
||||
api.fetchAllPrograms.mockResolvedValue([item()])
|
||||
api.fetchProgram.mockResolvedValue(detail())
|
||||
})
|
||||
|
||||
it('requests only active programs and automatically selects the sole result', async () => {
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Cycle force' })).toBeInTheDocument()
|
||||
expect(api.fetchAllPrograms).toHaveBeenCalledWith('', 'active', expect.any(AbortSignal))
|
||||
expect(api.fetchProgram).toHaveBeenCalledWith('pg_one', expect.any(AbortSignal))
|
||||
expect(screen.queryByRole('combobox', { name: 'Programme actif' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the exact empty state and navigates to Sessions program administration', async () => {
|
||||
api.fetchAllPrograms.mockResolvedValue([])
|
||||
const onNavigate = vi.fn()
|
||||
render(<ProgramsCalendarPage onNavigate={onNavigate} />)
|
||||
|
||||
expect(await screen.findByText('Aucun programme actif.')).toBeInTheDocument()
|
||||
expect(screen.getByText(/onglet Programmes de la page Séances/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Aller à Séances — Programmes' }))
|
||||
expect(onNavigate).toHaveBeenCalledWith('/seances')
|
||||
expect(api.fetchProgram).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers a non-persisted selector for multiple programs and fetches the chosen detail', async () => {
|
||||
const second = item('pg_two', 'Cycle mobilité')
|
||||
api.fetchAllPrograms.mockResolvedValue([item(), second])
|
||||
api.fetchProgram.mockImplementation((programId: string) => Promise.resolve(detail({
|
||||
program_id: programId,
|
||||
title: programId === 'pg_two' ? 'Cycle mobilité' : 'Cycle force',
|
||||
})))
|
||||
const storageSpy = vi.spyOn(Storage.prototype, 'setItem')
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
const selector = await screen.findByRole('combobox', { name: 'Programme actif' })
|
||||
expect(selector).toHaveValue('pg_one')
|
||||
fireEvent.change(selector, { target: { value: 'pg_two' } })
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Cycle mobilité' })).toBeInTheDocument()
|
||||
expect(api.fetchProgram).toHaveBeenLastCalledWith('pg_two', expect.any(AbortSignal))
|
||||
expect(storageSpy).not.toHaveBeenCalled()
|
||||
storageSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('ignores a stale detail response after selecting another program', async () => {
|
||||
const first = deferred<ProgramDetail>()
|
||||
api.fetchAllPrograms.mockResolvedValue([item(), item('pg_two', 'Cycle mobilité')])
|
||||
api.fetchProgram
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(detail({ program_id: 'pg_two', title: 'Cycle mobilité' }))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
const selector = await screen.findByRole('combobox', { name: 'Programme actif' })
|
||||
fireEvent.change(selector, { target: { value: 'pg_two' } })
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Cycle mobilité' })).toBeInTheDocument()
|
||||
first.resolve(detail({ title: 'Réponse périmée' }))
|
||||
await waitFor(() => expect(screen.queryByText('Réponse périmée')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Cycle mobilité' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders full Monday-Sunday weeks, program facts, and light rest days without inventing sessions', async () => {
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
|
||||
expect(screen.getByText('PROGRAMME ACTIF')).toBeInTheDocument()
|
||||
expect(screen.getByText('16/09/2026 – 24/09/2026 · 1 séance')).toBeInTheDocument()
|
||||
const calendar = screen.getByLabelText('Calendrier hebdomadaire du programme')
|
||||
expect(within(calendar).getAllByRole('region')).toHaveLength(14)
|
||||
expect(within(calendar).getByLabelText('Lun 14/09/2026')).toBeInTheDocument()
|
||||
expect(within(calendar).getByLabelText('Dim 27/09/2026')).toBeInTheDocument()
|
||||
expect(within(calendar).getAllByText('Repos')).toHaveLength(13)
|
||||
expect(within(calendar).getAllByText('Jambes')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves every session sharing a date and keeps null or invalid dates in the undated section', async () => {
|
||||
api.fetchProgram.mockResolvedValue(detail({ sessions: [
|
||||
session('pgs_a', 'Haut A', '2026-09-16'),
|
||||
session('pgs_b', 'Haut B', '2026-09-16', 'prepared'),
|
||||
session('pgs_c', 'Libre', null),
|
||||
session('pgs_d', 'Date source invalide', 'not-a-date'),
|
||||
] }))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
const day = await screen.findByLabelText('Mer 16/09/2026')
|
||||
expect(within(day).getByText('Haut A')).toBeInTheDocument()
|
||||
expect(within(day).getByText('Haut B')).toBeInTheDocument()
|
||||
const undated = screen.getByRole('region', { name: 'Séances sans date' })
|
||||
expect(within(undated).getByText('Libre')).toBeInTheDocument()
|
||||
expect(within(undated).getByText('Date source invalide')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a planned session in year 0001 in its calendar day rather than as undated', async () => {
|
||||
api.fetchProgram.mockResolvedValue(detail({
|
||||
start_date: '0001-01-01',
|
||||
end_date: '0001-01-01',
|
||||
sessions: [session('pgs_early', 'Séance ancienne', '0001-01-01')],
|
||||
}))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
const day = await screen.findByLabelText('Lun 01/01/0001')
|
||||
expect(within(day).getByText('Séance ancienne')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Séances sans date' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders every Core execution state with its exact semantic class and label', async () => {
|
||||
const states: ProgramSession['execution_state'][] = [
|
||||
'todo', 'prepared', 'in_progress', 'completed', 'deleted',
|
||||
]
|
||||
api.fetchProgram.mockResolvedValue(detail({ sessions: states.map((state, index) =>
|
||||
session(`pgs_${state}`, `Séance ${index}`, `2026-09-${String(16 + index).padStart(2, '0')}`, state)) }))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
const labels = ['À préparer', 'Préparée', 'En cours', 'Effectuée', 'Retirée']
|
||||
await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
|
||||
states.forEach((state, index) => {
|
||||
const title = screen.getByText(`Séance ${index}`)
|
||||
expect(title.closest('article')).toHaveClass(`program-calendar-state-${state}`)
|
||||
expect(within(title.closest('article') as HTMLElement).getByText(labels[index])).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getAllByRole('button', { name: 'Préparer' })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('guards a pending preparation against double clicks and rereads authoritative state', async () => {
|
||||
const command = deferred<{ preparation_id: string }>()
|
||||
api.createPreparationFromProgram.mockReturnValue(command.promise)
|
||||
api.fetchProgram
|
||||
.mockResolvedValueOnce(detail())
|
||||
.mockResolvedValueOnce(detail({ sessions: [session('pgs_one', 'Jambes', '2026-09-16', 'prepared')] }))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Préparer' })
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
expect(screen.getByRole('button', { name: 'Préparation…' })).toBeDisabled()
|
||||
expect(api.createPreparationFromProgram).toHaveBeenCalledTimes(1)
|
||||
expect(api.createPreparationFromProgram).toHaveBeenCalledWith('pg_one', 'pgs_one')
|
||||
|
||||
command.resolve({ preparation_id: 'sp_created' })
|
||||
await waitFor(() => expect(screen.getByText('Préparation créée.')).toBeInTheDocument())
|
||||
expect(api.fetchProgram).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getAllByText('Préparée')).toHaveLength(2)
|
||||
expect(screen.queryByRole('button', { name: 'Préparer' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not publish a preparation reread after switching programs or deliver to Android', async () => {
|
||||
const reread = deferred<ProgramDetail>()
|
||||
const deliver = vi.spyOn(sessionsApi, 'prepareForAndroid')
|
||||
api.fetchAllPrograms.mockResolvedValue([item(), item('pg_two', 'Cycle mobilité')])
|
||||
api.createPreparationFromProgram.mockResolvedValue({ preparation_id: 'sp_created' })
|
||||
api.fetchProgram
|
||||
.mockResolvedValueOnce(detail())
|
||||
.mockReturnValueOnce(reread.promise)
|
||||
.mockResolvedValueOnce(detail({ program_id: 'pg_two', title: 'Cycle mobilité' }))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Préparer' }))
|
||||
await waitFor(() => expect(api.fetchProgram).toHaveBeenCalledTimes(2))
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Programme actif' }), {
|
||||
target: { value: 'pg_two' },
|
||||
})
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Cycle mobilité' })).toBeInTheDocument()
|
||||
reread.resolve(detail({
|
||||
title: 'Réponse de préparation périmée',
|
||||
sessions: [session('pgs_one', 'Jambes', '2026-09-16', 'prepared')],
|
||||
}))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Réponse de préparation périmée')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Préparation créée.')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Ouvrir la préparation' })).not.toBeInTheDocument()
|
||||
})
|
||||
expect(deliver).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settles a pending preparation after unmount without rereading or updating delivery state', async () => {
|
||||
const command = deferred<{ preparation_id: string }>()
|
||||
const deliver = vi.spyOn(sessionsApi, 'prepareForAndroid')
|
||||
api.createPreparationFromProgram.mockReturnValue(command.promise)
|
||||
const view = render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Préparer' }))
|
||||
view.unmount()
|
||||
command.resolve({ preparation_id: 'sp_created' })
|
||||
await command.promise
|
||||
await Promise.resolve()
|
||||
expect(api.fetchProgram).toHaveBeenCalledTimes(1)
|
||||
expect(deliver).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers the returned preparation destination only after success', async () => {
|
||||
const onNavigate = vi.fn()
|
||||
api.createPreparationFromProgram.mockResolvedValue({ preparation_id: 'sp_created' })
|
||||
api.fetchProgram
|
||||
.mockResolvedValueOnce(detail())
|
||||
.mockResolvedValueOnce(detail({ sessions: [session('pgs_one', 'Jambes', '2026-09-16', 'prepared')] }))
|
||||
render(<ProgramsCalendarPage onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'Ouvrir la préparation' })).not.toBeInTheDocument()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Préparer' }))
|
||||
const link = await screen.findByRole('link', { name: 'Ouvrir la préparation' })
|
||||
expect(link).toHaveAttribute('href', '/seances/preparation/sp_created')
|
||||
fireEvent.click(link)
|
||||
expect(onNavigate).toHaveBeenCalledWith('/seances/preparation/sp_created')
|
||||
})
|
||||
|
||||
it('announces a specific command failure and leaves the Core-derived todo state intact', async () => {
|
||||
api.createPreparationFromProgram.mockRejectedValue(new Error('conflit de révision'))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Préparer' }))
|
||||
expect(await screen.findByText('Préparation impossible : conflit de révision')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Préparer' })).toBeEnabled()
|
||||
expect(screen.getAllByText('À préparer')).toHaveLength(2)
|
||||
expect(api.fetchProgram).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('link', { name: 'Ouvrir la préparation' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a failed authoritative reread without fabricating prepared state', async () => {
|
||||
api.createPreparationFromProgram.mockResolvedValue({ preparation_id: 'sp_created' })
|
||||
api.fetchProgram.mockResolvedValueOnce(detail()).mockRejectedValueOnce(new Error('réseau indisponible'))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Préparer' }))
|
||||
expect(await screen.findByText(/programme n’a pas pu être actualisé : réseau indisponible/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Préparer' })).toBeEnabled()
|
||||
expect(screen.getAllByText('À préparer')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
312
web/src/routes/ProgramsCalendarPage.tsx
Normal file
312
web/src/routes/ProgramsCalendarPage.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createPreparationFromProgram,
|
||||
fetchAllPrograms,
|
||||
fetchProgram,
|
||||
type ProgramDetail,
|
||||
type ProgramListItem,
|
||||
type ProgramSession,
|
||||
} from '../api/programs'
|
||||
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||
import { formatCivilDate, parseCivilDate } from '../presentation/dateFormat'
|
||||
|
||||
type ExecutionState = ProgramSession['execution_state']
|
||||
|
||||
const stateLabels: Record<ExecutionState, string> = {
|
||||
todo: 'À préparer',
|
||||
prepared: 'Préparée',
|
||||
in_progress: 'En cours',
|
||||
completed: 'Effectuée',
|
||||
deleted: 'Retirée',
|
||||
}
|
||||
|
||||
const weekdayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
||||
const dayMilliseconds = 24 * 60 * 60 * 1000
|
||||
|
||||
interface CalendarDay {
|
||||
date: string
|
||||
dayNumber: number
|
||||
weekday: string
|
||||
sessions: ProgramSession[]
|
||||
}
|
||||
|
||||
function dateValue(value: string | null): number | null {
|
||||
if (value === null) return null
|
||||
const parts = parseCivilDate(value)
|
||||
if (parts === null) return null
|
||||
|
||||
const date = new Date(0)
|
||||
date.setUTCFullYear(parts.year, parts.month - 1, parts.day)
|
||||
return date.valueOf()
|
||||
}
|
||||
|
||||
function isoDate(value: number): string {
|
||||
return new Date(value).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function calendarDays(program: ProgramDetail): CalendarDay[] {
|
||||
const datedSessions = program.sessions.filter((session) => dateValue(session.planned_for) !== null)
|
||||
const values = [program.start_date, program.end_date, ...datedSessions.map((session) => session.planned_for)]
|
||||
.map(dateValue).filter((value): value is number => value !== null)
|
||||
if (values.length === 0) return []
|
||||
|
||||
const first = Math.min(...values)
|
||||
const last = Math.max(...values)
|
||||
const firstWeekday = (new Date(first).getUTCDay() + 6) % 7
|
||||
const lastWeekday = (new Date(last).getUTCDay() + 6) % 7
|
||||
const start = first - firstWeekday * dayMilliseconds
|
||||
const end = last + (6 - lastWeekday) * dayMilliseconds
|
||||
const sessionsByDate = new Map<string, ProgramSession[]>()
|
||||
for (const session of datedSessions) {
|
||||
const sessions = sessionsByDate.get(session.planned_for as string) ?? []
|
||||
sessions.push(session)
|
||||
sessionsByDate.set(session.planned_for as string, sessions)
|
||||
}
|
||||
|
||||
const days: CalendarDay[] = []
|
||||
for (let value = start; value <= end; value += dayMilliseconds) {
|
||||
const date = isoDate(value)
|
||||
const dateObject = new Date(value)
|
||||
days.push({
|
||||
date,
|
||||
dayNumber: dateObject.getUTCDate(),
|
||||
weekday: weekdayLabels[(dateObject.getUTCDay() + 6) % 7],
|
||||
sessions: sessionsByDate.get(date) ?? [],
|
||||
})
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown, fallback: string): string {
|
||||
return reason instanceof Error && reason.message ? reason.message : fallback
|
||||
}
|
||||
|
||||
function NavigationLink({ path, onNavigate, children, className }: {
|
||||
path: string
|
||||
onNavigate: (path: string) => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <a className={className} href={path} onClick={(event) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
event.preventDefault()
|
||||
onNavigate(path)
|
||||
}}>{children}</a>
|
||||
}
|
||||
|
||||
function ProgramSessionCard({ programId, session, pending, preparationId, onPrepare, onNavigate }: {
|
||||
programId: string
|
||||
session: ProgramSession
|
||||
pending: boolean
|
||||
preparationId?: string
|
||||
onPrepare: (programId: string, session: ProgramSession) => void
|
||||
onNavigate: (path: string) => void
|
||||
}) {
|
||||
return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}>
|
||||
<h3>{session.title}</h3>
|
||||
<span className="program-calendar-state-label">{stateLabels[session.execution_state]}</span>
|
||||
<div className="program-calendar-action">
|
||||
{session.execution_state === 'todo' && <button type="button" disabled={pending}
|
||||
onClick={() => onPrepare(programId, session)}>
|
||||
{pending ? 'Préparation…' : 'Préparer'}
|
||||
</button>}
|
||||
{preparationId && <NavigationLink path={`/seances/preparation/${encodeURIComponent(preparationId)}`}
|
||||
onNavigate={onNavigate}>Ouvrir la préparation</NavigationLink>}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string) => void }) {
|
||||
const { dateFormat } = useDatePreferences()
|
||||
const [programs, setPrograms] = useState<ProgramListItem[]>([])
|
||||
const [programsPending, setProgramsPending] = useState(true)
|
||||
const [programsError, setProgramsError] = useState('')
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [detail, setDetail] = useState<ProgramDetail | null>(null)
|
||||
const [detailPending, setDetailPending] = useState(false)
|
||||
const [detailError, setDetailError] = useState('')
|
||||
const [pendingSessions, setPendingSessions] = useState<Set<string>>(new Set())
|
||||
const [preparationIds, setPreparationIds] = useState<Record<string, string>>({})
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const preparing = useRef(new Set<string>())
|
||||
const mounted = useRef(true)
|
||||
const selectedIdRef = useRef('')
|
||||
const selectionGeneration = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => {
|
||||
mounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
fetchAllPrograms('', 'active', controller.signal).then((items) => {
|
||||
if (controller.signal.aborted || !mounted.current) return
|
||||
setPrograms(items)
|
||||
const next = selectedIdRef.current || items[0]?.program_id || ''
|
||||
if (next !== selectedIdRef.current) selectionGeneration.current += 1
|
||||
selectedIdRef.current = next
|
||||
setSelectedId(next)
|
||||
}).catch((reason) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setProgramsError(errorMessage(reason, 'Chargement des programmes impossible.'))
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setProgramsPending(false)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setDetail(null)
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const generation = selectionGeneration.current
|
||||
const isCurrent = () => mounted.current && !controller.signal.aborted &&
|
||||
selectedIdRef.current === selectedId && selectionGeneration.current === generation
|
||||
setDetail(null)
|
||||
setDetailPending(true)
|
||||
setDetailError('')
|
||||
setAnnouncement('')
|
||||
setPreparationIds({})
|
||||
fetchProgram(selectedId, controller.signal).then((value) => {
|
||||
if (isCurrent()) setDetail(value)
|
||||
}).catch((reason) => {
|
||||
if (isCurrent()) {
|
||||
setDetailError(errorMessage(reason, 'Chargement du programme impossible.'))
|
||||
}
|
||||
}).finally(() => {
|
||||
if (isCurrent()) setDetailPending(false)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [selectedId])
|
||||
|
||||
const days = useMemo(() => detail === null ? [] : calendarDays(detail), [detail])
|
||||
const undatedSessions = detail?.sessions.filter((session) => dateValue(session.planned_for) === null) ?? []
|
||||
|
||||
const prepare = async (programId: string, session: ProgramSession) => {
|
||||
const sessionId = session.program_session_id
|
||||
if (session.execution_state !== 'todo' || preparing.current.has(sessionId)) return
|
||||
const generation = selectionGeneration.current
|
||||
const isCurrent = () => mounted.current && selectedIdRef.current === programId &&
|
||||
selectionGeneration.current === generation
|
||||
preparing.current.add(sessionId)
|
||||
setPendingSessions((current) => new Set(current).add(sessionId))
|
||||
setAnnouncement('')
|
||||
try {
|
||||
const result = await createPreparationFromProgram(programId, sessionId)
|
||||
if (!isCurrent()) return
|
||||
setPreparationIds((current) => ({ ...current, [sessionId]: result.preparation_id }))
|
||||
try {
|
||||
const reread = await fetchProgram(programId)
|
||||
if (!isCurrent()) return
|
||||
setDetail(reread)
|
||||
setAnnouncement('Préparation créée.')
|
||||
} catch (reason) {
|
||||
if (!isCurrent()) return
|
||||
const reasonMessage = errorMessage(reason, 'erreur inconnue')
|
||||
setAnnouncement(
|
||||
`Préparation créée, mais le programme n’a pas pu être actualisé : ${reasonMessage}`,
|
||||
)
|
||||
}
|
||||
} catch (reason) {
|
||||
if (isCurrent()) {
|
||||
setAnnouncement(`Préparation impossible : ${errorMessage(reason, 'erreur inconnue')}`)
|
||||
}
|
||||
} finally {
|
||||
preparing.current.delete(sessionId)
|
||||
if (mounted.current) {
|
||||
setPendingSessions((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(sessionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderSession = (session: ProgramSession) => <ProgramSessionCard
|
||||
key={session.program_session_id}
|
||||
programId={detail?.program_id ?? ''}
|
||||
session={session}
|
||||
pending={pendingSessions.has(session.program_session_id)}
|
||||
preparationId={preparationIds[session.program_session_id]}
|
||||
onPrepare={prepare}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
|
||||
return <section className="page programs-calendar-page">
|
||||
{programsPending && <p className="program-calendar-loading" role="status">Chargement des programmes…</p>}
|
||||
{!programsPending && programsError && <p className="error-panel" role="alert">{programsError}</p>}
|
||||
{!programsPending && !programsError && programs.length === 0 && <div className="program-calendar-empty">
|
||||
<p className="eyebrow">PROGRAMMES</p>
|
||||
<h1>Programmes</h1>
|
||||
<p>Aucun programme actif.</p>
|
||||
<p>Importez ou gérez un programme depuis l’onglet Programmes de la page Séances.</p>
|
||||
<NavigationLink className="primary-action" path="/seances" onNavigate={onNavigate}>
|
||||
Aller à Séances — Programmes
|
||||
</NavigationLink>
|
||||
</div>}
|
||||
{!programsPending && !programsError && programs.length > 0 && <>
|
||||
{programs.length > 1 && <label className="program-calendar-selector">
|
||||
Programme actif
|
||||
<select value={selectedId} onChange={(event) => {
|
||||
const next = event.target.value
|
||||
if (next !== selectedIdRef.current) selectionGeneration.current += 1
|
||||
selectedIdRef.current = next
|
||||
setSelectedId(next)
|
||||
}}>
|
||||
{programs.map((program) => <option key={program.program_id} value={program.program_id}>
|
||||
{program.title}
|
||||
</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
{detailPending && <p className="program-calendar-loading" role="status">Chargement du programme…</p>}
|
||||
{!detailPending && detailError && <p className="error-panel" role="alert">{detailError}</p>}
|
||||
{!detailPending && detail && <>
|
||||
<header className="program-calendar-header">
|
||||
<div>
|
||||
<p className="eyebrow">PROGRAMME ACTIF</p>
|
||||
<h1>{detail.title}</h1>
|
||||
<p className="program-calendar-summary">
|
||||
{detail.start_date && detail.end_date
|
||||
? `${formatCivilDate(detail.start_date, dateFormat)} – ${formatCivilDate(detail.end_date, dateFormat)}`
|
||||
: detail.start_date ? `À partir du ${formatCivilDate(detail.start_date, dateFormat)}`
|
||||
: detail.end_date ? `Jusqu’au ${formatCivilDate(detail.end_date, dateFormat)}`
|
||||
: 'Dates non définies'}
|
||||
{' · '}{detail.sessions.length} séance{detail.sessions.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="program-calendar-legend" aria-label="Légende des états">
|
||||
{(Object.keys(stateLabels) as ExecutionState[]).map((state) => <li key={state}
|
||||
className={`program-calendar-state-${state}`}>{stateLabels[state]}</li>)}
|
||||
</ul>
|
||||
</header>
|
||||
<p className="program-calendar-announcement" aria-live="polite">{announcement}</p>
|
||||
{days.length > 0 && <div className="program-calendar-scroll" tabIndex={0}
|
||||
aria-label="Calendrier hebdomadaire du programme">
|
||||
<div className="program-calendar-grid">
|
||||
{days.map((day) => <section className="program-calendar-day" key={day.date}
|
||||
aria-label={`${day.weekday} ${formatCivilDate(day.date, dateFormat)}`}>
|
||||
<header><span>{day.weekday}</span><strong>{day.dayNumber}</strong>
|
||||
<time dateTime={day.date}>{formatCivilDate(day.date, dateFormat)}</time></header>
|
||||
{day.sessions.length === 0
|
||||
? <p className="program-calendar-rest">Repos</p>
|
||||
: <div className="program-calendar-day-sessions">{day.sessions.map(renderSession)}</div>}
|
||||
</section>)}
|
||||
</div>
|
||||
</div>}
|
||||
{days.length === 0 && <p className="empty-inline">Aucune date planifiée.</p>}
|
||||
{undatedSessions.length > 0 && <section className="program-calendar-undated"
|
||||
aria-labelledby="undated-program-sessions">
|
||||
<h2 id="undated-program-sessions">Séances sans date</h2>
|
||||
<div>{undatedSessions.map(renderSession)}</div>
|
||||
</section>}
|
||||
</>}
|
||||
</>}
|
||||
</section>
|
||||
}
|
||||
|
|
@ -498,6 +498,7 @@ export function SessionsPage() {
|
|||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
const [sendingPreparation, setSendingPreparation] = useState<string | null>(null)
|
||||
const deleteReturnFocus = useRef<HTMLElement | null>(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 <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
|
||||
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
|
||||
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
|
||||
|
|
@ -636,6 +667,15 @@ export function SessionsPage() {
|
|||
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
|
||||
<div className="session-list">{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 <div className="session-row" key={`${item.collection}-${item.identity}`}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -649,8 +689,21 @@ export function SessionsPage() {
|
|||
: 'Date inconnue'
|
||||
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
|
||||
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
|
||||
<span>{sessionStateLabel(item.collection, item.state)}</span>
|
||||
<span>{item.collection === 'preparations'
|
||||
? `${sessionStateLabel(item.collection, item.editing_state ?? item.state)} · ${deliveryLabel}`
|
||||
: sessionStateLabel(item.collection, item.state)}</span>
|
||||
</button>
|
||||
{item.collection === 'preparations' && item.delivery_state === 'local' && <button
|
||||
type="button"
|
||||
className="android-send-action"
|
||||
aria-label={sendLabel}
|
||||
title={sendLabel}
|
||||
disabled={sendingPreparation !== null}
|
||||
onClick={() => void sendPreparation(item)}
|
||||
><span aria-hidden="true">⇥</span></button>}
|
||||
{item.collection === 'preparations' && item.delivery_state !== 'local' && <span
|
||||
className={`android-delivery-state state-${item.delivery_state}`}
|
||||
>{deliveryLabel}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="trash-action"
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
border: 1px solid var(--surface-1);
|
||||
border-radius: .75rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 3rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto 3rem;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -166,6 +166,35 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
}
|
||||
.trash-action:hover,
|
||||
.trash-action:focus-visible { background: rgb(243 139 168 / 12%); }
|
||||
.android-send-action {
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--surface-1);
|
||||
color: var(--blue);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: 1.25rem;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
.android-send-action:hover, .android-send-action:focus-visible { background: rgb(137 180 250 / 12%); }
|
||||
.android-send-action:disabled { cursor: wait; opacity: .55; }
|
||||
.android-delivery-state {
|
||||
align-items: center;
|
||||
border-left: 1px solid var(--surface-1);
|
||||
color: var(--subtext-0);
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
max-width: 9rem;
|
||||
padding: .55rem .75rem;
|
||||
text-align: center;
|
||||
}
|
||||
.android-delivery-state.state-remote_unknown { color: var(--yellow); }
|
||||
.android-delivery-state.state-acknowledged { color: var(--green); }
|
||||
.program-list, .program-session-list { display: grid; gap: .75rem; }
|
||||
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); }
|
||||
.program-row .session-row-main { grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr)); }
|
||||
|
|
@ -177,11 +206,52 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
.program-execution-state.state-prepared, .program-execution-state.state-in_progress { background: color-mix(in srgb, var(--yellow) 18%, transparent); color: var(--yellow); }
|
||||
.program-execution-state.state-completed { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
|
||||
.program-execution-state.state-deleted { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }
|
||||
.programs-calendar-page { max-width: 100%; }
|
||||
.program-calendar-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); }
|
||||
.program-calendar-empty { max-width: 40rem; padding: clamp(1.25rem, 4vw, 2.5rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
|
||||
.program-calendar-empty > p:not(.eyebrow) { color: var(--subtext-0); line-height: 1.6; }
|
||||
.program-calendar-empty .primary-action { display: inline-flex; align-items: center; text-decoration: none; }
|
||||
.program-calendar-selector { width: min(100%, 28rem); margin: 0 0 1.5rem auto; display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||
.program-calendar-selector select { min-height: 2.7rem; padding: .6rem .75rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .55rem; }
|
||||
.program-calendar-header { margin-bottom: 1.25rem; display: flex; align-items: end; justify-content: space-between; gap: 2rem; }
|
||||
.program-calendar-summary { margin: .7rem 0 0; color: var(--subtext-0); }
|
||||
.program-calendar-legend { margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .45rem; list-style: none; }
|
||||
.program-calendar-legend li, .program-calendar-state-label { padding: .3rem .55rem; border: 1px solid currentColor; border-radius: 999px; font-size: var(--font-size-xs); font-weight: 700; }
|
||||
.program-calendar-state-todo { color: var(--lavender); background: color-mix(in srgb, var(--lavender) 7%, transparent); }
|
||||
.program-calendar-state-prepared { color: var(--yellow); background: color-mix(in srgb, var(--yellow) 8%, transparent); }
|
||||
.program-calendar-state-in_progress { color: var(--sapphire); background: color-mix(in srgb, var(--sapphire) 9%, transparent); }
|
||||
.program-calendar-state-completed { color: var(--green); background: color-mix(in srgb, var(--green) 8%, transparent); }
|
||||
.program-calendar-state-deleted { color: var(--overlay-1); background: color-mix(in srgb, var(--surface-1) 35%, transparent); }
|
||||
.program-calendar-announcement { min-height: 1.5rem; margin: 0 0 .5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||
.program-calendar-scroll { max-width: 100%; overflow-x: auto; scrollbar-width: thin; outline-offset: .35rem; }
|
||||
.program-calendar-scroll:focus-visible { outline: 2px solid var(--lavender); }
|
||||
.program-calendar-grid { min-width: 72rem; display: grid; grid-template-columns: repeat(7, minmax(9rem, 1fr)); border-top: 1px solid var(--surface-1); border-left: 1px solid var(--surface-1); }
|
||||
.program-calendar-day { min-width: 0; min-height: 12rem; padding: .7rem; background: var(--mantle); border-right: 1px solid var(--surface-1); border-bottom: 1px solid var(--surface-1); }
|
||||
.program-calendar-day > header { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: .15rem .5rem; }
|
||||
.program-calendar-day > header span { color: var(--subtext-0); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; }
|
||||
.program-calendar-day > header strong { color: var(--text); font-size: var(--font-size-lg); }
|
||||
.program-calendar-day > header time { grid-column: 1 / -1; color: var(--overlay-1); font-size: var(--font-size-xs); }
|
||||
.program-calendar-rest { margin: 2rem 0 0; color: var(--overlay-1); font-size: var(--font-size-sm); text-align: center; }
|
||||
.program-calendar-day-sessions { margin-top: .75rem; display: grid; gap: .55rem; }
|
||||
.program-calendar-session { padding: .65rem; background: var(--base); border: 1px solid var(--surface-1); border-left: .22rem solid currentColor; border-radius: .55rem; }
|
||||
.program-calendar-session h3 { margin: 0 0 .5rem; color: var(--text); font-size: var(--font-size-sm); line-height: 1.35; }
|
||||
.program-calendar-state-label { display: inline-flex; padding: .2rem .4rem; }
|
||||
.program-calendar-action { margin-top: .55rem; display: grid; gap: .4rem; }
|
||||
.program-calendar-action button, .program-calendar-action a { min-height: 2.35rem; padding: .48rem .6rem; border: 1px solid var(--lavender); border-radius: .45rem; color: var(--text); background: transparent; cursor: pointer; font-size: var(--font-size-xs); text-align: center; text-decoration: none; }
|
||||
.program-calendar-action button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.program-calendar-undated { margin-top: 2rem; padding: clamp(1rem, 3vw, 1.5rem); background: var(--mantle); border: 1px solid var(--surface-1); border-radius: var(--radius); }
|
||||
.program-calendar-undated > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: .75rem; }
|
||||
.import-preview { margin: 1rem 0; padding: 1rem; border: 1px solid var(--blue); border-radius: .75rem; background: var(--mantle); }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 600px) {
|
||||
.program-session-list li { align-items: stretch; flex-direction: column; }
|
||||
.program-row .session-row-main { grid-template-columns: 1fr; }
|
||||
.program-calendar-header { align-items: start; flex-direction: column; gap: 1rem; }
|
||||
.program-calendar-legend { justify-content: flex-start; }
|
||||
.program-calendar-scroll { overflow: visible; }
|
||||
.program-calendar-grid { min-width: 0; display: grid; grid-template-columns: 1fr; gap: .6rem; border: 0; }
|
||||
.program-calendar-day { min-height: 0; border: 1px solid var(--surface-1); border-radius: .65rem; }
|
||||
.program-calendar-rest { margin: 1rem 0; text-align: left; }
|
||||
}
|
||||
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
|
||||
.detail-occurrences li { display: grid; gap: .35rem; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue