Compare commits

...

10 commits

15 changed files with 284 additions and 30 deletions

View file

@ -7,6 +7,12 @@
explicit proposal derivation, desktop schema v23, Android schema v22 and the explicit proposal derivation, desktop schema v23, Android schema v22 and the
separate `trainlog-session-preparations` V1 generation participant. separate `trainlog-session-preparations` V1 generation participant.
- Completed the controlled Sessions V1 deployment and delivered a six-entry
validation preparation to Android without starting it or replacing an active
draft. Foreground generation retries now emit a fresh daemon request and
resume the exact interrupted generation until both peer halves are durable,
preventing false transport timeouts and duplicate recapture.
- Added compatible non-destructive generation archival on desktop schema v22 - Added compatible non-destructive generation archival on desktop schema v22
and Android schema v21. Only exactly acknowledged generations older than the and Android schema v21. Only exactly acknowledged generations older than the
two active recovery tips leave admission after a verified durable payload two active recovery tips leave admission after a verified durable payload

View file

@ -17,6 +17,34 @@ internal sealed interface ForegroundGenerationResult {
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) { internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
private val service = SyncGenerationService(repository) private val service = SyncGenerationService(repository)
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
).use { cursor ->
if (!cursor.moveToFirst()) null
else {
val captured =
CapturedSyncGeneration(
cursor.getString(0),
runId,
cursor.getString(1),
File(cursor.getString(2)),
)
if (cursor.moveToNext())
throw SyncGenerationException("ambiguous resumable generation")
captured
}
}
}
private fun requireExactKeys(value: JSONObject, expected: Set<String>, label: String) { private fun requireExactKeys(value: JSONObject, expected: Set<String>, label: String) {
val actual = value.keys().asSequence().toSet() val actual = value.keys().asSequence().toSet()
if (actual != expected) throw SyncGenerationException("invalid $label fields") if (actual != expected) throw SyncGenerationException("invalid $label fields")
@ -112,6 +140,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
fun run( fun run(
directory: File, directory: File,
timeout: Duration = Duration.ofMinutes(5), timeout: Duration = Duration.ofMinutes(5),
afterPeerPublication: (() -> Unit)? = null,
afterPublication: (() -> Unit)? = null, afterPublication: (() -> Unit)? = null,
): ForegroundGenerationResult = ): ForegroundGenerationResult =
try { try {
@ -139,17 +168,25 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
) )
.toString(), .toString(),
) )
/* 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
* identity before its caller emits one fresh request signal.
* INVARIANT: a failed attempt's request, run, generations, and
* ACK evidence remain immutable; retry creates new correlation. */
afterPeerPublication?.invoke()
val deadline = System.nanoTime() + timeout.toNanos() val deadline = System.nanoTime() + timeout.toNanos()
val requestRaw = val requestRaw =
awaitCorrelated(File(directory, "request-v1.json"), deadline) { candidate -> awaitCorrelated(File(directory, "request-v1.json"), deadline) { candidate ->
val runId = candidate.optString("run_id") val runId = candidate.optString("run_id")
runId.startsWith("sy_") && runId.startsWith("sy_") &&
!repository.inSyncGenerationTransaction { db -> (resumableGeneration(runId) != null ||
db.rawQuery( !repository.inSyncGenerationTransaction { db ->
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1", db.rawQuery(
arrayOf(runId), "SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
).use { it.moveToFirst() } arrayOf(runId),
} ).use { it.moveToFirst() }
})
} }
val request = JSONObject(requestRaw.toString(Charsets.UTF_8)) val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
if (request.getString("android_peer_id") != peer) if (request.getString("android_peer_id") != peer)
@ -168,8 +205,18 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
peer, peer,
request.getString("desktop_peer_id"), request.getString("desktop_peer_id"),
) )
/* WHY: transport can fail after immutable generation publication.
* CONTRACT: an explicit retry for the same run republishes that
* exact generation and resumes ACK handling; it never recaptures
* mutable application state under an existing identity.
* INVARIANT: a completed inbound ledger makes the run ineligible;
* an acknowledged outbound generation remains resumable only to
* finish its missing inbound half. */
val captured = val captured =
service.capture( resumableGeneration(
request.getString("run_id"),
request.getString("desktop_peer_id"),
) ?: service.capture(
directory, directory,
request.getString("desktop_peer_id"), request.getString("desktop_peer_id"),
request.getString("run_id"), request.getString("run_id"),

View file

@ -54,7 +54,26 @@ internal suspend fun publishBundleAndRequest(
value.opt("version") is Int && value.getInt("version") == 1 && value.opt("enabled") == true value.opt("version") is Int && value.getInt("version") == 1 && value.opt("enabled") == true
} catch (_: Exception) { false } } catch (_: Exception) { false }
if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.") if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.")
return@withContext when (val result = SyncGenerationForegroundCoordinator(repository).run(canonicalExchangeDirectory())) { val opened = exporter.openStorageSnapshot()
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
return@withContext SyncRequestResult.Error("prepare:${opened.message}")
}
val snapshot = (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot
return@withContext when (
val result = SyncGenerationForegroundCoordinator(repository).run(
canonicalExchangeDirectory(),
afterPeerPublication = {
when (val request = requestOutbox.requestSync(snapshot)) {
is SyncRequestResult.Requested -> Unit
SyncRequestResult.Unsupported ->
throw IllegalStateException("Generation synchronization is unsupported.")
is SyncRequestResult.Error -> throw IllegalStateException(request.message)
is SyncRequestResult.Completed ->
throw IllegalStateException("Unexpected completed request signal.")
}
},
)
) {
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId) is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message) is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
} }

