fix(programs): recover historical program provenance
This commit is contained in:
parent
891c09179e
commit
dd6d2a180d
11 changed files with 268 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("{}")
|
||||
|
|
|
|||
|
|
@ -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=? "
|
||||
|
|
|
|||
Loading…
Reference in a new issue