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
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
and Android schema v21. Only exactly acknowledged generations older than the
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) {
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) {
val actual = value.keys().asSequence().toSet()
if (actual != expected) throw SyncGenerationException("invalid $label fields")
@ -112,6 +140,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
fun run(
directory: File,
timeout: Duration = Duration.ofMinutes(5),
afterPeerPublication: (() -> Unit)? = null,
afterPublication: (() -> Unit)? = null,
): ForegroundGenerationResult =
try {
@ -139,17 +168,25 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
)
.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 requestRaw =
awaitCorrelated(File(directory, "request-v1.json"), deadline) { candidate ->
val runId = candidate.optString("run_id")
runId.startsWith("sy_") &&
!repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
}
(resumableGeneration(runId) != null ||
!repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
})
}
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
if (request.getString("android_peer_id") != peer)
@ -168,8 +205,18 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
peer,
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 =
service.capture(
resumableGeneration(
request.getString("run_id"),
request.getString("desktop_peer_id"),
) ?: service.capture(
directory,
request.getString("desktop_peer_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
} catch (_: Exception) { false }
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.Error -> SyncRequestResult.Error(result.message)
}

View file

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

View file

@ -26,6 +26,54 @@ import org.robolectric.annotation.Config
class SyncGenerationServiceTest {
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
fun v19MigrationPreservesDataAndInventsNoGenerationState() {
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
interrupted runs, strictly revalidates desktop-retained historical ACKs, and
shows completion only after both durable peer acknowledgements.
Each explicit foreground generation attempt publishes the Android peer before
emitting one fresh legacy request signal for `trainlog-syncd`. If transport is
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 visualizations | `WEB_DASHBOARD_VISUALIZATIONS_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` |
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_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
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
`next_session.available` placeholder as a durable inventory. A separate bounded
`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
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
adapter on one paired Samsung phone. Three complete bidirectional conversations
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
performed facts. `SyncGenerationServiceTest` and
`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
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;
} 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,
* 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/routes/DashboardPage.tsx',
'../web/src/routes/PlaceholderPage.tsx',
'../web/src/routes/SessionsPage.tsx',
'../web/src/api/sessions.ts',
'../web/src/theme/app.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");
if (value == NULL || yyjson_is_null(value)) {
(void)sqlite3_bind_null(statement, 11);
} else if (!yyjson_is_num(value) || !isfinite(yyjson_get_real(value)) ||
yyjson_get_real(value) <= 0.0 || yyjson_get_real(value) > 2000.0 ||
sqlite3_bind_double(statement, 11, yyjson_get_real(value)) != SQLITE_OK) {
goto invalid;
} else {
double target_weight;
if (!yyjson_is_num(value)) {
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"))) {
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 (sqlite3_prepare_v2(
database->connection,
"UPDATE session_preparations SET source_proposal_id=?1,source_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)",
"SELECT 1 FROM ai_session_draft_imports WHERE draft_id=?1 AND payload_sha256=?2",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, source_proposal, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_bind_text(statement, 3, actual_preparation, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
sqlite3_step(statement) != SQLITE_ROW) {
status = TRAINLOG_STATUS_CONFLICT;
goto rollback;
}
@ -964,7 +966,7 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
statement = NULL;
} else if (sqlite3_prepare_v2(
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,
&statement,
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, 3, now, -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) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;

View file

@ -44,6 +44,16 @@ static bool creation_replay_revision_and_pagination(void) {
"{\"title\":\"Réordonnée\",\"session_type\":\"training\","
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\","
"\"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;
TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"};
char *created = NULL;
@ -70,6 +80,17 @@ static bool creation_replay_revision_and_pagination(void) {
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
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,
NULL,
"",
@ -125,6 +146,20 @@ static bool creation_replay_revision_and_pagination(void) {
free(detail);
detail = NULL;
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,
preparation_id,
revision_id,
@ -133,7 +168,7 @@ static bool creation_replay_revision_and_pagination(void) {
strlen(UPDATE_BODY),
&detail,
&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(replayed);
free(detail);

View file

@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { SessionsPage } from './SessionsPage'
import { commandOccurrence, SessionsPage } from './SessionsPage'
function page(kind: string, items: unknown[]) {
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', () => {
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 () => {
window.history.replaceState(null, '', '/seances')
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {

View file

@ -31,6 +31,42 @@ function emptyInput(): PreparationInput {
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 }: {
initial?: SessionDetail
onSaved: (identity: string) => void
@ -38,9 +74,8 @@ function Editor({ initial, onSaved, onCancel }: {
}) {
const [input, setInput] = useState<PreparationInput>(() => initial ? {
title: initial.title, session_type: initial.session_type, planned_for: initial.planned_for,
notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences.map((value) => ({
...value, entry_id: initial.kind === 'proposal' ? undefined : value.entry_id,
})),
notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences.map((value) =>
editableOccurrence(value, initial.kind === 'preparation')),
...(initial.kind === 'proposal' ? {
source_proposal_id: initial.identity,
source_payload_sha256: initial.source_fingerprint ?? undefined,
@ -78,7 +113,8 @@ function Editor({ initial, onSaved, onCancel }: {
const submit = async (ready: boolean) => {
setSaving(true); setError('')
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.revision_id : undefined)
if (ready) await prepareForAndroid(result.preparation_id, result.revision_id)