fix(programs): recover historical program provenance

This commit is contained in:
fy59 2026-09-21 22:27:56 +02:00
parent 891c09179e
commit dd6d2a180d
11 changed files with 268 additions and 5 deletions

View file

@ -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

View file

@ -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())

View file

@ -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.")

View file

@ -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)
}
}
}

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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("{}")

View file

@ -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=? "