View file

@ -103,11 +103,15 @@ class SyncDeploymentConversationTest {
repeat(conversations) { repeat(conversations) {
val coordinator = SyncGenerationForegroundCoordinator(repository) val coordinator = SyncGenerationForegroundCoordinator(repository)
val executor = java.util.concurrent.Executors.newSingleThreadExecutor() val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
var requestSignals = 0
val result = val result =
executor.submit<ForegroundGenerationResult> { executor.submit<ForegroundGenerationResult> {
coordinator.run(transport, Duration.ofSeconds(30)) { coordinator.run(
release?.let { awaitFile(File(it)) } transport,
} Duration.ofSeconds(30),
afterPeerPublication = { requestSignals += 1 },
afterPublication = { release?.let { awaitFile(File(it)) } },
)
} }
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText()) val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
if (suppliedTransport == null) { if (suppliedTransport == null) {
@ -138,6 +142,7 @@ class SyncDeploymentConversationTest {
"conversation ${it + 1}: $completed", "conversation ${it + 1}: $completed",
completed is ForegroundGenerationResult.Completed, completed is ForegroundGenerationResult.Completed,
) )
assertEquals("one fresh request signal per conversation", 1, requestSignals)
var workerOutput = "" var workerOutput = ""
localWorker?.let { worker -> localWorker?.let { worker ->
workerOutput = worker.inputStream.bufferedReader().readText() workerOutput = worker.inputStream.bufferedReader().readText()

View file

@ -26,6 +26,54 @@ import org.robolectric.annotation.Config
class SyncGenerationServiceTest { class SyncGenerationServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext() private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun acknowledgedOutboundRemainsResumableUntilInboundIsTerminal() {
val sourceName = "generation-resume-source-${UUID.randomUUID()}.db"
val destinationName = "generation-resume-destination-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-resume-").toFile()
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
try {
val sourceService = SyncGenerationService(source)
val destinationService = SyncGenerationService(destination)
val runId = "sy_${UUID.randomUUID()}"
val outbound =
sourceService.capture(
java.io.File(root, "source-owned"),
destinationService.peerId(),
runId,
)
val outboundDirectory =
sourceService.publish(outbound, java.io.File(root, "source-objects"))
sourceService.acceptAcknowledgement(
destinationService.consume(outboundDirectory).toByteArray()
)
val coordinator = SyncGenerationForegroundCoordinator(source)
assertEquals(
outbound.generationId,
coordinator.resumableGeneration(runId, destinationService.peerId())?.generationId,
)
val inbound =
destinationService.capture(
java.io.File(root, "destination-owned"),
sourceService.peerId(),
runId,
)
sourceService.consume(
destinationService.publish(inbound, java.io.File(root, "destination-objects"))
)
assertEquals(null, coordinator.resumableGeneration(runId, destinationService.peerId()))
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test @Test
fun v19MigrationPreservesDataAndInventsNoGenerationState() { fun v19MigrationPreservesDataAndInventsNoGenerationState() {
val name = "generation-migration-${UUID.randomUUID()}.db" val name = "generation-migration-${UUID.randomUUID()}.db"

View file

@ -972,3 +972,11 @@ ledger without changing any domain identity. Its full-generation foreground
coordinator waits for correlated objects, tolerates stale objects left by coordinator waits for correlated objects, tolerates stale objects left by
interrupted runs, strictly revalidates desktop-retained historical ACKs, and interrupted runs, strictly revalidates desktop-retained historical ACKs, and
shows completion only after both durable peer acknowledgements. shows completion only after both durable peer acknowledgements.
Each explicit foreground generation attempt publishes the Android peer before
emitting one fresh legacy request signal for `trainlog-syncd`. If transport is
interrupted after generation publication, a later attempt resumes the exact
stored generation instead of recapturing mutable data. An acknowledged Android
generation remains resumable only until the desktop generation for that run is
durably consumed; a fully completed run is then excluded from foreground
selection. This preserves late-ACK recovery without replaying completed runs.

View file

@ -72,7 +72,7 @@ parallel implementation of its rules.
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` | | Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` | | Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` | | Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (isolated software validation; controlled deployment pending) | | Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
| Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` | | Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` | | Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` | | Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
@ -228,6 +228,13 @@ Android schema v22 stores received preparations separately from AI proposals
and the active singleton. An exact correlated ACK is required before delivery and the active singleton. An exact correlated ACK is required before delivery
is shown as acknowledged. is shown as acknowledged.
The controlled deployment preserved the installed Android signing identity and
advanced its private `versionCode` without uninstalling or clearing data. The
validation preparation derived from the six-occurrence proposal remained a
separate pending Android preparation across application and service restart.
Two correlated generation conversations completed without a duplicate,
performed fact, or active-draft replacement.
The Next Session tile no longer treats the frozen Dashboard The Next Session tile no longer treats the frozen Dashboard
`next_session.available` placeholder as a durable inventory. A separate bounded `next_session.available` placeholder as a durable inventory. A separate bounded
`GET /api/v1/prepared-items` Core projection distinguishes imported AI `GET /api/v1/prepared-items` Core projection distinguishes imported AI

View file

@ -817,6 +817,14 @@ for its ACK). Objects retained from an interrupted conversation are ignored
until atomically replaced by matching objects. After both peer ACKs, Android until atomically replaced by matching objects. After both peer ACKs, Android
reports completion directly; it does not enter the legacy receipt wait state. reports completion directly; it does not enter the legacy receipt wait state.
The peer publication precedes one fresh `trainlog-sync-request-v1` signal on
every explicit Android attempt. This wakes the existing user-session daemon;
it is not an ACK and does not alter generation identity. A retry may resume an
exact `captured`, `published`, or `waiting_acknowledgement` generation. It may
also finish the inbound half of an acknowledged outbound generation when no
terminal consumed/rejected inbound ledger row exists for the run. It never
recaptures state under that run identity.
The 2026-09-17 private rollout exercised this path with the production libmtp The 2026-09-17 private rollout exercised this path with the production libmtp
adapter on one paired Samsung phone. Three complete bidirectional conversations adapter on one paired Samsung phone. Three complete bidirectional conversations
passed, including service/application restart and an idempotent replay whose passed, including service/application restart and an idempotent replay whose

View file

@ -10,7 +10,11 @@ built into the canonical Meson binary.
singleton refusal, explicit start, reserved execution identity and absence of singleton refusal, explicit start, reserved execution identity and absence of
performed facts. `SyncGenerationServiceTest` and performed facts. `SyncGenerationServiceTest` and
`SyncDeploymentConversationTest` exercise the real optional artifact through `SyncDeploymentConversationTest` exercise the real optional artifact through
generation validation and 24 bidirectional conversations. generation validation and 24 bidirectional conversations. It also asserts that
every foreground conversation emits exactly one fresh daemon request signal;
the coordinator's production resume predicate preserves an interrupted
generation identity until the counterpart generation has a durable terminal
consumption row.
`SyncDeploymentConversationTest` executes both production generation services `SyncDeploymentConversationTest` executes both production generation services
and reopens Android storage. `test_web_sync_browser.py` adds a real Firefox click and reopens Android storage. `test_web_sync_browser.py` adds a real Firefox click

View file

@ -24,6 +24,12 @@ typedef struct TrainlogWebSessionsPageQuery {
const char *search; const char *search;
} TrainlogWebSessionsPageQuery; } TrainlogWebSessionsPageQuery;
/*
* CONTRACT: every successful JSON-producing function below transfers one
* heap allocation through output_json; the caller owns it and releases it
* with free(). Borrowed input strings need only remain valid for the call.
*/
/* /*
* CONTRACT: database and query strings are borrowed for the call. On success, * CONTRACT: database and query strings are borrowed for the call. On success,
* output_json is heap-owned by the caller and must be released with free(). * output_json is heap-owned by the caller and must be released with free().

View file

@ -67,6 +67,8 @@ if web_assets_enabled
'../web/src/dashboard/dashboardLayout.ts', '../web/src/dashboard/dashboardLayout.ts',
'../web/src/routes/DashboardPage.tsx', '../web/src/routes/DashboardPage.tsx',
'../web/src/routes/PlaceholderPage.tsx', '../web/src/routes/PlaceholderPage.tsx',
'../web/src/routes/SessionsPage.tsx',
'../web/src/api/sessions.ts',
'../web/src/theme/app.css', '../web/src/theme/app.css',
'../web/src/theme/tokens.css', '../web/src/theme/tokens.css',
) )

View file

@ -792,10 +792,16 @@ static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
value = yyjson_obj_get(entry, "target_weight_kg"); value = yyjson_obj_get(entry, "target_weight_kg");
if (value == NULL || yyjson_is_null(value)) { if (value == NULL || yyjson_is_null(value)) {
(void)sqlite3_bind_null(statement, 11); (void)sqlite3_bind_null(statement, 11);
} else if (!yyjson_is_num(value) || !isfinite(yyjson_get_real(value)) || } else {
yyjson_get_real(value) <= 0.0 || yyjson_get_real(value) > 2000.0 || double target_weight;
sqlite3_bind_double(statement, 11, yyjson_get_real(value)) != SQLITE_OK) { if (!yyjson_is_num(value)) {
goto invalid; goto invalid;
}
target_weight = yyjson_get_num(value);
if (!isfinite(target_weight) || target_weight <= 0.0 || target_weight > 2000.0 ||
sqlite3_bind_double(statement, 11, target_weight) != SQLITE_OK) {
goto invalid;
}
} }
if (!bind_nullable_text(statement, 12, yyjson_obj_get(entry, "notes"))) { if (!bind_nullable_text(statement, 12, yyjson_obj_get(entry, "notes"))) {
goto invalid; goto invalid;
@ -923,18 +929,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) { if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) {
if (sqlite3_prepare_v2( if (sqlite3_prepare_v2(
database->connection, database->connection,
"UPDATE session_preparations SET source_proposal_id=?1,source_payload_sha256=?2 " "SELECT 1 FROM ai_session_draft_imports WHERE draft_id=?1 AND payload_sha256=?2",
"WHERE preparation_id=?3 AND EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE "
"i.draft_id=?1 AND i.payload_sha256=?2)",
-1, -1,
&statement, &statement,
NULL) != SQLITE_OK || NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, source_proposal, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 1, source_proposal, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) != sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) !=
SQLITE_OK || SQLITE_OK ||
sqlite3_bind_text(statement, 3, actual_preparation, -1, SQLITE_TRANSIENT) != sqlite3_step(statement) != SQLITE_ROW) {
SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
status = TRAINLOG_STATUS_CONFLICT; status = TRAINLOG_STATUS_CONFLICT;
goto rollback; goto rollback;
} }
@ -964,7 +966,7 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
statement = NULL; statement = NULL;
} else if (sqlite3_prepare_v2( } else if (sqlite3_prepare_v2(
database->connection, database->connection,
"INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',NULL,NULL)", "INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',?5,?6)",
-1, -1,
&statement, &statement,
NULL) != SQLITE_OK || NULL) != SQLITE_OK ||
@ -973,6 +975,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
sqlite3_bind_text(statement, 2, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
(source_proposal == NULL
? sqlite3_bind_null(statement, 5)
: sqlite3_bind_text(statement, 5, source_proposal, -1, SQLITE_TRANSIENT)) !=
SQLITE_OK ||
(source_fingerprint == NULL
? sqlite3_bind_null(statement, 6)
: sqlite3_bind_text(statement, 6, source_fingerprint, -1, SQLITE_TRANSIENT)) !=
SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) { sqlite3_step(statement) != SQLITE_DONE) {
status = TRAINLOG_STATUS_DATABASE_ERROR; status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback; goto rollback;

View file

@ -44,6 +44,16 @@ static bool creation_replay_revision_and_pagination(void) {
"{\"title\":\"Réordonnée\",\"session_type\":\"training\"," "{\"title\":\"Réordonnée\",\"session_type\":\"training\","
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\"," "\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\","
"\"occurrences\":[]}"; "\"occurrences\":[]}";
static const char DERIVED_BODY[] =
"{\"title\":\"Derived\",\"session_type\":\"training\","
"\"planned_for\":\"2026-09-17\",\"notes\":null,\"editing_state\":\"ready\","
"\"occurrences\":[{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"equipment_id\":null,\"load_mode\":\"external\",\"rest_seconds\":90,"
"\"target_sets\":4,\"target_reps\":10,\"target_duration_seconds\":null,"
"\"target_weight_kg\":39,\"notes\":null}],\"source_proposal_id\":"
"\"aid_11111111-1111-4111-8111-111111111111\","
"\"source_payload_sha256\":"
"\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}";
TrainlogDatabase *database = NULL; TrainlogDatabase *database = NULL;
TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"}; TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"};
char *created = NULL; char *created = NULL;
@ -70,6 +80,17 @@ static bool creation_replay_revision_and_pagination(void) {
TRAINLOG_TRACKING_REPS, TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS, TRAINLOG_RECORDING_SETS,
0U) == TRAINLOG_STATUS_OK); 0U) == TRAINLOG_STATUS_OK);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO ai_session_drafts(draft_id,created_at,session_type,title) "
"VALUES('aid_11111111-1111-4111-8111-111111111111','2026-09-18T00:00:00Z',"
"'training','Source');"
"INSERT INTO ai_session_draft_imports VALUES("
"last_insert_rowid(),'aid_11111111-1111-4111-8111-111111111111',"
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
"'2026-09-18T00:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_sessions_save_json(database, CHECK(trainlog_web_sessions_save_json(database,
NULL, NULL,
"", "",
@ -125,6 +146,20 @@ static bool creation_replay_revision_and_pagination(void) {
free(detail); free(detail);
detail = NULL; detail = NULL;
detail_size = 0U; detail_size = 0U;
CHECK(trainlog_web_sessions_save_json(database,
NULL,
"",
"request-derived-1",
DERIVED_BODY,
strlen(DERIVED_BODY),
&detail,
&detail_size) == TRAINLOG_STATUS_OK);
CHECK(scalar(database,
"SELECT COUNT(*) FROM session_preparations WHERE source_proposal_id="
"'aid_11111111-1111-4111-8111-111111111111'") == 1);
free(detail);
detail = NULL;
detail_size = 0U;
CHECK(trainlog_web_sessions_save_json(database, CHECK(trainlog_web_sessions_save_json(database,
preparation_id, preparation_id,
revision_id, revision_id,
@ -133,7 +168,7 @@ static bool creation_replay_revision_and_pagination(void) {
strlen(UPDATE_BODY), strlen(UPDATE_BODY),
&detail, &detail,
&detail_size) == TRAINLOG_STATUS_CONFLICT); &detail_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 2); CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 3);
free(created); free(created);
free(replayed); free(replayed);
free(detail); free(detail);

View file

@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { SessionsPage } from './SessionsPage' import { commandOccurrence, SessionsPage } from './SessionsPage'
function page(kind: string, items: unknown[]) { function page(kind: string, items: unknown[]) {
return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items } return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items }
@ -11,6 +11,19 @@ function response(value: unknown) {
} }
describe('Sessions page', () => { describe('Sessions page', () => {
it('removes presentation-only fields from a preparation command', () => {
const command = commandOccurrence({
exercise_id: 'ex_one', exercise_name: 'Visible name', equipment_id: null,
recording_mode: 'sets', tracking_mode: 'reps', load_mode: 'external', rest_seconds: 90,
target_sets: 3, target_reps: 8, target_duration_seconds: null, target_weight_kg: 42,
notes: null,
})
expect(command).not.toHaveProperty('exercise_name')
expect(command).not.toHaveProperty('recording_mode')
expect(command).not.toHaveProperty('tracking_mode')
expect(command).toMatchObject({ exercise_id: 'ex_one', target_sets: 3, rest_seconds: 90 })
})
it('keeps proposals distinct and opens a stable deep link', async () => { it('keeps proposals distinct and opens a stable deep link', async () => {
window.history.replaceState(null, '', '/seances') window.history.replaceState(null, '', '/seances')
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => { vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {

View file

@ -31,6 +31,42 @@ function emptyInput(): PreparationInput {
editing_state: 'draft', occurrences: [] } editing_state: 'draft', occurrences: [] }
} }
function editableOccurrence(
value: PreparationOccurrence,
preserveIdentity: boolean,
): PreparationOccurrence {
return {
...(preserveIdentity ? { entry_id: value.entry_id } : {}),
exercise_id: value.exercise_id,
exercise_name: value.exercise_name,
equipment_id: value.equipment_id,
recording_mode: value.recording_mode,
tracking_mode: value.tracking_mode,
load_mode: value.load_mode,
rest_seconds: value.rest_seconds,
target_sets: value.target_sets,
target_reps: value.target_reps,
target_duration_seconds: value.target_duration_seconds,
target_weight_kg: value.target_weight_kg,
notes: value.notes,
}
}
export function commandOccurrence(value: PreparationOccurrence): PreparationOccurrence {
return {
...(value.entry_id ? { entry_id: value.entry_id } : {}),
exercise_id: value.exercise_id,
equipment_id: value.equipment_id,
load_mode: value.load_mode,
rest_seconds: value.rest_seconds,
target_sets: value.target_sets,
target_reps: value.target_reps,
target_duration_seconds: value.target_duration_seconds,
target_weight_kg: value.target_weight_kg,
notes: value.notes,
}
}
function Editor({ initial, onSaved, onCancel }: { function Editor({ initial, onSaved, onCancel }: {
initial?: SessionDetail initial?: SessionDetail
onSaved: (identity: string) => void onSaved: (identity: string) => void
@ -38,9 +74,8 @@ function Editor({ initial, onSaved, onCancel }: {
}) { }) {
const [input, setInput] = useState<PreparationInput>(() => initial ? { const [input, setInput] = useState<PreparationInput>(() => initial ? {
title: initial.title, session_type: initial.session_type, planned_for: initial.planned_for, title: initial.title, session_type: initial.session_type, planned_for: initial.planned_for,
notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences.map((value) => ({ notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences.map((value) =>
...value, entry_id: initial.kind === 'proposal' ? undefined : value.entry_id, editableOccurrence(value, initial.kind === 'preparation')),
})),
...(initial.kind === 'proposal' ? { ...(initial.kind === 'proposal' ? {
source_proposal_id: initial.identity, source_proposal_id: initial.identity,
source_payload_sha256: initial.source_fingerprint ?? undefined, source_payload_sha256: initial.source_fingerprint ?? undefined,
@ -78,7 +113,8 @@ function Editor({ initial, onSaved, onCancel }: {
const submit = async (ready: boolean) => { const submit = async (ready: boolean) => {
setSaving(true); setError('') setSaving(true); setError('')
try { try {
const result = await savePreparation({ ...input, editing_state: ready ? 'ready' : 'draft' }, const result = await savePreparation({ ...input, editing_state: ready ? 'ready' : 'draft',
occurrences: input.occurrences.map(commandOccurrence) },
initial?.kind === 'preparation' ? initial.identity : undefined, initial?.kind === 'preparation' ? initial.identity : undefined,
initial?.kind === 'preparation' ? initial.revision_id : undefined) initial?.kind === 'preparation' ? initial.revision_id : undefined)
if (ready) await prepareForAndroid(result.preparation_id, result.revision_id) if (ready) await prepareForAndroid(result.preparation_id, result.revision_id)