From dd6d2a180d73ca61761c64ef499c352fb0b2fd74 Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 21 Sep 2026 22:27:56 +0200 Subject: [PATCH] fix(programs): recover historical program provenance --- CHANGELOG.md | 4 + .../SyncGenerationForegroundCoordinator.kt | 6 ++ .../trainlog/data/TrainlogRepository.kt | 33 ++++++- .../trainlog/data/SleepDiaryRepositoryTest.kt | 89 +++++++++++++++++++ .../data/TrainlogRepositoryDraftTest.kt | 40 +++++++++ docs/android.md | 4 + docs/current_state.md | 4 + docs/sync_exchange.md | 5 ++ docs/tests.md | 5 ++ tests/test_sync_generation_exchange.py | 54 +++++++++++ tools/sync_generation_exchange.py | 29 +++++- 11 files changed, 268 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b1c95..5ddf0e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ enriched only when every pre-existing field and execution identity matches. Android and Web consequently present the completed session and progress from the same persisted identity without fabricating history. + Historical acknowledged deliveries now complete that recovery on the + original session even when the preparation contained planned continuous + activities, and current sleep snapshots can initialize a fresh peer from a + non-root revision without discarding causal identity. - Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one exact 18:00-to-18:00 timestamp projection. Selecting another night now clears the previous entry immediately, adopts only an exact-date result and rejects diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt index 3009c2e..feb60d2 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt @@ -541,6 +541,12 @@ internal class SyncGenerationCoordinator( phase(currentRunId, "cancelled", result = "interrupted") ForegroundGenerationResult.Cancelled(currentRunId) } catch (error: Exception) { + Log.e( + "TrainlogSyncGeneration", + "run_id=${currentRunId ?: "pending"} phase=error " + + "type=${error.javaClass.name} message=${error.message}", + error, + ) if (error.message == "generation retention capacity exhausted") { try { val request = JSONObject(File(directory, "request-v1.json").readText()) diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 554b7f4..4363270 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -841,7 +841,11 @@ class TrainlogRepository( continue } if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision") - if (local == null && parent != null) return SleepDiaryImportResult.Rejected("unknown medication parent") + /* CONTRACT: this companion is a current-state snapshot, not a + * revision log. A fresh peer may therefore seed the current + * revision while retaining an unavailable parent identity; + * subsequent advances must still name the locally current + * revision exactly. */ if (local?.second == 1 && !medication.getBoolean("deleted")) return SleepDiaryImportResult.Rejected("medication resurrection") val dose = if (medication.isNull("default_dose_value")) null else medication.getDouble("default_dose_value") val unit = if (medication.isNull("default_dose_unit")) null else medication.getString("default_dose_unit") @@ -929,8 +933,9 @@ class TrainlogRepository( } if (local != null && parent != local.first) return SleepDiaryImportResult.Rejected("concurrent sleep diary revision") - if (local == null && parent != null) - return SleepDiaryImportResult.Rejected("unknown sleep diary parent") + /* The snapshot may start at a non-root current revision on a + * fresh peer. Preserve its causal parent identity even though + * the historical parent payload is intentionally absent. */ val deleted = item.getBoolean("deleted") if (local?.second == 1 && !deleted) return SleepDiaryImportResult.Rejected("sleep diary resurrection") @@ -4878,7 +4883,27 @@ class TrainlogRepository( val sessionExerciseRowId = exerciseCursor.getLong(0) val recording = exerciseCursor.getString(3) val tracking = exerciseCursor.getString(4) - val exportPlan = readSessionPlan(exerciseCursor, 10) + val storedPlan = readSessionPlan(exerciseCursor, 10) + /* WHY: a delivered continuous occurrence can retain a + * local duration target, but mobile history represents + * its completed fact exclusively through `continuous`. + * CONTRACT: validate that local-only plan without + * inventing a set-based target the Desktop schema + * forbids. INVARIANT: performed duration, speed and + * distance are still exported unchanged below. */ + if (recording == "continuous" && storedPlan != null) { + check( + tracking == "duration" && + exerciseCursor.isNull(9) && + storedPlan.sets == 0 && + storedPlan.reps == null && + storedPlan.durationSeconds in 1..MAX_PLAN_DURATION_SECONDS && + storedPlan.weightKg == null && + storedPlan.loadMode == SessionLoadMode.NONE && + storedPlan.restSeconds == 0, + ) { "Plan continu SQLite incohérent" } + } + val exportPlan = if (recording == "continuous") null else storedPlan val hasPlan = exportPlan != null if (version < 3 && exportPlan != null) { error("Un plan de séance exige l'export mobile V3.") diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt index 8e49047..8e610ea 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt @@ -72,4 +72,93 @@ class SleepDiaryRepositoryTest { assertTrue(repository.listSleepDiary().isEmpty()) } finally { repository.close(); context.deleteDatabase(name) } } + + @Test + fun currentSnapshotSeedsFreshPeerFromNonRootRevisions() { + val sourceName = "sleep-source-${UUID.randomUUID()}.db" + val destinationName = "sleep-destination-${UUID.randomUUID()}.db" + val source = TrainlogRepository(context, sourceName) + val destination = TrainlogRepository(context, destinationName) + try { + val time = "2026-09-21T20:00:00+02:00" + val createdMedication = + source.saveSleepMedication( + SleepMedication("", "", time, time, "Medication", 5.0, "mg", "", "", true), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + val medication = source.listSleepMedications().single() + source.saveSleepMedication( + medication.copy(name = "Medication revised", updatedAt = "2026-09-21T20:05:00+02:00"), + ) + val createdEntry = + source.saveSleepDiary( + SleepDiaryDraft( + nightStartDate = "2026-09-20", + nightEndDate = "2026-09-21", + createdAt = time, + updatedAt = time, + sleepQuality = null, + wakeQuality = null, + dayForm = null, + treatmentAndNotes = "", + events = listOf( + SleepDiaryEvent( + "", + SleepEventType.SLEEP, + "2026-09-20T23:00:00+02:00", + "2026-09-21T07:00:00+02:00", + ), + ), + intakes = listOf( + MedicationIntake( + "", + createdMedication.entryId, + "Medication", + "2026-09-20T22:00:00+02:00", + 5.0, + "mg", + "", + time, + ), + ), + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + val current = source.listSleepDiary().single() + val edited = + source.saveSleepDiary( + SleepDiaryDraft( + entryId = current.entryId, + expectedRevision = createdEntry.revisionId, + nightStartDate = current.nightStartDate, + nightEndDate = current.nightEndDate, + createdAt = current.createdAt, + updatedAt = "2026-09-21T20:10:00+02:00", + sleepQuality = current.sleepQuality, + wakeQuality = current.wakeQuality, + dayForm = SleepQuality.B, + treatmentAndNotes = current.treatmentAndNotes, + events = current.events, + intakes = current.intakes, + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + assertTrue( + source.validateSleepDiary( + current.entryId, + edited.revisionId, + "2026-09-21T20:15:00+02:00", + ) is TrainlogRepository.SaveSleepDiaryResult.Saved, + ) + + assertEquals( + TrainlogRepository.SleepDiaryImportResult.Applied(1, 0), + destination.applySleepDiaryV1Json(source.buildSleepDiaryV1Json()), + ) + assertEquals("Medication revised", destination.listSleepMedications().single().name) + assertEquals(SleepQuality.B, destination.listSleepDiary().single().dayForm) + } finally { + source.close() + destination.close() + context.deleteDatabase(sourceName) + context.deleteDatabase(destinationName) + } + } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index d62c5f3..a5d3b94 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -2695,6 +2695,46 @@ class TrainlogRepositoryDraftTest { } catch (_: IllegalStateException) { } } + @Test + fun completedContinuousPlanKeepsPerformedFactAndExportsWithoutSetTarget() { + val repo = openRepository() + val exercise = createExercise( + repo, + "Marche planifiée", + RecordingMode.CONTINUOUS, + TrackingMode.DURATION, + ) + val plan = SessionExercisePlan(sets = 0, durationSeconds = 600) + assertEquals( + ActiveDraftMutationResult.Saved, + repo.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = exercise, + plan = plan, + continuousDurationSeconds = 540, + ), + ), + ), + ), + ) + val result = repo.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved + + val detail = repo.getSessionDetail(result.sessionId)!! + assertEquals(plan, detail.exercises.single().plan) + val exported = + JSONObject(repo.buildMobileExportV4Json()) + .getJSONArray("sessions") + .getJSONObject(0) + .getJSONArray("exercises") + .getJSONObject(0) + assertTrue(exported.isNull("target")) + assertEquals(540, exported.getJSONObject("continuous").getInt("duration_seconds")) + assertTrue(!exported.getJSONObject("continuous").has("speed_kmh")) + assertTrue(!exported.getJSONObject("continuous").has("distance_km")) + } + @Test fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() { val repo = openRepository() diff --git a/docs/android.md b/docs/android.md index cad170d..5f313db 100644 --- a/docs/android.md +++ b/docs/android.md @@ -47,6 +47,10 @@ active draft and completed session. A legacy stored delivery may receive those two omitted fields later only when its revision, complete prior payload and reserved execution identity match exactly; this repairs provenance without altering performed workout facts. +Acknowledged historical deliveries use the same exact-identity recovery. A +local continuous duration target is validated during generation capture while +the completed duration, speed and distance remain represented by the existing +`continuous` history payload rather than an invented set target. Android schema v23 adds the durable manual-preparation withdrawal ledger. The schema is unchanged by Web proposal withdrawal: the existing durable AI draft diff --git a/docs/current_state.md b/docs/current_state.md index 273629b..afbf50b 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -436,6 +436,10 @@ strict same-delivery enrichment repairs completed sessions produced from the earlier omission without changing workout facts. Android and Web therefore derive the completed count and per-session state from the same execution identity after synchronization and restart. +The historical S1 A delivery was recovered onto its original completed +`session_id`; Android and Web now both report one completed session out of 24. +Generation capture also accepts its valid local continuous duration plans +without projecting them as unsupported set targets. The private Programs rollout migrated desktop schema v25 to v26 and Android schema v23 to v24 after verified backups, then installed the signed diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 1ffa4ab..1ac300f 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -254,6 +254,11 @@ For Program-owned manual preparations, an acknowledged delivery is republished while its matching canonical execution evidence is absent on the PC. This bounded recovery lets Android enrich legacy deliveries with Program provenance and stops automatically when the execution returns through this companion. +The recovery emission retains the delivery's original acknowledged generation +relation and requires exact delivery, revision, execution and Program +identities. A current-state Sleep Diary snapshot may initialize a fresh peer +from a non-root current revision while retaining the unavailable parent +identity for subsequent causal advances. Desktop schema v28 adds terminal `deleted` provenance for a completed session removed through causal history deletion. A retained older diff --git a/docs/tests.md b/docs/tests.md index b9f5ee9..59e3aff 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -15,6 +15,11 @@ removes `crypto.randomUUID()` and proves the HTTP-origin-compatible UUIDv4 path. A real Firefox run through the production C server and `trainlog.perf` creates events, medications and intakes, reloads their durable state, checks a responsive viewport and captures console errors and unhandled rejections. +Program recovery coverage starts from an acknowledged legacy preparation, +repairs the same completed Android `session_id`, publishes one completed +execution and verifies Web `1 / 24` presentation. Related regressions cover +planned continuous activity capture and fresh-peer Sleep Diary bootstrap from +non-root current revisions. `web_analysis` covers empty history, every 7/30/90/all period selector, loaded and unloaded repetitions, continuous duration/distance/speed, explicit diff --git a/tests/test_sync_generation_exchange.py b/tests/test_sync_generation_exchange.py index 6931ee2..11903e4 100644 --- a/tests/test_sync_generation_exchange.py +++ b/tests/test_sync_generation_exchange.py @@ -168,6 +168,60 @@ class GenerationTest(unittest.TestCase): "SELECT acknowledged_at FROM session_preparation_withdrawals", ).fetchone()[0], ) + + def test_acknowledged_program_recovery_keeps_original_generation_relation(self): + delivery = "spd_55555555-5555-4555-8555-555555555555" + preparation = "sp_66666666-6666-4666-8666-666666666666" + revision = "spr_77777777-7777-4777-8777-777777777777" + execution = "se_88888888-8888-4888-8888-888888888888" + program = "pg_99999999-9999-4999-8999-999999999999" + program_session = "pgs_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + original_generation = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + with closing(sqlite3.connect(self.database)) as db: + db.execute("ALTER TABLE session_preparations ADD COLUMN source_program_id TEXT") + db.execute("ALTER TABLE session_preparations ADD COLUMN source_program_session_id TEXT") + db.execute( + "CREATE TABLE program_session_executions(program_session_id TEXT PRIMARY KEY," + "program_id TEXT,session_id TEXT UNIQUE,state TEXT,observed_at TEXT)" + ) + db.execute( + "INSERT INTO session_preparations VALUES(?,?,?,?,?,?)", + (preparation, revision, "acknowledged", None, program, program_session), + ) + db.execute( + "INSERT INTO session_preparation_deliveries VALUES(?,?,?,?,?,?,?,?)", + (delivery, preparation, revision, execution, "acknowledged", + "2026-09-21T12:00:00Z", original_generation, "2026-09-21T12:05:00Z"), + ) + db.commit() + artifacts = (("session-preparations", "trainlog-session-preparations", 2, + "session-preparations-v2.json", False, "controlled.py", ()),) + payload = { + "format": "trainlog-session-preparations", "version": 2, + "generated_at": "2026-09-21T12:10:00Z", "withdrawals": [], + "deliveries": [{ + "delivery_id": delivery, "preparation_id": preparation, + "revision_id": revision, "execution_session_id": execution, + "state": "remote_unknown", "source_program_id": program, + "source_program_session_id": program_session, + }], + } + def exporter(_tool, _extra, output, _snapshot): + output.write_text(json.dumps(payload)) + with mock.patch.object(generation, "ARTIFACTS", artifacts), \ + mock.patch.object(generation, "SUPPORTED", {("trainlog-session-preparations", 2)}), \ + mock.patch.object(generation, "run_export", exporter): + generation.capture_desktop( + self.database, self.root / "program-recovery", PEER_B, RUN, GEN, + ) + with closing(sqlite3.connect(self.database)) as db: + self.assertEqual( + ("acknowledged", original_generation), + db.execute( + "SELECT state,generation_id FROM session_preparation_deliveries " + "WHERE delivery_id=?", (delivery,), + ).fetchone(), + ) def test_substitution_and_transaction_failure_leave_no_consumption(self): _,manifest,checksum=self.capture();published=self.publish() (published/"history.json").write_text("{}") diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index 53268b8..cd54dee 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -430,7 +430,34 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str, (generation, delivery["delivery_id"], delivery["revision_id"]), ) if cursor.rowcount != 1: - raise GenerationError("preparation delivery changed during capture") + # WHY: Program-provenance recovery republishes an + # already acknowledged legacy delivery as + # remote_unknown until its canonical execution comes + # back. CONTRACT: this exception is allowed only for + # the exact immutable delivery/revision/execution and + # Program identities captured in the artifact. + # INVARIANT: no delivery lifecycle or generation + # relation is rewritten by the recovery emission. + recovery = db.execute( + "SELECT 1 FROM session_preparation_deliveries d " + "JOIN session_preparations p ON p.preparation_id=d.preparation_id " + "WHERE d.delivery_id=? AND d.revision_id=? " + "AND d.execution_session_id=? AND d.state='acknowledged' " + "AND p.source_program_id=? AND p.source_program_session_id=? " + "AND NOT EXISTS(SELECT 1 FROM program_session_executions x " + "WHERE x.program_id=p.source_program_id " + "AND x.program_session_id=p.source_program_session_id " + "AND x.session_id=d.execution_session_id)", + ( + delivery["delivery_id"], + delivery["revision_id"], + delivery["execution_session_id"], + delivery.get("source_program_id"), + delivery.get("source_program_session_id"), + ), + ).fetchone() + if recovery is None: + raise GenerationError("preparation delivery changed during capture") for withdrawal in captured_preparations["withdrawals"]: cursor = db.execute( "UPDATE session_preparation_withdrawals SET generation_id=? "