Compare commits

..

No commits in common. "trainlog-docs-deep-review-v1" and "main" have entirely different histories.

123 changed files with 502 additions and 5243 deletions

View file

@ -8,13 +8,12 @@ Trainlog consists of:
- a Unix/Linux C17 terminal TUI for durable history, correction, analysis, - a Unix/Linux C17 terminal TUI for durable history, correction, analysis,
visualization, and manual synchronization, using Notcurses as its completed visualization, and manual synchronization, using Notcurses as its completed
active rendering/input backend; active rendering/input backend;
- a loopback-only local Web adapter with implemented Dashboard, Analyse, - a loopback-only local Web adapter whose Dashboard is implemented on the
Programmes, Sessions, and Exercises routes on the 0.1.7 development line; 0.1.2 development branch; its Analyse, Programmes, Sessions, and Exercises
routes remain placeholders pending separate contracts;
- a small user-session PC agent, `trainlog-syncd`, for Android-triggered - a small user-session PC agent, `trainlog-syncd`, for Android-triggered
synchronization; synchronization;
- versioned JSON synchronization artifacts exchanged through configured - versioned JSON synchronization artifacts exchanged over direct MTP.
Bluetooth Classic RFCOMM, direct MTP recovery, or a separate private Drive
workflow.
The desktop SQLite database is the canonical long-term history. The desktop SQLite database is the canonical long-term history.
@ -171,7 +170,7 @@ It is not the canonical analytics store.
The Android UI is driven by exercise metadata, never by exercise-name The Android UI is driven by exercise metadata, never by exercise-name
heuristics. heuristics.
Android local SQLite schema v34 retains the exactly-one durable active-session Android local SQLite schema v24 retains the exactly-one durable active-session
draft introduced in v4. draft introduced in v4.
Every meaningful draft/form mutation is persisted by the repository. Back, Every meaningful draft/form mutation is persisted by the repository. Back,
backgrounding and process death never delete the draft. Home offers explicit backgrounding and process death never delete the draft. Home offers explicit
@ -185,7 +184,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
durable flattened exercise-alias mapping used to resolve retired creator IDs. durable flattened exercise-alias mapping used to resolve retired creator IDs.
Desktop schema v12 owns the same alias compatibility boundary. Later additive Desktop schema v12 owns the same alias compatibility boundary. Later additive
desktop migrations reach v37 and Android migrations reach v34 without changing desktop migrations reach v26 and Android migrations reach v24 without changing
that identity contract. The active that identity contract. The active
completed-session exchange is the separate strict completed-session exchange is the separate strict
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1` `trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
@ -242,16 +241,14 @@ Desktop SQLite schema is versioned with:
PRAGMA user_version; PRAGMA user_version;
``` ```
The current desktop schema is v37. Schema v9 added an occurrence-owned explicit The current desktop schema is v26. Schema v9 added an occurrence-owned explicit
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
flattened exercise aliases; v13-v18 add machine metadata, feedback and flattened exercise aliases; v13-v18 add machine metadata, feedback and
revisions, occurrence tracking snapshots, causal profile state, and AI proposal revisions, occurrence tracking snapshots, causal profile state, and AI proposal
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
generation/ACK ledgers; v22-v24 add generation archival, manual preparations, generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
and durable preparation withdrawal; v25 adds Programs; and v26 additively adds and durable preparation withdrawal; v25 adds Programs; and v26 additively adds
logical Program deletion. Later additive migrations through v37 add Sleep, logical Program deletion. None changes `TRAINLOG_FORMAT_V1`.
heart-rate, Cardio and Program planning state. None changes
`TRAINLOG_FORMAT_V1`.
Every incompatible schema evolution requires an explicit migration and Every incompatible schema evolution requires an explicit migration and
regression coverage. regression coverage.

View file

@ -1,106 +1,5 @@
# Changelog # Changelog
## Unreleased — 0.1.7
- Reconciled the canonical documentation against the `f4ddcfd` development
lineage: current source schemas v37/v34, implemented Web routes and Program
rescheduling, observed package provenance, Sleep row paging, capability
selected exchange, Web packaging requirements and validation limits. The
[documentation review](docs/reviews/documentation_deep_review_v1.md) records
the evidence matrix. This entry changes no runtime behavior or format.
- Show the Web Sleep agenda newest first with one 7, 14, 21, 28, or All
recorded-night selector (7 by default). Sleep reads its full available history
without the Analysis period control; other Analysis sections retain their
period choice. The browser-local 5/10/15 preference migrates to 7/14/21,
and a capped full-history read fails explicitly. Page changes leave the
selected night, editor, statistics, and inclusive PDF range untouched.
Removed only the redundant textual sport list below the Web agenda; factual
blue overlays, the sport legend, and the complete PDF sport appendix remain.
- Corrected the Dashboard/Analysis active-Program next-session projection to
select title, identity, and current date from one pending Program session.
Recorded rescheduling dates replace imported dates, ceded slots are excluded,
and an overdue uncompleted session remains eligible. The read-only projection
does not change Program history, preparation priority, or synchronization.
- Aligned the Web front/back BODY ZONES figures with Android's normalized
Compose Canvas silhouettes. The Web now uses Android's head, neck and 11
region paths per face, while retaining the existing Catppuccin intensity
classes, 30-day session counts, legend, labels and keyboard focus behavior.
No Android, domain, database or synchronization behavior changed.
- Widened the Sleep diary PDF treatment/remarks column from 84 to 180 pt
and raised its text from 4 to 7.2 pt. Width-aware wrapping keeps paragraphs,
separate medication intakes, accents, doses, units, and quantities visible.
Row heights and page count now follow content; exceptional entries continue
on clearly dated pages. The former truncated observations duplicate is gone.
The 18:00 timeline and factual blue sport overlay use one shared PDF geometry;
Sleep, sport, database, and synchronization data are unchanged.
The private PC/Web rollout from `3758875` was verified with a four-page
browser-downloaded PDF; synchronization services retained their Program paths.
- Added a read-only completed-session overlay to the Web Sleep agenda and its
local vector PDF preview/export. Factual session start and end times share
the Sleep row's 18:00 axis in both renderers; blue outlined bands and a
printable paginated listing expose recorded names and times. Missing ends
remain point markers. The report flags sport without a recorded Sleep row,
and export waits for the complete paginated history read. Sleep entries,
session history, database schema, Android, and synchronization contracts
remain unchanged.
The private PC/Web rollout from `e2c07f1` was verified against its served
agenda and an actual downloaded PDF; the phone installation was untouched.
The isolated `trainlog-program-recalage-v1` candidate adds an explicit
preview-and-confirm cascade for unfinished Program sessions. It retains source
and actual dates, stores absolute current dates and ceded-slot state, and
selects a separate Programs V2 Android projection by capability. The source
candidate remains isolated from the stable checkout while its private desktop
and compatible Android build are installed for the coordinated rollout.
Rollout packaging rejects a native build without the embedded Web frontend.
The private rollout preserves parsed V1 Program number types during Android V2
upgrade and raises the bounded local MTP operation budget to 90 seconds and
retained outgoing generation capacity to 16 after interrupted exchanges.
Development opened after the stable v0.1.6 release. This line is restricted to
observed bug fixes, regression fixes, directly related small UI corrections,
documentation corrections, robustness, and associated tests. It does not admit
new features, modules, protocols, or speculative refactors.
- Corrected the Web Program calendar's completed-session placement using the
linked session's actual start date in the browser's history timezone. A
changed day displays both actual and imported dates, and concurrent cards
remain distinct. The Program detail Web API gains a nullable read-only
timestamp field; this changes no history, schema, Android behavior, or sync
artifact.
Durable ordered rescheduling remains outside the stabilized Programs V1
exchange: its strict snapshot has no current date or ceded-slot state.
- Fixed automatic Bluetooth synchronization recovery when RFCOMM disconnects
after `trainlog-syncd` admits Android's arrival request but before the
generation conversation can start. Requests whose durable terminal result
is explicitly transient (`device_unavailable`, `transport_timeout`,
`sync_in_progress`, or interruption) remain eligible after reconnect;
completed requests and business failures remain consumed. Generation, ACK,
causal, schema, MTP, Drive, and manual-sync contracts are unchanged.
- Fixed Android generation coordination after a durable desktop rejection.
The Android producer now retains and reports the correlated `rejected` ACK
but immediately releases its conversation lease instead of waiting for a
reciprocal desktop generation that the rejecting worker cannot publish.
This prevents a rejected stale revision from starving a subsequent Web or
automatic Bluetooth request; rejection, generation, ACK, causal, schema,
and transport contracts are unchanged.
- Preserved the desktop consumer's known Sleep revision rejection diagnostic
through the full-generation worker and classified it as `data_conflict` in
Web run state. A synthetic divergent-final-wake regression keeps the true
conflict rejected; this reporting fix does not reconcile real Sleep data or
turn a rejected generation into a successful exchange.
- Resolved safely comparable concurrent Sleep V2 edits by the last actual
user-edit instant, regardless of device or the event times being recorded.
Complete winning snapshots and both original immutable revisions are kept;
a local winner gains a fresh causal successor for peer convergence. Equal
timestamps, changed entry envelopes, and deletion transitions still reject
instead of guessing. Existing rejected generation ACKs are not rewritten.
## 0.1.6 — 2026-09-25 ## 0.1.6 — 2026-09-25
- Corrected delayed session finalization so a heart-rate capture cannot extend - Corrected delayed session finalization so a heart-rate capture cannot extend

View file

@ -145,20 +145,13 @@ cd ..
meson setup build-web -Dweb=enabled meson setup build-web -Dweb=enabled
meson compile -C build-web meson compile -C build-web
meson test -C build-web --print-errorlogs
./build-web/tui/trainlog --web ./build-web/tui/trainlog --web
``` ```
The Web application listens on `127.0.0.1:8080` by default. Use The Web application listens on `127.0.0.1:8080` by default. Use
`--port <port>` for an explicit alternative. For an existing build directory, `--port <port>` for an explicit alternative. For an existing build directory,
use `meson configure build -Dweb=enabled` instead of running `meson setup` use `meson configure build -Dweb=enabled` instead of running `meson setup`
again. The separate `npm test` run checks Web source; the enabled Meson build again.
registers embedded frontend tests and places its assets in the executable.
The default `web=auto` configuration does not by itself prove embedded assets.
For a compatible service bundle, use the versioned candidate packaging
procedure in [rollout readiness](docs/sync_rollout_readiness.md); copying only
`trainlog` omits its matching synchronization helpers. Restarting a service
does not change the package selected by its launcher.
### Android ### Android
@ -238,7 +231,7 @@ chronological detail in the [`CHANGELOG`](CHANGELOG.md).
| Boundary | Current state | | Boundary | Current state |
| ------------------- | --------------------------------------------------------------------------------------------------------- | | ------------------- | --------------------------------------------------------------------------------------------------------- |
| Product | `0.1.7` bugfix-only development; latest stable release `v0.1.6` | | Product | `0.1.6` stable |
| Desktop persistence | SQLite schema v36 | | Desktop persistence | SQLite schema v36 |
| Android persistence | SQLite schema v33 | | Android persistence | SQLite schema v33 |
| Mobile exchange | V3 active; V1/V2 readable; V4 staged only | | Mobile exchange | V3 active; V1/V2 readable; V4 staged only |

View file

@ -14,7 +14,7 @@ ANDROID_BODY_SHELL=IMPLEMENTED
LOCAL_PERSISTENCE=IMPLEMENTED LOCAL_PERSISTENCE=IMPLEMENTED
DIRECT_STORAGE=IMPLEMENTED DIRECT_STORAGE=IMPLEMENTED
MTP_SYNC=IMPLEMENTED MTP_SYNC=IMPLEMENTED
ANDROID_SCHEMA=34 ANDROID_SCHEMA=33
``` ```
## Visual contract ## Visual contract

View file

@ -44,7 +44,7 @@ val trainlogVersionCode =
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw -> providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE } raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer") ?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
} ?: 8 } ?: 7
/* /*
* WHY: a user may need to finish one preserved draft after a completion bug * WHY: a user may need to finish one preserved draft after a completion bug
@ -87,7 +87,7 @@ android {
targetSdk = 36 targetSdk = 36
versionCode = trainlogVersionCode versionCode = trainlogVersionCode
versionName = "0.1.7" versionName = "0.1.6"
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"") buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"") buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
testInstrumentationRunner = testInstrumentationRunner =

View file

@ -225,7 +225,7 @@ internal class AndroidBackupService(
manifest.getInt("version") == VERSION && manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName && manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") && manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..34 manifest.getInt("schema") in 17..33
) { ) {
"Unsupported backup." "Unsupported backup."
} }
@ -319,7 +319,7 @@ internal class AndroidBackupService(
} }
) )
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..34) { "Unsupported Android schema ${db.version}." } require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
return db.version return db.version
} finally { } finally {
db.close() db.close()

View file

@ -370,7 +370,6 @@ internal class SyncGenerationCoordinator(
ACK_RECOVERY_CAPABILITY, ACK_RECOVERY_CAPABILITY,
"session-preparations-v2", "session-preparations-v2",
"programs-v1", "programs-v1",
"programs-v2",
) )
), ),
) )
@ -607,18 +606,6 @@ internal class SyncGenerationCoordinator(
}, pollTransport, lease) }, pollTransport, lease)
service.acceptAcknowledgement(desktopAck) service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId) phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
/* WHY: a durable desktop rejection closes this Android-produced
* generation, so the desktop worker deliberately cannot publish a
* reciprocal generation for this conversation. CONTRACT: retain
* and validate the rejection ACK, then end this run immediately.
* INVARIANT: a rejected generation never holds the process-wide
* conversation lease while waiting for an impossible reference. */
val desktopAcknowledgement = JSONObject(desktopAck.toString(Charsets.UTF_8))
if (desktopAcknowledgement.getString("result") == "rejected") {
val diagnostic = desktopAcknowledgement.getString("diagnostic")
phase(runId, "desktop_rejected", "desktop-consumption-ack-v1.json", captured.generationId)
return ForegroundGenerationResult.Failed(diagnostic, captured.runId)
}
val referenceRaw = val referenceRaw =
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, { awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
it.optString("run_id") == captured.runId it.optString("run_id") == captured.runId

View file

@ -86,8 +86,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
(ANDROID_KINDS.values + (ANDROID_KINDS.values +
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) + Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) + Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) +
Kind("trainlog-programs", 1, "programs-v1.json", false) + Kind("trainlog-programs", 1, "programs-v1.json", false))
Kind("trainlog-programs", 2, "programs-v2.json", false))
.map { it.format to it.version } .map { it.format to it.version }
.toSet() .toSet()
@ -774,8 +773,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
throw SyncGenerationException("artifact digest mismatch") throw SyncGenerationException("artifact digest mismatch")
artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8) artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8)
} }
if ("programs-v1" in artifacts && "programs-v2" in artifacts)
throw SyncGenerationException("multiple Program revisions in one generation")
return ValidatedGeneration(manifest, sha256(raw), artifacts) return ValidatedGeneration(manifest, sha256(raw), artifacts)
} }
@ -950,10 +947,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
apply("programs-v1") { apply("programs-v1") {
repository.applyProgramsV1Json(checkNotNull(a["programs-v1"])) repository.applyProgramsV1Json(checkNotNull(a["programs-v1"]))
} }
if ("programs-v2" in a)
apply("programs-v2") {
repository.applyProgramsV2Json(checkNotNull(a["programs-v2"]))
}
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) } apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
apply("exercise-profile-state") { apply("exercise-profile-state") {
repository.applyExerciseProfileStateJson( repository.applyExerciseProfileStateJson(

View file

@ -676,9 +676,8 @@ class TrainlogRepository(
} }
} catch (_: RuntimeException) { false } } catch (_: RuntimeException) { false }
/** CONTRACT: one local edit transaction appends an immutable revision and /** CONTRACT: one transaction appends an immutable revision and advances
* advances the guarded tip. Cross-device conflict selection belongs only * the guarded tip. Wall time never resolves a concurrent edit. */
* to the versioned import path after both edit instants are validated. */
fun saveSleepDiary(draft: SleepDiaryDraft): SaveSleepDiaryResult = fun saveSleepDiary(draft: SleepDiaryDraft): SaveSleepDiaryResult =
saveSleepDiary(draft, quickPublicationAt = null) saveSleepDiary(draft, quickPublicationAt = null)
@ -1556,21 +1555,8 @@ class TrainlogRepository(
} }
val remoteDescendsFromLocal = val remoteDescendsFromLocal =
local != null && ancestry?.drop(1)?.contains(local.first) == true local != null && ancestry?.drop(1)?.contains(local.first) == true
var winningLocalRevision: String? = null if (local != null && !remoteDescendsFromLocal && parent != local.first)
if (local != null && !remoteDescendsFromLocal && parent != local.first) { return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
val localWins = sleepConcurrentLocalWins(
db, item, entryId, local.first, wireVersion,
)
if (localWins) {
if (knownOwner != null) {
// A retained losing branch must not generate another
// reconciliation revision when its artifact replays.
unchanged++
continue
}
winningLocalRevision = local.first
}
}
/* The snapshot may start at a non-root current revision on a /* The snapshot may start at a non-root current revision on a
* fresh peer. Preserve its causal parent identity even though * fresh peer. Preserve its causal parent identity even though
* the historical parent payload is intentionally absent. */ * the historical parent payload is intentionally absent. */
@ -1620,77 +1606,21 @@ class TrainlogRepository(
arrayOf(entryId,draft.nightStartDate,draft.nightEndDate,draft.createdAt,draft.updatedAt,revisionId,if (deleted) 1 else 0)) arrayOf(entryId,draft.nightStartDate,draft.nightEndDate,draft.createdAt,draft.updatedAt,revisionId,if (deleted) 1 else 0))
else db.execSQL("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?", else db.execSQL("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
arrayOf(draft.nightStartDate,draft.nightEndDate,draft.updatedAt,revisionId,if (deleted) 1 else 0,entryId,local.first)) arrayOf(draft.nightStartDate,draft.nightEndDate,draft.updatedAt,revisionId,if (deleted) 1 else 0,entryId,local.first))
if (knownOwner == null) { db.execSQL("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
db.execSQL( arrayOf(revisionId,entryId,parent,draft.updatedAt,draft.sleepQuality?.wireValue,draft.wakeQuality?.wireValue,draft.dayForm?.wireValue,draft.treatmentAndNotes))
"INSERT INTO sleep_diary_revisions(revision_id,entry_id," + draft.events.forEach { event -> db.execSQL("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
"parent_revision_id,created_at,sleep_quality,wake_quality,day_form," + arrayOf(revisionId,event.eventId,event.type.wireValue,event.startAt,event.endAt)) }
"treatment_and_notes) VALUES(?,?,?,?,?,?,?,?)", draft.intakes.forEach { intake -> db.execSQL(
arrayOf( "INSERT INTO sleep_medication_intakes(" +
revisionId, entryId, parent, draft.updatedAt, "revision_id,intake_id,medication_id,medication_name,taken_at," +
draft.sleepQuality?.wireValue, draft.wakeQuality?.wireValue, "dose_value,dose_unit,quantity,note,created_at" +
draft.dayForm?.wireValue, draft.treatmentAndNotes, ") VALUES(?,?,?,?,?,?,?,?,?,?)",
), arrayOf<Any?>(revisionId, intake.intakeId, intake.medicationId,
) intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
draft.events.forEach { event -> intake.quantity,
db.execSQL( intake.note, intake.createdAt),
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type," + ) }
"start_at,end_at) VALUES(?,?,?,?,?)",
arrayOf(revisionId, event.eventId, event.type.wireValue,
event.startAt, event.endAt),
)
}
draft.intakes.forEach { intake -> db.execSQL(
"INSERT INTO sleep_medication_intakes(" +
"revision_id,intake_id,medication_id,medication_name,taken_at," +
"dose_value,dose_unit,quantity,note,created_at" +
") VALUES(?,?,?,?,?,?,?,?,?,?)",
arrayOf<Any?>(revisionId, intake.intakeId, intake.medicationId,
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
intake.quantity, intake.note, intake.createdAt),
) }
}
markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at")) markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at"))
if (winningLocalRevision != null) {
val successor = sleepId("slr")
// INVARIANT: this technical successor inherits the latest
// user's edit timestamp and exact snapshot, not the import
// time. Its remote parent makes the peer converge, while
// both original sibling revisions stay immutable.
db.execSQL(
"INSERT INTO sleep_diary_revisions(revision_id,entry_id," +
"parent_revision_id,created_at,sleep_quality,wake_quality,day_form," +
"treatment_and_notes) " +
"SELECT ?,entry_id,?,created_at,sleep_quality,wake_quality,day_form," +
"treatment_and_notes FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(successor, revisionId, winningLocalRevision),
)
db.execSQL(
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type," +
"start_at,end_at) " +
"SELECT ?,event_id,event_type,start_at,end_at FROM sleep_diary_events " +
"WHERE revision_id=?",
arrayOf(successor, winningLocalRevision),
)
db.execSQL(
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id," +
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) " +
"SELECT ?,intake_id,medication_id,medication_name,taken_at,dose_value," +
"dose_unit,quantity,note,created_at FROM sleep_medication_intakes " +
"WHERE revision_id=?",
arrayOf(successor, winningLocalRevision),
)
db.execSQL(
"UPDATE sleep_diary_entries SET updated_at=(SELECT created_at FROM " +
"sleep_diary_revisions WHERE revision_id=?),current_revision_id=? " +
"WHERE entry_id=? AND current_revision_id=?",
arrayOf(winningLocalRevision, successor, entryId, revisionId),
)
db.execSQL(
"UPDATE sleep_diary_publication_state SET validated_revision_id=?," +
"validated_at=? WHERE entry_id=?",
arrayOf(successor, root.getString("generated_at"), entryId),
)
}
advanced++ advanced++
} }
SleepDiaryImportResult.Applied(advanced, unchanged) SleepDiaryImportResult.Applied(advanced, unchanged)
@ -1699,40 +1629,6 @@ class TrainlogRepository(
} }
} }
private fun sleepConcurrentLocalWins(
db: SQLiteDatabase,
item: JSONObject,
entryId: String,
localRevision: String,
wireVersion: Int,
): Boolean {
// WHY: generation/ACK timestamps are transport events. Only revision
// creation times reflect editing actions on the two clients.
// CONTRACT: V2 sibling tips with an unchanged entry envelope and
// deletion state may use last-user-edit-wins. An equal edit instant
// or inconsistent provenance remains an explicit conflict.
if (wireVersion != 2) throw IllegalArgumentException("concurrent sleep diary revision")
val local = db.rawQuery(
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," +
"r.created_at FROM sleep_diary_entries e JOIN sleep_diary_revisions r " +
"ON r.revision_id=e.current_revision_id WHERE e.entry_id=? AND r.revision_id=?",
arrayOf(entryId, localRevision),
).use { cursor ->
if (!cursor.moveToFirst()) null else List(6) { index -> cursor.getString(index) }
} ?: throw IllegalArgumentException("concurrent sleep diary revision")
if (local[0] != item.getString("night_start_date") ||
local[1] != item.getString("night_end_date") ||
local[2] != item.getString("created_at") ||
(local[4] == "1") != item.getBoolean("deleted") || local[3] != local[5]
) throw IllegalArgumentException("concurrent sleep diary revision")
val localAt = OffsetDateTime.parse(local[5]).toInstant()
val remoteAt = OffsetDateTime.parse(item.getString("updated_at")).toInstant()
if (localAt == remoteAt) throw IllegalArgumentException(
"concurrent sleep diary revision has indeterminate edit order",
)
return localAt > remoteAt
}
private fun sleepDiaryRevisionMatches( private fun sleepDiaryRevisionMatches(
db: SQLiteDatabase, db: SQLiteDatabase,
item: JSONObject, item: JSONObject,
@ -11825,39 +11721,7 @@ class TrainlogRepository(
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always * explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
* wins over a later stale snapshot and never touches drafts, preparations, history or catalog. * wins over a later stale snapshot and never touches drafts, preparations, history or catalog.
*/ */
fun applyProgramsV1Json(json: String): ProgramsImportResult = applyProgramsJson(json, 1) fun applyProgramsV1Json(json: String): ProgramsImportResult {
fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2)
private fun legacyProgramDigest(program: JSONObject): String {
// WHY: serializing and reparsing JSONObject changes integral doubles (for
// example 52.0 to 52), which changes a V1 revision's causal digest.
// CONTRACT: discard only V2 fields while retaining the parsed V1 values.
val legacy = JSONObject()
program.keys().forEach { key ->
when (key) {
"revision_sequence" -> Unit
"sessions" -> {
val rows = JSONArray()
val sessions = program.getJSONArray("sessions")
for (index in 0 until sessions.length()) {
val source = sessions.getJSONObject(index)
val row = JSONObject()
source.keys().forEach { sessionKey ->
if (sessionKey !in setOf("position", "current_for", "planning_state"))
row.put(sessionKey, source.get(sessionKey))
}
rows.put(row)
}
legacy.put(key, rows)
}
else -> legacy.put(key, program.get(key))
}
}
return causalDigest(legacy)
}
private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult {
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 || if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.") !jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
val root = try { JSONObject(json) } catch (_: Exception) { val root = try { JSONObject(json) } catch (_: Exception) {
@ -11885,7 +11749,7 @@ class TrainlogRepository(
try { try {
if (!root.exact("format", "version", "generated_at", "programs", "deletions") || if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int || root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
root.getInt("version") != documentVersion || !timestamp(root.opt("generated_at")) || root.getInt("version") != 1 || !timestamp(root.opt("generated_at")) ||
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray) root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
throw ProgramsImportInvalid("Unsupported Programs document.") throw ProgramsImportInvalid("Unsupported Programs document.")
val programs = root.getJSONArray("programs") val programs = root.getJSONArray("programs")
@ -11912,16 +11776,11 @@ class TrainlogRepository(
for (programIndex in 0 until programs.length()) { for (programIndex in 0 until programs.length()) {
val program = programs.optJSONObject(programIndex) val program = programs.optJSONObject(programIndex)
?: throw ProgramsImportInvalid("Invalid program.") ?: throw ProgramsImportInvalid("Invalid program.")
val programKeysValid = if (documentVersion == 2) program.exact( if (!program.exact(
"program_id", "revision_id", "revision_sequence", "title", "note", "state", "start_date",
"end_date", "created_at", "updated_at", "source_format", "source_version",
"source_payload_sha256", "sessions",
) else program.exact(
"program_id", "revision_id", "title", "note", "state", "start_date", "program_id", "revision_id", "title", "note", "state", "start_date",
"end_date", "created_at", "updated_at", "source_format", "source_version", "end_date", "created_at", "updated_at", "source_format", "source_version",
"source_payload_sha256", "sessions", "source_payload_sha256", "sessions",
) ) ||
if (!programKeysValid ||
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) || !bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) || !bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") || !nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
@ -11931,33 +11790,17 @@ class TrainlogRepository(
(program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true || (program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
program.opt("sessions") !is JSONArray) program.opt("sessions") !is JSONArray)
throw ProgramsImportInvalid("Invalid program metadata.") throw ProgramsImportInvalid("Invalid program metadata.")
if (documentVersion == 2 && !integer(program.opt("revision_sequence"), 0, Int.MAX_VALUE))
throw ProgramsImportInvalid("Invalid Program revision sequence.")
val sessions = program.getJSONArray("sessions") val sessions = program.getJSONArray("sessions")
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.") if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
for (sessionIndex in 0 until sessions.length()) { for (sessionIndex in 0 until sessions.length()) {
val session = sessions.optJSONObject(sessionIndex) val session = sessions.optJSONObject(sessionIndex)
?: throw ProgramsImportInvalid("Invalid program session.") ?: throw ProgramsImportInvalid("Invalid program session.")
val validKeys = if (documentVersion == 2) session.exact( if (!session.exact("program_session_id", "title", "session_type", "planned_for", "note", "occurrences") ||
"program_session_id", "position", "title", "session_type", "planned_for",
"current_for", "planning_state", "note", "occurrences",
) else session.exact(
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences",
)
if (!validKeys ||
!bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) || !bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) ||
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") || !bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) || !date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
session.opt("occurrences") !is JSONArray) session.opt("occurrences") !is JSONArray)
throw ProgramsImportInvalid("Invalid program session metadata.") throw ProgramsImportInvalid("Invalid program session metadata.")
if (documentVersion == 2 &&
(!integer(session.opt("position"), 0, 255) || session.getInt("position") != sessionIndex ||
!date(session.opt("current_for")) ||
session.optString("planning_state") !in setOf("active", "ceded") ||
(session.optString("planning_state") == "ceded" && !session.isNull("current_for")) ||
(session.optString("planning_state") == "active" &&
session.isNull("current_for") && !session.isNull("planned_for"))))
throw ProgramsImportInvalid("Invalid Program planning state.")
val occurrences = session.getJSONArray("occurrences") val occurrences = session.getJSONArray("occurrences")
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.") if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
val entryIds = mutableSetOf<String>() val entryIds = mutableSetOf<String>()
@ -11983,12 +11826,6 @@ class TrainlogRepository(
} }
} }
data class ExistingProjection(
val revisionId: String,
val digest: String,
val version: Int,
val sequence: Long,
)
val db = database.writableDatabase val db = database.writableDatabase
db.beginTransaction() db.beginTransaction()
try { try {
@ -12021,76 +11858,28 @@ class TrainlogRepository(
.use { it.moveToFirst() } .use { it.moveToFirst() }
if (tombstoned) { skipped++; continue } if (tombstoned) { skipped++; continue }
val digest = causalDigest(program) val digest = causalDigest(program)
val existing = db.rawQuery( val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id))
"SELECT revision_id,snapshot_sha256,projection_version,revision_sequence " + .use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) }
"FROM synced_programs WHERE program_id=?", if (existing?.first == program.getString("revision_id")) {
arrayOf(id), if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.")
).use { cursor -> skipped++
if (!cursor.moveToFirst()) null else ExistingProjection( continue
cursor.getString(0), cursor.getString(1), cursor.getInt(2), cursor.getLong(3),
)
}
val sequence = if (documentVersion == 2) program.getLong("revision_sequence") else 0L
// WHY: a later V1 artifact cannot erase current dates or a
// ceded slot after this phone has accepted Programs V2.
if (existing != null && documentVersion < existing.version)
throw ProgramsImportInvalid("Programs V2 capability required.")
if (existing != null && documentVersion == 2 &&
(sequence < existing.sequence ||
(sequence == existing.sequence && existing.version == 2 &&
existing.revisionId != program.getString("revision_id"))))
throw ProgramsImportInvalid("Stale Program revision sequence.")
if (existing?.revisionId == program.getString("revision_id")) {
if (existing.digest == digest) { skipped++; continue }
if (documentVersion != 2 || existing.version != 1 ||
existing.digest != legacyProgramDigest(program))
throw ProgramsImportInvalid("Program revision identity conflict.")
}
if (documentVersion == 2) {
val activeProgramSession = db.rawQuery(
"SELECT source_program_session_id FROM active_session_draft " +
"WHERE id=1 AND source_program_id=?",
arrayOf(id),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
if (activeProgramSession != null) {
val newSessions = program.getJSONArray("sessions")
for (sessionIndex in 0 until newSessions.length()) {
val candidate = newSessions.getJSONObject(sessionIndex)
if (candidate.getString("program_session_id") == activeProgramSession) {
val existingPlan = db.rawQuery(
"SELECT current_for,planning_state FROM synced_program_sessions " +
"WHERE program_session_id=?",
arrayOf(activeProgramSession),
).use { cursor ->
if (!cursor.moveToFirst()) null else
(if (cursor.isNull(0)) null else cursor.getString(0)) to cursor.getString(1)
}
if (existingPlan != null &&
(existingPlan.first != candidate.optStringOrNull("current_for") ||
existingPlan.second != candidate.getString("planning_state")))
throw ProgramsImportInvalid("Active Program draft blocks reschedule.")
}
}
}
} }
db.delete("synced_programs", "program_id=?", arrayOf(id)) db.delete("synced_programs", "program_id=?", arrayOf(id))
db.execSQL( db.execSQL(
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"), arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"),
program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"), program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"), program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
program.getInt("source_version"), program.getString("source_payload_sha256"), digest, program.getInt("source_version"), program.getString("source_payload_sha256"), digest),
documentVersion, sequence),
) )
val sessions = program.getJSONArray("sessions") val sessions = program.getJSONArray("sessions")
for (sessionIndex in 0 until sessions.length()) { for (sessionIndex in 0 until sessions.length()) {
val session = sessions.getJSONObject(sessionIndex) val session = sessions.getJSONObject(sessionIndex)
val sessionId = session.getString("program_session_id") val sessionId = session.getString("program_session_id")
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?,?,?)", arrayOf<Any?>( db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?)", arrayOf<Any?>(
sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"), sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"),
session.optStringOrNull("planned_for"), session.optStringOrNull("note"), session.optStringOrNull("planned_for"), session.optStringOrNull("note")))
if (documentVersion == 2) session.optStringOrNull("current_for") else session.optStringOrNull("planned_for"),
if (documentVersion == 2) session.getString("planning_state") else "active"))
val entries = session.getJSONArray("occurrences") val entries = session.getJSONArray("occurrences")
for (entryIndex in 0 until entries.length()) { for (entryIndex in 0 until entries.length()) {
val entry = entries.getJSONObject(entryIndex) val entry = entries.getJSONObject(entryIndex)
@ -12159,7 +11948,7 @@ class TrainlogRepository(
} ?: return null } ?: return null
val sessions = mutableListOf<SyncedProgramSession>() val sessions = mutableListOf<SyncedProgramSession>()
db.rawQuery( db.rawQuery(
"SELECT program_session_id,title,session_type,planned_for,note,current_for,planning_state " + "SELECT program_session_id,title,session_type,planned_for,note " +
"FROM synced_program_sessions WHERE program_id=? " + "FROM synced_program_sessions WHERE program_id=? " +
"ORDER BY position,program_session_id", "ORDER BY position,program_session_id",
arrayOf(programId), arrayOf(programId),
@ -12202,8 +11991,6 @@ class TrainlogRepository(
sessionCursor.getString(1), sessionCursor.getString(1),
sessionCursor.getString(2), sessionCursor.getString(2),
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3), if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
if (sessionCursor.isNull(5)) null else sessionCursor.getString(5),
sessionCursor.getString(6),
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4), if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
execution.first, execution.first,
execution.second, execution.second,
@ -12269,8 +12056,7 @@ class TrainlogRepository(
val available = db.rawQuery( val available = db.rawQuery(
"SELECT s.session_type FROM synced_program_sessions s " + "SELECT s.session_type FROM synced_program_sessions s " +
"JOIN synced_programs p ON p.program_id=s.program_id " + "JOIN synced_programs p ON p.program_id=s.program_id " +
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active' " + "WHERE p.program_id=? AND s.program_session_id=? AND p.state='active'",
"AND s.planning_state='active'",
arrayOf(programId, programSessionId), arrayOf(programId, programSessionId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
?: return StartProgramSessionResult.NotAvailable ?: return StartProgramSessionResult.NotAvailable
@ -12564,7 +12350,7 @@ private class TrainlogDatabaseHelper(
appContext, appContext,
databaseName, databaseName,
null, null,
34, 33,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -12918,34 +12704,6 @@ private class TrainlogDatabaseHelper(
createSleepImmutabilityTriggers(db) createSleepImmutabilityTriggers(db)
version = 33 version = 33
} }
if (version < 34 && newVersion >= 34) {
/* WHY: V1 retains the source date; V2 needs an independent current
* target and a durable ceded-slot state. INVARIANT: no local draft
* or completed workout is rewritten by the projection migration. */
if (!tableHasColumn(db, "synced_programs", "projection_version")) {
db.execSQL(
"ALTER TABLE synced_programs ADD COLUMN projection_version INTEGER NOT NULL " +
"DEFAULT 1 CHECK(projection_version IN(1,2))",
)
}
if (!tableHasColumn(db, "synced_programs", "revision_sequence")) {
db.execSQL(
"ALTER TABLE synced_programs ADD COLUMN revision_sequence INTEGER NOT NULL " +
"DEFAULT 0 CHECK(revision_sequence>=0)",
)
}
if (!tableHasColumn(db, "synced_program_sessions", "current_for")) {
db.execSQL("ALTER TABLE synced_program_sessions ADD COLUMN current_for TEXT")
db.execSQL("UPDATE synced_program_sessions SET current_for=planned_for")
}
if (!tableHasColumn(db, "synced_program_sessions", "planning_state")) {
db.execSQL(
"ALTER TABLE synced_program_sessions ADD COLUMN planning_state TEXT NOT NULL " +
"DEFAULT 'active' CHECK(planning_state IN('active','ceded'))",
)
}
version = 34
}
if (version != newVersion) { if (version != newVersion) {
error( error(
@ -12962,9 +12720,7 @@ private class TrainlogDatabaseHelper(
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')), note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
source_format TEXT NOT NULL, source_version INTEGER NOT NULL, source_format TEXT NOT NULL, source_version INTEGER NOT NULL,
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL, source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL
projection_version INTEGER NOT NULL DEFAULT 1 CHECK(projection_version IN(1,2)),
revision_sequence INTEGER NOT NULL DEFAULT 0 CHECK(revision_sequence>=0)
)""".trimIndent(), )""".trimIndent(),
) )
db.execSQL( db.execSQL(
@ -12973,9 +12729,7 @@ private class TrainlogDatabaseHelper(
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE, program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL, position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')), session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
planned_for TEXT, note TEXT, current_for TEXT, planned_for TEXT, note TEXT, UNIQUE(program_id,position)
planning_state TEXT NOT NULL DEFAULT 'active' CHECK(planning_state IN('active','ceded')),
UNIQUE(program_id,position)
)""".trimIndent(), )""".trimIndent(),
) )
db.execSQL( db.execSQL(

View file

@ -25,8 +25,6 @@ data class SyncedProgramSession(
val title: String, val title: String,
val sessionType: String, val sessionType: String,
val plannedFor: String?, val plannedFor: String?,
val currentFor: String?,
val planningState: String,
val note: String?, val note: String?,
val executionState: ProgramSessionExecutionState, val executionState: ProgramSessionExecutionState,
val executionSessionId: String?, val executionSessionId: String?,

View file

@ -118,12 +118,7 @@ fun ProgramDetailScreen(
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) } program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(it) } program.note?.let { TrainlogInfo(it) }
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED } val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
val ceded = program.sessions.count { it.planningState == "ceded" } TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
TrainlogInfo(
"$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}" +
if (ceded > 0) " · $ceded créneau(x) cédé(s)" else "",
if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted,
)
} }
message?.let { TrainlogInfo(it) } message?.let { TrainlogInfo(it) }
program.sessions.forEachIndexed { index, session -> program.sessions.forEachIndexed { index, session ->
@ -150,16 +145,12 @@ fun ProgramDetailScreen(
color = LocalTrainlogColors.current.text, color = LocalTrainlogColors.current.text,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
ProgramStateBadge( ProgramStateBadge(strings.getString(stateLabel), completed)
if (session.planningState == "ceded" && !completed) "Créneau cédé"
else strings.getString(stateLabel),
completed,
)
} }
TrainlogInfo( TrainlogInfo(
listOfNotNull( listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training), strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
session.currentFor?.let { strings.getString(R.string.planned_for, formatDate(it)) }, session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size), strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
).joinToString(" · "), ).joinToString(" · "),
) )
@ -173,7 +164,7 @@ fun ProgramDetailScreen(
Modifier.testTag("program-session-toggle-${session.programSessionId}"), Modifier.testTag("program-session-toggle-${session.programSessionId}"),
) )
when (session.executionState) { when (session.executionState) {
ProgramSessionExecutionState.TODO -> if (session.planningState == "active") TrainlogButton( ProgramSessionExecutionState.TODO -> TrainlogButton(
strings.getString(R.string.program_session_start), strings.getString(R.string.program_session_start),
{ start(session.programSessionId) }, { start(session.programSessionId) },
Modifier.testTag("program-session-start-${session.programSessionId}"), Modifier.testTag("program-session-start-${session.programSessionId}"),

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() } TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(34, scalar(db, "PRAGMA user_version")) assertEquals(33, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) { for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(34, scalar(db, "PRAGMA user_version")) assertEquals(33, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and /* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */ * exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(34, scalar(db, "PRAGMA user_version")) assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf( assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"), scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
reopened.close() reopened.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(34, scalar(db, "PRAGMA user_version")) assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
assertEquals("ok", text(db, "PRAGMA integrity_check")) assertEquals("ok", text(db, "PRAGMA integrity_check"))
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->

View file

@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
context.getDatabasePath(name).path, context.getDatabasePath(name).path,
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(34, it.version) } ).use { assertEquals(33, it.version) }
} finally { } finally {
repository.close() repository.close()
context.deleteDatabase(name) context.deleteDatabase(name)

View file

@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
context.getDatabasePath(name).path, context.getDatabasePath(name).path,
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(34, it.version) } ).use { assertEquals(33, it.version) }
} finally { } finally {
repository.close() repository.close()
context.deleteDatabase(name) context.deleteDatabase(name)

View file

@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { db -> ).use { db ->
assertEquals(34, db.version) assertEquals(33, db.version)
db.rawQuery( db.rawQuery(
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions", "SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
null, null,

View file

@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { db -> ).use { db ->
assertEquals(34, db.version) assertEquals(33, db.version)
db.rawQuery( db.rawQuery(
"SELECT session_type,session_kind FROM sessions", "SELECT session_type,session_kind FROM sessions",
null, null,

View file

@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { db -> ).use { db ->
assertEquals(34, db.version) assertEquals(33, db.version)
db.rawQuery( db.rawQuery(
"SELECT COUNT(*) FROM exercises WHERE " + "SELECT COUNT(*) FROM exercises WHERE " +
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " + "exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +

View file

@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest {
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { db -> ).use { db ->
assertEquals(34, db.version) assertEquals(33, db.version)
db.rawQuery( db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
"'cardio_guidance_runs','cardio_guidance_phases'," + "'cardio_guidance_runs','cardio_guidance_phases'," +

View file

@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest {
migrated.listSleepDiary() migrated.listSleepDiary()
migrated.close() migrated.close()
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(34, scalar(db, "PRAGMA user_version")) assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals( assertEquals(
1, 1,
scalar( scalar(

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor -> db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(34, cursor.getInt(0)) assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0))
} }
db.rawQuery("SELECT id FROM exercises", null).use { cursor -> db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -70,91 +70,6 @@ class ProgramsProjectionRepositoryTest {
.put("deletions", deletions) .put("deletions", deletions)
.toString() .toString()
private fun artifactV2(
program: JSONObject,
sequence: Int = 0,
currentFor: String = "2026-10-01",
): String {
program.put("revision_sequence", sequence)
val session = program.getJSONArray("sessions").getJSONObject(0)
session.put("position", 0)
session.put("current_for", currentFor)
session.put("planning_state", "active")
return JSONObject()
.put("format", "trainlog-programs")
.put("version", 2)
.put("generated_at", "2026-09-18T12:00:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
}
@Test
fun v2UpgradePreservesCurrentDateAndRejectsLegacyDowngrade() {
val name = "program-v2-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
)
val upgraded = program()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV2Json(artifactV2(upgraded)),
)
assertEquals("2026-10-01", repository.getSyncedProgram(upgraded.getString("program_id"))!!
.sessions.single().currentFor)
val stale = artifactV2(program())
val newer = program().put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV2Json(artifactV2(newer, 1, "2026-10-02")),
)
assertTrue(repository.applyProgramsV2Json(stale) is ProgramsImportResult.Invalid)
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
.sessions.single().currentFor)
assertTrue(repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
is ProgramsImportResult.Invalid)
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
.sessions.single().currentFor)
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
assertEquals("2026-10-02", repository.getSyncedProgram(program().getString("program_id"))!!
.sessions.single().currentFor)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun v2UpgradeKeepsV1DigestForIntegralDecimalWeights() {
val name = "program-decimal-upgrade-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
// The desktop JSON exporter writes SQLite REAL values as 40.0.
// JSONObject's own serialization writes the same value as 40.
val v1 = artifact(JSONArray().put(program()))
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
val v2 = artifactV2(program())
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
assertTrue(v1.contains("\"target_weight_kg\":40.0"))
assertTrue(v2.contains("\"target_weight_kg\":40.0"))
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV1Json(v1))
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV2Json(v2))
assertEquals("2026-10-01", repository.getSyncedProgram(program().getString("program_id"))!!
.sessions.single().currentFor)
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV2Json(v2))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
private fun preparationArtifact( private fun preparationArtifact(
includeProgramProvenance: Boolean = true, includeProgramProvenance: Boolean = true,
deliveryState: String = "pending", deliveryState: String = "pending",
@ -296,12 +211,6 @@ class ProgramsProjectionRepositoryTest {
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(programId, active.sourceProgramId) assertEquals(programId, active.sourceProgramId)
assertEquals(programSessionId, active.sourceProgramSessionId) assertEquals(programSessionId, active.sourceProgramSessionId)
val changedPlan = program().apply {
put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
}
assertTrue(repository.applyProgramsV2Json(artifactV2(changedPlan))
is ProgramsImportResult.Invalid)
assertEquals(null, repository.getSyncedProgram(programId)!!.sessions.single().currentFor)
val performed = active.copy( val performed = active.copy(
exercises = active.exercises.map { occurrence -> exercises = active.exercises.map { occurrence ->
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0))) occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
@ -309,12 +218,6 @@ class ProgramsProjectionRepositoryTest {
) )
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed)) assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved) assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV2Json(artifactV2(changedPlan)),
)
assertEquals("2026-10-01", repository.getSyncedProgram(programId)!!
.sessions.single().currentFor)
assertEquals( assertEquals(
ProgramSessionExecutionState.COMPLETED, ProgramSessionExecutionState.COMPLETED,
repository.getSyncedProgram(programId)!!.sessions.single().executionState, repository.getSyncedProgram(programId)!!.sessions.single().executionState,
@ -335,8 +238,6 @@ class ProgramsProjectionRepositoryTest {
try { try {
val detail = repository.listSyncedPrograms().single() val detail = repository.listSyncedPrograms().single()
assertEquals(1, detail.sessionCount) assertEquals(1, detail.sessionCount)
assertEquals("2026-10-01", repository.getSyncedProgram(detail.programId)!!
.sessions.single().currentFor)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None) assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
assertEquals(1, repository.listSessions().size) assertEquals(1, repository.listSessions().size)
} finally { } finally {

View file

@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
repository = TrainlogRepository(context, name) repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size) assertEquals(1, repository.listPreparedSessions().size)
repository.close() repository.close()
assertEquals("34", scalarText(name, "PRAGMA user_version")) assertEquals("33", scalarText(name, "PRAGMA user_version"))
assertEquals( assertEquals(
"0", "0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -13,7 +13,6 @@ import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID import java.util.UUID
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertThrows import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -26,147 +25,6 @@ import org.robolectric.annotation.Config
class SleepDiaryRepositoryTest { class SleepDiaryRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext() private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun concurrentV2SleepEditsChooseLatestUserEditAndConverge() {
for (localNewer in listOf(true, false)) {
val sourceName = "sleep-lww-source-${UUID.randomUUID()}.db"
val destinationName = "sleep-lww-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
try {
val initial = source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-24T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = listOf(
SleepDiaryEvent(
"", SleepEventType.SLEEP,
"2026-10-24T23:00:00+02:00",
"2026-10-25T07:00:00+01:00",
),
),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(source.validateSleepDiary(
initial.entryId,
initial.revisionId,
"2026-10-25T08:00:00+01:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
destination.applySleepDiaryV1Json(source.buildSleepDiaryV1Json()),
)
fun edit(repository: TrainlogRepository, at: String, wakeAt: String): String {
val current = repository.listSleepDiary().single()
val saved = repository.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = current.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = at,
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = current.events.map { it.copy(endAt = wakeAt) },
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(repository.validateSleepDiary(
current.entryId, saved.revisionId, at,
) is TrainlogRepository.SaveSleepDiaryResult.Saved)
return saved.revisionId
}
val localAt = if (localNewer) "2026-10-25T10:00:00+01:00"
else "2026-10-25T09:00:00+01:00"
val remoteAt = if (localNewer) "2026-10-25T09:00:00+01:00"
else "2026-10-25T10:00:00+01:00"
val localRevision = edit(destination, localAt, "2026-10-25T06:00:00+01:00")
val remoteRevision = edit(source, remoteAt, "2026-10-25T07:30:00+01:00")
val remoteSnapshot = source.buildSleepDiaryV1Json()
val tiedSnapshot = org.json.JSONObject(remoteSnapshot)
val tiedEntry = tiedSnapshot.getJSONArray("entries").getJSONObject(0)
val tiedRevision = "slr_${UUID.randomUUID()}"
tiedEntry.put("revision_id", tiedRevision)
tiedEntry.put("updated_at", localAt)
tiedEntry.put(
"ancestry",
org.json.JSONArray().put(tiedRevision).put(initial.revisionId),
)
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Rejected(
"concurrent sleep diary revision has indeterminate edit order",
),
destination.applySleepDiaryV1Json(tiedSnapshot.toString()),
)
assertEquals(localRevision, destination.listSleepDiary().single().revisionId)
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
destination.applySleepDiaryV1Json(remoteSnapshot),
)
val winner = destination.listSleepDiary().single()
assertEquals(
if (localNewer) "2026-10-25T06:00:00+01:00"
else "2026-10-25T07:30:00+01:00",
winner.events.single().endAt,
)
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(0, 1),
destination.applySleepDiaryV1Json(remoteSnapshot),
)
val reconciled = destination.buildSleepDiaryV1Json()
if (localNewer) {
assertNotEquals(localRevision, winner.revisionId)
assertEquals(
remoteRevision,
org.json.JSONObject(reconciled).getJSONArray("entries")
.getJSONObject(0).getString("parent_revision_id"),
)
} else {
assertEquals(remoteRevision, winner.revisionId)
}
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(
if (localNewer) 1 else 0, if (localNewer) 0 else 1,
),
source.applySleepDiaryV1Json(reconciled),
)
assertEquals(winner.revisionId, source.listSleepDiary().single().revisionId)
val retained = SQLiteDatabase.openDatabase(
context.getDatabasePath(destinationName).path,
null,
SQLiteDatabase.OPEN_READONLY,
)
try {
assertEquals(2, retained.rawQuery(
"SELECT COUNT(*) FROM sleep_diary_revisions " +
"WHERE revision_id IN (?,?)",
arrayOf(localRevision, remoteRevision),
).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getInt(0)
})
} finally {
retained.close()
}
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
}
}
}
@Test fun createEditConflictDeleteAndReopen() { @Test fun createEditConflictDeleteAndReopen() {
val name = "sleep-${UUID.randomUUID()}.db" val name = "sleep-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name) var repository = TrainlogRepository(context, name)
@ -245,7 +103,7 @@ class SleepDiaryRepositoryTest {
SQLiteDatabase.OPEN_READWRITE, SQLiteDatabase.OPEN_READWRITE,
) )
try { try {
assertEquals(34, raw.version) assertEquals(33, raw.version)
assertThrows(SQLiteException::class.java) { assertThrows(SQLiteException::class.java) {
raw.execSQL( raw.execSQL(
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?", "UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",

View file

@ -4,7 +4,6 @@ import android.content.Context
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.security.MessageDigest
import java.time.Duration import java.time.Duration
import java.util.UUID import java.util.UUID
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
@ -55,109 +54,6 @@ class SyncConversationOwnershipTest {
) )
} }
private fun canonical(value: Any?): String =
when (value) {
null,
JSONObject.NULL -> "null"
is JSONObject ->
value.keys().asSequence().toList().sorted().joinToString(",", "{", "}") {
JSONObject.quote(it) + ":" + canonical(value.get(it))
}
is JSONArray ->
(0 until value.length()).joinToString(",", "[", "]") { canonical(value.get(it)) }
is String -> JSONObject.quote(value)
is Boolean,
is Int,
is Long -> value.toString()
else -> error("unsupported test ACK value")
}
private fun rejectedDesktopAcknowledgement(
runId: String,
generationId: String,
producerPeerId: String,
consumerPeerId: String,
manifestSha256: String,
): String {
/* This fixture signs the exact wire ACK because the production
* validator must persist it before the coordinator may release its
* lease; an unsigned rejection would only test malformed-input logic. */
val acknowledgement =
JSONObject()
.put("format", "trainlog-sync-ack")
.put("version", 1)
.put("ack_id", "ack_${generationId.removePrefix("gen_")}")
.put("run_id", runId)
.put("generation_id", generationId)
.put("producer_peer_id", producerPeerId)
.put("consumer_peer_id", consumerPeerId)
.put("manifest_sha256", manifestSha256)
.put("result", "rejected")
.put("durability", "sqlite-commit-rejection")
.put("consumed_at", "2026-09-28T12:00:00+02:00")
.put("diagnostic", "concurrent sleep diary revision")
val digest = MessageDigest.getInstance("SHA-256")
.digest(canonical(acknowledgement).toByteArray())
.joinToString("") { "%02x".format(it) }
return acknowledgement.put("payload_sha256", digest).toString()
}
@Test
fun rejectedDesktopAcknowledgementEndsConversationAndReleasesBackgroundLease() {
val databaseName = "generation-rejection-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-rejection-").toFile()
val repository = TrainlogRepository(context, databaseName)
val arbiter = SyncConversationArbiter()
val referencePublished = CountDownLatch(1)
val desktopPeer = "peer_${UUID.randomUUID()}"
val runId = "sy_${UUID.randomUUID()}"
try {
val service = SyncGenerationService(repository)
request(root, runId, service.peerId(), desktopPeer)
archiveAcknowledgements(root, runId, service.peerId(), desktopPeer)
val coordinator =
SyncGenerationCoordinator(
repository,
arbiter = arbiter,
trace = SyncGenerationTrace { _, phase, _, _, _ ->
if (phase == "generation_reference_published") referencePublished.countDown()
},
)
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
try {
val active = executor.submit<ForegroundGenerationResult> {
coordinator.run(root, Duration.ofSeconds(5))
}
assertTrue(referencePublished.await(3, TimeUnit.SECONDS))
val reference = JSONObject(File(root, "android-generation-v1.json").readText())
File(root, "desktop-consumption-ack-v1.json").writeText(
rejectedDesktopAcknowledgement(
runId,
reference.getString("generation_id"),
service.peerId(),
desktopPeer,
reference.getString("manifest_sha256"),
)
)
val result = active.get(2, TimeUnit.SECONDS)
assertEquals(
ForegroundGenerationResult.Failed("concurrent sleep diary revision", runId),
result,
)
assertTrue(
"The rejected run must release the lease for its next request.",
coordinator.run(root, Duration.ofMillis(100)) !is ForegroundGenerationResult.Busy,
)
} finally {
executor.shutdownNow()
}
} finally {
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
@Test @Test
fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() { fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() {
val databaseName = "generation-ownership-${UUID.randomUUID()}.db" val databaseName = "generation-ownership-${UUID.randomUUID()}.db"

View file

@ -878,7 +878,7 @@ class SyncGenerationServiceTest {
.use { db -> .use { db ->
db.rawQuery("PRAGMA user_version", null).use { db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())
assertEquals(34, it.getInt(0)) assertEquals(33, it.getInt(0))
} }
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())

View file

@ -2382,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(34, cursor.getInt(0)) assertEquals(33, cursor.getInt(0))
} }
db.rawQuery( db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2509,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(34, cursor.getInt(0)) assertEquals(33, cursor.getInt(0))
} }
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())

View file

@ -19,8 +19,6 @@ evidence and chronology.
existing-artifact synchronization boundary. existing-artifact synchronization boundary.
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume, - [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
history and Android-delivery workflow. history and Android-delivery workflow.
- [Sleep Diary guide](sleep_diary.md) explains capture, agenda row paging,
factual sport and heart-rate displays, and independent PDF range selection.
- [Program format V1](program_format_v1.md) owns the strict desktop planning - [Program format V1](program_format_v1.md) owns the strict desktop planning
import contract and program lifecycle used by the Sessions Programs subtab. import contract and program lifecycle used by the Sessions Programs subtab.
@ -82,9 +80,6 @@ evidence and chronology.
- [Review records](reviews/) retain historical audits, incidents, checkpoints, - [Review records](reviews/) retain historical audits, incidents, checkpoints,
and validation evidence. Their status and paths are historical. and validation evidence. Their status and paths are historical.
- [Documentation deep review V1](reviews/documentation_deep_review_v1.md)
records the audited 0.1.7 source baseline, corrected claims, evidence limits,
and coverage matrix.
- [Programs presentation / Android delete evidence](reviews/programs_presentation_android_delete_v1_evidence.md) - [Programs presentation / Android delete evidence](reviews/programs_presentation_android_delete_v1_evidence.md)
records the implemented, not-deployed validation checkpoint and real-browser records the implemented, not-deployed validation checkpoint and real-browser
screenshot evidence. screenshot evidence.

View file

@ -1,21 +1,5 @@
# Android application # Android application
## Schema v34: read-only Programs V2 projection
The audited Program rescheduling development line additively stores a projection
version and revision sequence on synchronized Programs, plus `current_for` and
`planning_state` on synchronized Program sessions. The original `planned_for`
and all local completed sessions and drafts remain separate. Existing V1 rows
hydrate as active with their original date. Android still has no Program editor.
Programs V2 replaces one Program snapshot transactionally. A lower revision
sequence or a later Programs V1 snapshot cannot overwrite accepted V2 planning.
If the singleton active draft refers to a session whose current date or ceded
state changes, V2 import rejects the generation; it does not alter the draft.
The Program screen uses current dates for unfinished sessions and does not offer
Start on a ceded slot. The private Program rollout records a compatible v34
Android installation; this source snapshot alone does not prove another device.
## Sleep Diary V1 and active V2 companion ## Sleep Diary V1 and active V2 companion
Android schema v26 additively mirrors the Sleep Diary revision/event model for Android schema v26 additively mirrors the Sleep Diary revision/event model for

View file

@ -3,13 +3,12 @@
## Synchronization transport boundary ## Synchronization transport boundary
Generation capture, validation, causal import, publication and ACK acceptance Generation capture, validation, causal import, publication and ACK acceptance
are transport-neutral. `sync_peer_worker.py` owns that conversation. The paired are transport-neutral. `sync_peer_worker.py` owns that conversation; direct MTP
private automatic path selects authenticated Bluetooth first and direct MTP as and `sync_drive_transport.py` only move its bounded bytes. Automatic selection
wired recovery. The separately configured legacy USB/Drive workflow probes the probes the expected MTP peer before preferring USB. A successful USB run is
expected MTP peer before selecting USB. A successful USB run is committed committed independently, then mirrored to Drive best-effort; Drive failure can
independently, then mirrored to Drive best-effort; Drive failure cannot reopen never reopen its SQLite transactions. If MTP is unavailable, the same worker
its SQLite transactions. If MTP is unavailable in that workflow, the same runs over the Drive byte adapter.
worker runs over the Drive byte adapter. No transport carries a SQLite file.
Android mirrors that ownership: one `SyncGenerationCoordinator` is called by Android mirrors that ownership: one `SyncGenerationCoordinator` is called by
SyncScreen, the visible USB foreground service, and the private-folder Drive SyncScreen, the visible USB foreground service, and the private-folder Drive
@ -43,7 +42,7 @@ Android field client
Android shared storage Android shared storage
Documents/Trainlog Documents/Trainlog
| |
| Bluetooth RFCOMM primary; direct MTP recovery | direct MTP
v v
shared desktop sync engine shared desktop sync engine
/ \ / \
@ -359,16 +358,11 @@ SVG was selected over Canvas because the bounded series has very few elements
and vector sharpness/responsive scaling matter more than high-volume drawing. and vector sharpness/responsive scaling matter more than high-volume drawing.
All colours are read from the live Catppuccin CSS variables. All colours are read from the live Catppuccin CSS variables.
The muscle figure is a code-native SVG whose geometry is ported from the The muscle figure is an original code-native SVG. Canonical localized mappings
normalized Compose Canvas in Android `HomeScreen.kt`: `drawOval()` for the are: `chest` to bilateral front chest; `back` to rear upper back and bilateral
head, `neckPath()` for the neck, and `bodyRegions()` for 11 paths on each lats; `shoulders` to bilateral front/rear shoulders; `arms` to bilateral
face. The SVG viewBox uses Android fractions multiplied by 100 and scales to front/rear arms; `core` to the front trunk; `glutes` to bilateral rear glutes;
a portrait ratio based on Android's 236dp tall two-panel Home layout. `thighs` and `calves` to bilateral front/rear legs. Aggregate zones
Canonical localized mappings are: `chest` to
bilateral front chest; `back` to the contiguous rear trunk; `shoulders` to
bilateral front/rear shoulders; `arms` to bilateral front/rear arms; `core` to
the front trunk; `glutes` to bilateral rear glutes; `thighs` and `calves` to
bilateral front/rear legs. Aggregate zones
`full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and `full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and
remain in the complete text list. Visual levels are a deterministic ratio to remain in the complete text list. Visual levels are a deterministic ratio to
the maximum `session_count` in the current snapshot: zero, up to one third, up the maximum `session_count` in the current snapshot: zero, up to one third, up
@ -525,14 +519,12 @@ silently rendered as unclassified.
### Desktop ### Desktop
Desktop SQLite schema v37 is canonical long-term history in the audited Desktop SQLite schema v26 is canonical long-term history. Its additive v25 ->
development source. The additive v37 migration stores absolute Program current v26 migration adds terminal Program logical deletion and its durable replay,
dates, ceded slots and revision ordering. The earlier v25 -> v26 migration generation, and acknowledgement ledger without deleting imported definitions,
added terminal Program logical deletion and its durable replay, generation and derived preparations, or workout facts. Android schema v24 maintains the
acknowledgement ledger without deleting imported definitions, derived separate read-only synchronized Programs projection; the optional staged
preparations or workout facts. Android schema v34 extends the read-only `trainlog-programs` V1 companion does not replace mobile V3 or change
Programs projection with capability-selected V2 planning state; v24 introduced
its original V1 projection. Neither Programs companion replaces mobile V3 or changes
`TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable `TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable
manual-preparation withdrawal identity without deleting revisions, deliveries manual-preparation withdrawal identity without deleting revisions, deliveries
or workout facts. Its additive v18 -> or workout facts. Its additive v18 ->
@ -592,7 +584,7 @@ exercise_body_zone_sync
### Android ### Android
Android has an independent local SQLite schema, v34 in the audited source. Completed and Android has an independent local SQLite schema, currently v14. Completed and
draft MAX values use one-to-one `max_results` and `draft_max_results` rows; draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
resuming a completed Test max records its stable source session in the one resuming a completed Test max records its stable source session in the one
durable draft. durable draft.

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="52" viewBox="0 0 960 52" role="img" aria-labelledby="title desc"> <svg xmlns="http://www.w3.org/2000/svg" width="960" height="52" viewBox="0 0 960 52" role="img" aria-labelledby="title desc">
<title id="title">Trainlog technology badges</title> <title id="title">Trainlog technology badges</title>
<desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.7.</desc> <desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.6.</desc>
<style> <style>
.label { font: 600 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #cdd6f4; dominant-baseline: middle; text-anchor: middle; } .label { font: 600 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #cdd6f4; dominant-baseline: middle; text-anchor: middle; }
</style> </style>
@ -32,6 +32,6 @@
<g> <g>
<rect x="816" y="4" width="144" height="44" rx="12" fill="#1e1e2e" stroke="#fab387" stroke-width="2"/> <rect x="816" y="4" width="144" height="44" rx="12" fill="#1e1e2e" stroke="#fab387" stroke-width="2"/>
<circle cx="840" cy="26" r="6" fill="#fab387"/> <circle cx="840" cy="26" r="6" fill="#fab387"/>
<text class="label" x="896" y="27">0.1.7 DEV</text> <text class="label" x="896" y="27">v0.1.6</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,42 +1,5 @@
# Current implementation state # Current implementation state
## Audited development line and private installation
This snapshot audits `trainlog-sleep-single-selector-weeks-v1` at `f4ddcfd`
on 2026-10-02. It includes the Program projection fix `8995843`, the body
figure Web change `c2935b8`, and the Sleep PDF, sport and paging changes by
ancestry. A later documentation-only commit does not imply a new runtime
package. The uncommitted Dashboard layout work in the separate
`trainlog-dashboard-polish-v1` worktree is outside this snapshot.
The private daily installation runs the ordered Program rescheduling rollout
from `trainlog-program-recalage-v1` at `3a4ec34`, with desktop schema v37,
Android schema v34, and the capability-selected `trainlog-programs` V2
projection. The observed PC/Web launcher points to the
`sleep-single-selector-weeks-v1-f4ddcfd` package; synchronization agents
retain the compatible Program
rollout bundle. The previous PC/Web bundle from
`trainlog-sleep-pdf-treatments-space-v1` at `3758875` remains available for
rollback. The served Web uses Android Compose Canvas head, neck, and 22 body
paths on Dashboard muscle distribution, Analysis overview, and Analysis body
distribution. Firefox captures of all three served views show complete figures
at the intended aspect ratio, with the existing color scale, legend, and focus
interaction. The candidate geometry was compared with reconstructed Android
source paths; no Android runtime screenshot was available because no device or
emulator was connected. No Android, schema, domain, or synchronization change
accompanied this Web rollout.
Firefox downloaded a four-page PDF from the served Web. The earlier PDF layout
validation rendered and inspected every page: its 65 individual medication
lines match its summary, its remarks remain visible, and no extracted word
exceeds the page margins. After the body figure rollout, a fresh PDF export
again contained the treatments and remarks sections; actual sport markers
remained blue in Web, and a recorded start time matched the PDF. The earlier
PDF inspection also confirmed black-outlined blue sport bands. A separate
synthetic set of 7, 14, 21, and 30 nights in French and English, plus long
continuations, was rendered page by page and checked in grayscale. Rollout
packaging requires a Meson `-Dweb=enabled` build with embedded frontend tests.
Full-generation synchronization uses one generation/ACK business engine across Full-generation synchronization uses one generation/ACK business engine across
versioned transports. On the paired 0.1.4 deployment, automatic local selection versioned transports. On the paired 0.1.4 deployment, automatic local selection
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
@ -57,25 +20,12 @@ retained. The desktop service installer retires the exact obsolete private
ExecStart override that omitted required runtime paths and installs both ExecStart override that omitted required runtime paths and installs both
`trainlog-syncd` and the BlueZ RFCOMM agent. `trainlog-syncd` and the BlueZ RFCOMM agent.
The daemon's durable arrival ledger distinguishes admission from terminal
consumption. If RFCOMM disappears after an Android request is admitted but
before the generation conversation can complete, an explicitly transient
terminal result leaves that same stable request eligible on reconnect. Success
and non-transient business failures remain consumed, while every retry receives
a fresh run identity and revalidates the existing idempotent generation/ACK
evidence.
Android synchronization paths share the same concurrency-guarded generation Android synchronization paths share the same concurrency-guarded generation
coordinator as SyncScreen. That coordinator has one process-wide ownership coordinator as SyncScreen. That coordinator has one process-wide ownership
arbiter: explicit foreground intent has priority over automatic background arbiter: explicit foreground intent has priority over automatic background
resumption, while a foreground service may remain alive without owning the resumption, while a foreground service may remain alive without owning the
conversation. An automatic run yields cooperatively at safe boundaries; its conversation. An automatic run yields cooperatively at safe boundaries; its
durable generation is retained for a correlated handoff rather than deleted. durable generation is retained for a correlated handoff rather than deleted.
A correlated desktop `rejected` ACK is also terminal for the Android producer:
it is retained as durable rejection evidence, then releases the conversation
lease immediately because no reciprocal desktop generation can follow that
rejection. This keeps an explicit Web request or a later automatic Bluetooth
request from being starved by an impossible post-rejection wait.
Direct-MTP publication now has an explicit Android visibility boundary. Direct-MTP publication now has an explicit Android visibility boundary.
Durable immutable generation files are submitted to MediaProvider and all scan Durable immutable generation files are submitted to MediaProvider and all scan
@ -83,7 +33,7 @@ callbacks complete before the mutable generation reference is published; the
reference and Android ACK use the same confirmation. Bounded diagnostic logs reference and Android ACK use the same confirmation. Bounded diagnostic logs
contain only run/generation identities, artifact names, phases and results. contain only run/generation identities, artifact names, phases and results.
The desktop worker performs lightweight local correlation checks continuously The desktop worker performs lightweight local correlation checks continuously
but opens a full MTP pull no more than once every three seconds. A 90-second but opens a full MTP pull no more than once every three seconds. A 30-second
individual adapter budget is distinct from the overall conversation deadline, individual adapter budget is distinct from the overall conversation deadline,
so an absent correlated generation reports a protocol wait timeout while an so an absent correlated generation reports a protocol wait timeout while an
actually blocked adapter reports `transport_timeout`. actually blocked adapter reports `transport_timeout`.
@ -178,11 +128,11 @@ parallel implementation of its rules.
| Boundary | Current state | | Boundary | Current state |
|---|---| |---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | source schema v37; v37 adds Program current dates, ceded slots and revision ordering; v36 makes Sleep revision payload append-only; v35 adds intake quantity | | Desktop SQLite | schema v36; v36 makes Sleep revision payload tables append-only; v35 gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
| Android SQLite | source schema v34; v34 adds the read-only Programs V2 projection; v33 makes Sleep revision payload append-only; v32 adds intake quantity and quick publication | | Android SQLite | schema v33; v33 makes Sleep revision payload tables append-only; v32 gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only | | Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.7` bugfix-only development, synchronized across Android and desktop; latest stable release: `v0.1.6` | | Trainlog product version | `0.1.6` stable, synchronized across Android and desktop; latest stable release: `v0.1.6` |
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces | | Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
| AI history export | `TRAINLOG_AI_EXPORT_V1` active | | AI history export | `TRAINLOG_AI_EXPORT_V1` active |
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` | | AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
@ -200,7 +150,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` |
| Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. Distinct concurrent V2 edits with comparable user-edit instants retain the complete latest snapshot and both immutable histories; ambiguous ordering and unsafe deletion/envelope changes still reject. | | Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. |
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) | | Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) | | Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) | | Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
@ -227,7 +177,7 @@ parallel implementation of its rules.
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. | | 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK | | Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
| 0.1.6 validation | `TRAINLOG_0_1_6_VALIDATION_V1=PASS`: native Meson 114/114 and Clang ASan/UBSan 114/114; Android 317 tests (312 passed, five documented optional skips), debug APK and signed release APK; Web 32 files / 209 tests plus typecheck/build; JSON/import, changed-C formatting and diff checks pass. | | 0.1.6 validation | `TRAINLOG_0_1_6_VALIDATION_V1=PASS`: native Meson 114/114 and Clang ASan/UBSan 114/114; Android 317 tests (312 passed, five documented optional skips), debug APK and signed release APK; Web 32 files / 209 tests plus typecheck/build; JSON/import, changed-C formatting and diff checks pass. |
| Current operational cursor | 0.1.7 stabilization is open after immutable stable v0.1.6. Only observed bug fixes, regression fixes, directly related small UI corrections, documentation corrections, robustness, and associated tests are in scope. | | Current operational cursor | Stable v0.1.6 closes the Web refinement, Sleep timeline, synchronization recovery and factual session-finalization cycle; subsequent stabilization opens as 0.1.7 after the immutable release tag. |
| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. | | Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. |
| Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. | | Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. |
| Sleep revision collision recovery | `TRAINLOG_SLEEP_REVISION_COLLISION_RECOVERY_V1=PASS`: one unpublished divergent desktop tip was re-identified without payload loss, the immutable Android identity was restored exactly, and a fresh successor retained the non-conflicting union. The original generation was consumed with a full SQLite ACK; the real 24/09 session, Sleep facts, cardio samples, timeline, feedback, and Program provenance are visible on Desktop/Web. Schema v36/v33 and importer payload checks prevent recurrence. | | Sleep revision collision recovery | `TRAINLOG_SLEEP_REVISION_COLLISION_RECOVERY_V1=PASS`: one unpublished divergent desktop tip was re-identified without payload loss, the immutable Android identity was restored exactly, and a fresh successor retained the non-conflicting union. The original generation was consumed with a full SQLite ACK; the real 24/09 session, Sleep facts, cardio samples, timeline, feedback, and Program provenance are visible on Desktop/Web. Schema v36/v33 and importer payload checks prevent recurrence. |
@ -243,38 +193,13 @@ parallel implementation of its rules.
Desktop and Android schema numbers are independent. Neither changes the frozen Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract. Trainlog JSON V1 contract.
The active Sleep V2 view reads the complete available Sleep history independently The active Sleep V2 delta keeps the Web agenda period-owned: every returned
of the Analysis period used by other sections. Every returned night remains night has one row on the shared 18:00-to-18:00 geometry. Selecting a row is
available on the shared 18:00-to-18:00 geometry. Sleep has one agenda display
selector: the newest nights first, seven recorded nights per page by default,
with 7, 14, 21, 28, and All choices. These are row counts, not calendar windows.
The browser-local choice survives reloads; old 5/10/15 choices migrate to
7/14/21. At the server's 3660-night read cap, the Web reports that completeness
cannot be established instead of presenting a truncated agenda as All. Paging
changes only the visible rows; PDF selection, statistics, and the current editor
remain based on the complete loaded history. Selecting a row is
presentation-only and atomically drives the detail dates, editor content, and presentation-only and atomically drives the detail dates, editor content, and
full-width heart-rate detail; it performs no database mutation. PDF preview full-width heart-rate detail; it performs no database mutation. PDF preview
and download instead share an explicit inclusive range over night start dates, and download instead share an explicit inclusive range over night start dates,
initialized to the loaded history bounds and independent of detail selection. initialized to the loaded agenda bounds and independent of detail selection. Its
The completed-sport overlay reads factual `sessions.started_at` and `ended_at` rise/fall overlays are descriptive relative measured
from desktop history through a read-only, paginated Web endpoint.
Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for
Sleep, medication, and sport in both Web and vector PDF. Valid sport intervals
intersect `[start,end)` and are clipped only for display; missing or invalid
ends are point markers without invented duration. The Web keeps a separate
blue lane with accessible labels and tooltips; the redundant textual Web sport
list is removed. Preview and download wait for the
complete sport read and use the same selected sessions. The PDF uses a 180 pt treatment/remarks column with 7.2 pt text,
width-aware wrapping, content-driven row heights, and dated continuation pages
for exceptional notes. Each selected intake and remark is printed once without
a fixed character limit. The shorter 430 pt timeline shares one coordinate
mapping across hour ticks, grid, sleep markers, medication markers, and blue
sport bands. The PDF prints blue
outlined bands and paginated names, factual times, and durations, marking
sessions without a corresponding Sleep row as `sleep not recorded`. Sport
creates no Sleep event and changes no sleep total, session, schema, or sync
contract. The HR detail's rise/fall overlays are descriptive relative measured
variation only (five-minute trailing reference excluding the current 60-second variation only (five-minute trailing reference excluding the current 60-second
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
are not sleep events, sleep stages, causes, readiness, or medical inference. are not sleep events, sleep stages, causes, readiness, or medical inference.
@ -511,7 +436,7 @@ catalogue state. Built-in identities cannot be retired, equipment associations
are presented read-only, and profile edits affect future use only: occurrence are presented read-only, and profile edits affect future use only: occurrence
snapshots, performed sets, feedback and MAX history are never rewritten. A snapshots, performed sets, feedback and MAX history are never rewritten. A
successful mutation changes only the canonical desktop database; the existing successful mutation changes only the canonical desktop database; the existing
configured generation synchronization later carries the existing USB-priority, Drive mirror/fallback synchronization later carries the existing
catalogue/profile/BODY ZONES/causal artifacts. No Web mutation invokes sync. catalogue/profile/BODY ZONES/causal artifacts. No Web mutation invokes sync.
The calendar uses the existing typed Core services `fetchAllPrograms(active)`, The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
@ -519,21 +444,9 @@ The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
or execution state. With no active Program it shows an empty state and or execution state. With no active Program it shows an empty state and
navigation to Sessions administration; with one it selects it automatically; navigation to Sessions administration; with one it selects it automatically;
with several it offers a non-persisted selector. Its continuous Monday–Sunday with several it offers a non-persisted selector. Its continuous Monday–Sunday
weeks derive from Program bounds, each unfinished session's current date weeks derive from Program bounds and real `planned_for` dates, do not invent
(`current_for` when present, otherwise imported `planned_for`), and the actual rest-day sessions, and retain undated sessions in a separate section. It shows
`sessions.started_at` instant for linked completed executions, presented on the all five Core execution states; only `todo` offers Prepare. Each Prepare result
same browser-local civil day as Web session history. The Program detail Web API
adds the nullable read-only `execution_started_at` projection field; the
Programs V2 projection carries the current dates and ceded-slot state to
capable Android peers without changing the strict Programs V1 artifact. A completed
session moved from its imported date shows both dates without changing history
or Program identity. Several cards may share a day; rest days contain no
invented session. A completed execution with no valid history timestamp stays
undated rather than appearing to have occurred on its planned day. Unfinished
sessions show their current dates; ceded unfinished slots remain visible but
are excluded from next-session selection and preparation, and are not counted
as performed. It shows all five Core execution states; only eligible `todo`
offers Prepare. Each Prepare result
is followed by an authoritative Core reread and may expose the returned is followed by an authoritative Core reread and may expose the returned
preparation link. This calendar adds no Android delivery, start, or execution preparation link. This calendar adds no Android delivery, start, or execution
behavior. behavior.
@ -601,7 +514,6 @@ unchanged at ten pre-existing errors: one `NewApi`, one
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches` `LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
diagnostics. This UI tranche adds no lint error and changes no lint policy. diagnostics. This UI tranche adds no lint error and changes no lint policy.
The stable Android v0.1.6 tag uses `versionName=0.1.6` and public versionCode 7. The stable Android v0.1.6 tag uses `versionName=0.1.6` and public versionCode 7.
The v0.1.7 stabilization source starts at `versionName=0.1.7` and versionCode 8.
Private validation builds may still supply only a strictly increasing Private validation builds may still supply only a strictly increasing
`versionCode` as a build-time override; that does not create a public release `versionCode` as a build-time override; that does not create a public release
or change schemas and protocols. or change schemas and protocols.
@ -772,12 +684,9 @@ partial, invalid and transport-error states never synthesize values.
`WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` uses a lazy, modular Apache `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` uses a lazy, modular Apache
ECharts 6.1.0 SVG line chart for medium/large Progression tiles. Every real ECharts 6.1.0 SVG line chart for medium/large Progression tiles. Every real
point remains visible, `smooth=false`, legacy zeroes are preserved, and only point remains visible, `smooth=false`, legacy zeroes are preserved, and only
Core-owned `improved` flags alter markers. A React SVG ports the exact Core-owned `improved` flags alter markers. An original React SVG supplies
normalized head, neck, and 11 front/11 back BODY ZONES paths from Android front/back BODY ZONES regions whose discrete colour level depends solely on
`HomeScreen.kt` `bodyRegions()` and `neckPath()`. Its discrete Catppuccin colour `session_count`; the factual text list remains authoritative and accessible.
level still depends solely on `session_count`; the factual text list remains
authoritative and accessible. The figure geometry changes no zone mapping or
30-day calculation.
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence, `TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
formatting, layout-invariance and source-derived text-boundary tests, plus formatting, layout-invariance and source-derived text-boundary tests, plus

View file

@ -1,21 +1,5 @@
# Desktop database # Desktop database
## Schema v37: Program planning
Schema v37 is additive to the Program rescheduling development line. It
creates `program_session_planning` for absolute current dates and ceded-slot
state, `program_reschedule_operations` for exact request replay, and
`program_revision_sequences` for monotonically ordered Program snapshots.
A trigger increments the per-Program sequence whenever its revision changes,
including archive, reschedule and logical deletion. No date uniqueness or
historical session mutation is introduced. Missing planning rows mean the
original `planned_for` date and active planning state. The source format and
published Programs V1 artifact remain strict and unchanged.
This schema is present in the audited development commit. Its presence in
source does not establish the schema of another installation; a deployment
requires a compatible package, backup and migration verification.
## Causal heart-rate tail correction (schema v36, no migration) ## Causal heart-rate tail correction (schema v36, no migration)
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
@ -256,8 +240,8 @@ is a later, separately versioned migration.
## 1. Status ## 1. Status
```text ```text
TRAINLOG_DATABASE_SCHEMA_VERSION=37 TRAINLOG_DATABASE_SCHEMA_VERSION=28
DATABASE_SCHEMA_V37=IMPLEMENTED DATABASE_SCHEMA_V28=IMPLEMENTED
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
``` ```
@ -276,7 +260,7 @@ PRAGMA user_version;
Current value: Current value:
```text ```text
37 27
``` ```
The independent actual-set loads documented in the current desktop, Android The independent actual-set loads documented in the current desktop, Android
@ -735,7 +719,7 @@ distance km
The Android SQLite database is independent. The Android SQLite database is independent.
Current Android-local version: **34**. The historical migration chain adds the Current Android-local version: **17**. The explicit migration chain adds the
durable draft in v4, equipment references in v5, per-set load in v6, occurrence durable draft in v4, equipment references in v5, per-set load in v6, occurrence
identity/multi-occurrence support in v7, and the widened custom-equipment identity/multi-occurrence support in v7, and the widened custom-equipment
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw definition graph in v8. Version 9 adds completed/draft explicit max rows, raw

View file

@ -68,32 +68,3 @@ source definition and derived preparations. A deleted program cannot be
resurrected or prepared; an archived program cannot be prepared. The optional resurrected or prepared; an archived program cannot be prepared. The optional
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android `trainlog-programs` V1 synchronization companion is a separate PC-to-Android
projection and does not alter this import format. projection and does not alter this import format.
## Explicit ordered rescheduling in the audited development line
The `trainlog-program-recalage-v1` line adds a separate planning
state. `planned_for` remains the imported date. `current_for` is an absolute
current target for an active, unfinished session; `ceded` releases one selected
future slot without completing or deleting its session identity. Completed
cards still use the linked session's actual start timestamp. Positions from the
imported Program determine the cascade order; titles never determine priority.
The Web action selects a first and last unfinished session, a resumption date,
and the exact session identities whose slots are ceded. Its available days are
the dated original Program slots in that interval on or after resumption.
The preview lists original, prior current and proposed dates. It rejects a
missing slot, a date beyond the Program end, a completed/in-progress/prepared
session, or a stale revision. It does not invent rest-day or post-end slots.
An explicitly ceded session keeps its identity and remains visible as ceded.
Unchanged sessions in the selected range are displayed in the preview but need
no planning row. Other sessions and Programs are not moved. Multiple actual or
independent sessions may still share one civil day.
Confirmation binds the preview fingerprint to the current Program projection
and writes absolute dates, ceded states, the Program revision and a durable
operation response in one transaction. Exact operation replay returns that
response; changed content under the same operation ID conflicts. A failed
write rolls back every row. The development implementation requires a recent acknowledged
Programs V2 generation for every previously contacted Android peer. A newly
started offline Android Program draft remains protected by Android's V2 import
rejection; the sync run then reports a conflict for explicit resolution.

View file

@ -1,82 +0,0 @@
# Documentation deep review V1 — 2026-10-02
## Scope and evidence hierarchy
This review covers the linear development source at
`f4ddcfdb05e9627397f7225c05f40212e9de1ea6`
(`trainlog-sleep-single-selector-weeks-v1`). Its ancestors include `3a4ec34`
(Program rollout), `8995843` (current Program projection), `c2935b8` (Web body
figures), and the Sleep sport/PDF/paging changes. It does not integrate or
describe as delivered the uncommitted Dashboard layout work in the separate
`trainlog-dashboard-polish-v1` worktree. Stable v0.1.6 and the 0.1.7
development line are different publication states.
Normative frozen contracts take priority over source implementation; source and
tests establish capabilities of this commit; dated rollout records establish
only the installation they observed; design/review files retain history. The
observed PC/Web launcher path names the `f4ddcfd` package. That observation
alone is not a byte comparison of a running process, an Android APK inspection,
or proof of every synchronization service's package. The earlier verified
Program rollout records desktop/Android schemas v37/v34. No private health
data, PDF, database or device identifier was read for this review.
## Coverage inventory
The 105 tracked baseline Markdown files include root `README.md`, `AGENTS.md`,
`CHANGELOG.md`, `android/README.md`, `format/README.md`, 17 primary `docs/*.md`
owners, domain and design records, release notes and historical reviews. This
revision adds an indexed Sleep guide and this review. The primary owners were
inspected against code, migration constants, Meson/Gradle settings, tests and
retained rollout records. Designs, releases and reviews were mapped as dated
evidence rather than rewritten into current status. The tracked Markdown
relative-link scan found no missing target before publication. External link
availability was not verified.
| Document/section | Claim | Evidence (commit, path, symbol or test) | Gap | Correction | Status |
| --- | --- | --- | --- | --- | --- |
| `AGENTS.md` scope | Analyse, Programmes, Sessions and Exercises are placeholders | `web/src/routes/`; `docs/web_sessions.md`; `docs/web_exercises.md` at `f4ddcfd` | Obsolete present tense | Names implemented routes | Corrected |
| `AGENTS.md`, database and Android status | Current schemas v26/v24, v28/v17 or v36/v33 | `tui/include/trainlog/database.h` `TRAINLOG_DATABASE_SCHEMA_VERSION=37`; `TrainlogRepository.kt` helper `version=34`; migration tests | Mixed historical and current versions | Current source v37/v34; historical migration steps retained | Corrected |
| `current_state.md`, roadmap | `c2935b8`/`3758875` Web package served | Observed launcher target names `sleep-single-selector-weeks-v1-f4ddcfd`; ancestor graph | Package claim stale; running bytes unverified | Reports launcher observation and separate Program rollout | Corrected with limit |
| Sleep agenda | Old selector or calendar-week interpretation | `SleepDiaryWorkspace.tsx` `initialAgendaPageSize`, `recentEntries`, `visibleEntries`; Sleep component tests | Seven recorded rows can span more than seven dates | Current 7/14/21/28/All row counts, default seven; old values migration only | Corrected |
| Sleep PDF and sport | Page controls could be confused with export range | `SleepDiaryWorkspace.tsx` `pdfStartDate`/`pdfEndDate`; `sleepVisualProjection`; Web PDF tests | Selection and visible page have different scopes | Documents independent inclusive range and factual sport bands | Verified in source |
| Programs | Rescheduling labelled future/candidate | `docs/program_format_v1.md`; `docs/sync_exchange.md`; `tui/tests/test_web_programs.c`; Android v34 projection | Development capability mislabelled future | Describes implemented development contract and partial V1-peer convergence | Corrected |
| Web package | `npm test` could be mistaken for embedded runtime proof | `meson_options.txt` `web`; `tui/meson.build` frontend target/tests; `tools/package_sync_candidate.py` asset gate | Source tests and package differ | README requires `-Dweb=enabled`, Meson tests, compatible bundle | Corrected |
| Synchronization | One ACK might imply universal convergence | `docs/sync_exchange.md` Programs V2 matrix; generation and ACK implementation | V1-only peer omits rescheduled Program while other domains proceed | Added domain/format/selection/ACK matrix | Corrected |
| Transport narrative | Current Web edits and architecture described USB as universal priority | `docs/sync_exchange.md` Bluetooth selector; `tools/sync_peer_worker.py`; private rollout record | Conflated paired Bluetooth path with separate USB/Drive workflow | Separated automatic paired choice from legacy configured workflow | Corrected |
| Rollout readiness | Historical eight-generation limit could look universal/current | `tools/sync_generation_exchange.py` `MAX_RETAINED_PER_PEER=16`; Android `SyncGenerationService.MAX_RETAINED_PER_PEER=8`; `tools/sync_peer_worker.py` MTP timeout | Desktop/Android ceilings differ; timeout is not success proof | Separated limits and historical bridge instructions | Corrected |
| Sleep user path | Capture, agenda and PDF behavior had no short indexed guide | `TrainlogRepository.kt`; `SleepDiaryWorkspace.tsx`; Sleep projection/PDF tests | Readers had to assemble workflow from state and code | Added indexed factual guide with privacy and evidence boundaries | Corrected |
| Backup/rollback | Old executable could be mistaken for schema rollback | `tools/backup_trainlog_sqlite.py` SQLite backup API; `docs/sync_rollout_readiness.md` | Needs paired compatibility and retained backups | Existing procedure retained and linked from README | Verified in documentation |
| Tests | Largest historical totals could look like current validation | `docs/tests.md` dated checkpoints; `tui/meson.build`; Web scripts | Build configuration and skipped tests were implicit | Added evidence-reading rule | Corrected |
| 0.1.8 | Possible training ideas could look implemented | `docs/roadmap.md`; present catalog/feedback/Sleep/Cardio owners | No approved algorithm or medical rule | Proposed-only section with human validation | Corrected |
## Validation performed in the isolated documentation worktree
`python3 tools/validate_json.py` and
`python3 tools/validate_import_contract.py` passed. With a local
`-Dweb=enabled` Meson build, strict compilation and **114/114** registered
tests passed, including the embedded frontend and package self-containment
checks. A preliminary `-Dweb=disabled` run passed 111/112; its deployment
package test rejected the intentionally absent Web assets as designed. The
changed C/H format gate passed with no changed C/H file. Relative links across
the tracked baseline plus new Markdown files and `git diff --check` passed.
These checks used build artifacts in this worktree and synthetic test data;
they did not exercise a personal device, database or service.
## Boundaries and open evidence
The source can prove format selection, persistence rules and test coverage; it
cannot prove that a particular uninspected phone or service is currently
running that source. The `f4ddcfd` launcher observation identifies a package
path, not a live process hash. The private rollout and Firefox/PDF records are
dated evidence for their stated configurations. Android path reconstruction for
the Web silhouette is source parity evidence, not an Android runtime
screenshot. The local desktop 16-generation and Android eight-generation
admission limits, and the 90-second MTP operation budget, are bounded
configuration, not a guarantee against interruption or proof that transient
storage occupation is always lower than either ceiling.
No code defect was confirmed by this documentation pass. A broad line-by-line
revalidation of every historical review and every external URL is outside the
verified coverage; their current status must not be inferred from this matrix.
No application, database, service, synchronization or personal-data operation
belongs to this documentation branch.

View file

@ -1,12 +1,5 @@
# Roadmap # Roadmap
The Program cascade is deployed in the private daily installation from
`trainlog-program-recalage-v1` at `3a4ec34`. The observed PC/Web launcher points
to the `f4ddcfd` package, which includes
the verified blue Sleep sport overlay and the readable paginated treatment and
remarks PDF. Synchronization services retain their Program rollout paths. No
new personal Program rescheduling is implied by this presentation work.
The controlled private rollout is complete for the paired daily installation: The controlled private rollout is complete for the paired daily installation:
physical libmtp, verified backups, signing continuity, migrations, correlated physical libmtp, verified backups, signing continuity, migrations, correlated
ACKs, restart, and idempotent replay passed. Generation mode remains an ACKs, restart, and idempotent replay passed. Generation mode remains an
@ -37,9 +30,8 @@ estimates.
## Current baseline ## Current baseline
Stable v0.1.6 is the release baseline for the v0.1.7 stabilization cycle. Stable v0.1.6 is the release baseline for the next stabilization cycle.
The audited development source has desktop schema v37 and Android schema v34. Desktop schema v36, Android schema v33, Notcurses, direct `Documents/Trainlog`
Stable v0.1.6 had desktop schema v36 and Android schema v33. Notcurses, direct `Documents/Trainlog`
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
Generator V1 is hidden pending V2. AI session-draft exchange retains its Generator V1 is hidden pending V2. AI session-draft exchange retains its
@ -84,43 +76,9 @@ release closes this implementation cycle without reopening its frozen
contracts. contracts.
```text ```text
CURRENT_OPERATIONAL_CURSOR=0.1.7 bugfix-only stabilization open after immutable stable v0.1.6. CURRENT_OPERATIONAL_CURSOR=Stable v0.1.6 published; open 0.1.7 stabilization after the immutable release tag.
``` ```
### 0.1.7 stabilization scope
The 0.1.7 line is restricted to observed bug fixes, regression fixes, small UI
inconsistencies directly caused by a bug, documentation corrections, robustness,
and tests associated with those corrections.
New features, new modules, architectural refactors not required to fix a bug,
protocol evolution, new Android/Web/TUI capabilities, and speculative
optimization are out of scope. Work outside this boundary waits for a later
feature cycle rather than widening 0.1.7.
### Program rescheduling in the audited development line
Ordered Program rescheduling is implemented in the audited development line
and deployed in the recorded private Program rollout. One explicit user action
must select the starting session, restart date, affected successors, and usable
slots; preview the old and new dates; then durably record one decision. Only
unfinished sessions move, in Program position order. Explicitly ceded optional
slots may absorb required sessions without marking optional sessions completed
or removing historical identities. At most one principal Program session per
selected slot is a rule for this action, not a limit on other same-day workouts.
Rest days and Program bounds cannot be silently overridden. Preparations and
in-progress executions require checks before applying the decision.
The result does not cascade again on display, import, replay, or Bluetooth
reconnection. PC and Android must receive the same business state; a Web-only
plan is insufficient. Compatibility requires proof in both supported exchange
directions and across revisions or reexports, including explicit behavior for
older clients unable to represent the decision. Payload acceptance alone does
not establish convergence. This development contract does not reopen the
Bluetooth transport, Sleep, HR, or general sync invariants. Before any real
application, reread executions and preparations, confirm the restart date and
slot choices, and preview the resulting plan again.
Sleep Diary V1 stays frozen/readable while active capable peers require Sleep Diary V1 stays frozen/readable while active capable peers require
`sleep-diary-v2`. The Web’s relative HR rise/fall overlays remain descriptive `sleep-diary-v2`. The Web’s relative HR rise/fall overlays remain descriptive
measured-series presentation only, never inferred sleep states or causes. measured-series presentation only, never inferred sleep states or causes.
@ -181,8 +139,7 @@ remaining per-installation opt-ins are deployment policy, not the v0.1.3 cursor.
TypeScript and Vite shell with its five client routes, Catppuccin Mocha design TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
system, permanent Header/Footer, empty-data states and health status. The system, permanent Header/Footer, empty-data states and health status. The
Dashboard slices below are complete; this does not authorize implementation of Dashboard slices below are complete; this does not authorize implementation of
the then-remaining route placeholders; those routes are implemented on the the remaining route placeholders.
audited 0.1.7 development line.
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid, `WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid,
explicit edit mode, drag/resize, keyboard alternative and derived responsive explicit edit mode, drag/resize, keyboard alternative and derived responsive
projections without persisting layout state. projections without persisting layout state.
@ -423,20 +380,6 @@ normal capture.
## Later ## Later
### Proposed 0.1.8 training assistance (not implemented)
Explore gradual, explained progression or maintaining a load from existing
performance and subjective feedback. Compare exercises and equipment only when
their context is genuinely comparable; account for effort, discomfort and
missing observations. Explore requested or evidenced targeted strengthening,
reasoned exercise diversity and dumbbell use without diagnosing weakness.
Define cautious cardio reference points before any gradual introduction, with
no maximal test or generated prescription from this documentation work. Reuse
the existing medication diary, Sleep and Cardio facts as context without a
second intake workflow or asserted medical causality. A human must validate
any proposed training change before it alters a workout. These are ideas, not
an implementation decision, delivery date, recovery score or clinical rule.
- evolve training analytics only where sufficient real history supports the - evolve training analytics only where sufficient real history supports the
metric, without aggregating incomparable loads; metric, without aggregating incomparable loads;
- add body baseline and trend comparisons while keeping direct measurements - add body baseline and trend comparisons while keeping direct measurements

View file

@ -1,63 +0,0 @@
# Sleep Diary: capture, review and PDF
This guide describes the Web and Android behavior in the audited `f4ddcfd`
0.1.7 development source. The [current state](current_state.md) owns rollout
and validation status; [synchronization exchange](sync_exchange.md) owns the
versioned Sleep companion. Sleep facts and medication intakes are not a medical
interpretation or an automatic training decision.
## Capture and correction
Android's quick actions record bedtime (`Couché`), a waking interval
(`Réveil`), final get-up (`Levé`) and time-stamped medication intakes. An intake
may be recorded during the day or before bedtime. A pre-bed intake creates or
reuses the same durable night/day entry; `Couché` later adds to that entry.
There is no separate medicine log to fill out. Quantity is a count of the
separately stored per-unit dose. Each meaningful edit advances the entry's
causal revision; correction uses the existing entry rather than inventing
another night. The Android owner and draft/quick-publication rules are detailed
in [Android](android.md#sleep-diary-v1-and-active-v2-companion).
The Sleep day is assigned using the local 18:00-to-18:00 boundary. Each Web
agenda row and its heart-rate detail use that night's exact offset and a
24-hour elapsed timeline. A missing factual sleep interval may receive a
clearly labelled presentation estimate when both bedtime and final get-up
exist: bedtime plus 45 minutes through final get-up minus 10 minutes, minus
structured waking overlap for the displayed duration. It does not write a
Sleep fact. No missing heart-rate samples or sleep stages are manufactured.
## Review the agenda
In Analyse → Sommeil, the agenda is newest first. Its single display selector
shows 7, 14, 21, 28 or All **recorded entries** per page; seven is the first-use
default. These are row counts, not complete calendar weeks: seven entries can
span more than seven dates. The choice is browser-local and survives reload;
previous 5/10/15 choices migrate to 7/14/21. The other Analyse sections keep
their own period control. Select a night to inspect and edit that entry and
its measured heart-rate timeline. Paging does not discard the selected night,
editor state, loaded statistics or PDF range.
Blue sport bands come from completed session start/end history, not the
Program plan. A missing or invalid end is shown as a point marker with no
invented duration. The Web agenda omits the redundant sport text list; the PDF
retains a dated sport appendix. Sport does not become a Sleep event or change a
sleep total. If the full read reaches its 3,660-night cap, the Web reports that
completeness cannot be established instead of silently treating a truncated
set as All. Missing data and load errors have explicit states.
## Preview and export
The vector PDF preview and download share an inclusive range of **night start
dates**. That range is initialized from loaded history and is independent of
both the visible agenda page and selected detail night. Sport is read completely
before preview/export. The PDF uses the same 18:00 geometry as Web, prints
factual sport intervals in blue, and lists sessions even when there is no
matching Sleep row. Treatments and remarks wrap by available width and continue
on dated pages instead of truncating silently. Review the chosen range before
export; a PDF is a local copy of potentially sensitive information.
The descriptive heart-rate rise/fall overlays compare measured BPM with a
recent reference. They do not infer sleep stages, causes, readiness or medical
conditions. The source-backed projection and evidence limits are summarized in
[current state](current_state.md#versions-and-compatibility) and the
[documentation review](reviews/documentation_deep_review_v1.md).

View file

@ -32,25 +32,8 @@ offset-bearing timestamps, appreciations, notes, logical deletion, and an
integer `quantity` in `1..99` on every medication intake. `dose_value` and integer `quantity` in `1..99` on every medication intake. `dose_value` and
`dose_unit` remain the per-unit snapshot; quantity is never encoded in notes. `dose_unit` remain the per-unit snapshot; quantity is never encoded in notes.
V1 input omits `quantity` and is normalized to one unit. Exact revision replay V1 input omits `quantity` and is normalized to one unit. Exact revision replay
is unchanged; resurrection after deletion is rejected in the generation is unchanged; sibling revisions and resurrection after deletion are rejected
consumption transaction. For distinct V2 sibling tips of the same entry with in the generation consumption transaction.
an unchanged night envelope and deletion state, the last actual user edit
wins, independent of device. Revision edit timestamps are compared as instants;
event times, generation publication, import, and ACK times never establish
priority. An equal edit instant or inconsistent provenance remains an explicit
conflict. This selection uses the complete winning snapshot, not a field or
event union, so an intentional removal is not resurrected. The losing revision
remains immutable in local history. When the local snapshot wins, a fresh
technical successor descends from the received revision and retains the
winning user-edit timestamp and exact payload, allowing the other peer to
advance causally without rewriting either original revision or generation.
A durable Sleep rejection keeps its exact ACK and does not advance either
causal tip. The full-generation worker carries the consumer's bounded Sleep
diagnostic through to the Web run state, where an unresolved concurrent
revision is reported as `data_conflict` rather than an opaque `internal_error`.
An old rejected generation remains rejected after a later, newly captured
generation resolves its distinct conflict.
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal
ownership. ownership.
@ -261,28 +244,6 @@ does not publish to it or let it override an artifact in the new endpoint.
## 3. Artifact table ## 3. Artifact table
The following is a navigation matrix for the audited `f4ddcfd` source. A
capability advertisement selects a companion; file presence alone is not an
ACK or proof that every domain converged. The detailed versioned rules below
remain authoritative.
| Domain | Producer → consumer | Format/version | Selection and persistence | ACK/proof and limit |
| --- | --- | --- | --- | --- |
| Completed sessions | Android ↔ desktop | `trainlog-mobile-export` V3; V1/V2 legacy input | Default automatic snapshot; stable IDs in separate SQLite stores | V3 import/replay is idempotent; this is not a full-generation ACK |
| Manual preparations | Desktop → Android | `trainlog-session-preparations` V2 | Full generation on capable peer; revisions, deliveries and withdrawals retained | Correlated generation ACK closes delivery/withdrawal; an active draft is preserved |
| Programs | Desktop → Android | `trainlog-programs` V1 or V2 | Exactly one selected by peer capability; V2 carries current dates, ceded state and revision sequence; desktop owns plan | Correlated ACK; V1-only peer cannot represent a rescheduled live plan, so Program convergence may remain incomplete while other domains proceed |
| Program executions | Android → desktop | `trainlog-program-executions` V1 | Stable in-progress/completed provenance, separate from `trainlog-program` V1 desktop import | Generation validation and ACK; no invented completed session |
| Sleep diary | Android ↔ desktop | `sleep-diary-v2`; frozen V1 readable | Current causal revision and immutable history; v2 carries intake quantity | Generation/ACK plus revision validation; concurrent ambiguous edits can reject |
| Heart rate and Cardio | Android → desktop | Heart Rate V1, session timeline V1, cardio sessions/calibration/guidance V1; separate heart-rate corrections V1 | Factual captures and versioned companions, selected by capabilities | Correlated generation/ACK; no sensor or medical inference on desktop |
| Exercise/equipment context | Both directions | Catalog, aliases, BODY ZONES and equipment companions below | Stable IDs, revision and causal rules apply per domain | A generation ACK does not override a per-domain conflict |
`trainlog-program` V1 is a strict desktop file import defined in
[Program format V1](program_format_v1.md); it is never the synchronized
`trainlog-programs` companion. `/api/v1` is the local Web API, not a JSON
exchange format. Bluetooth, MTP and private Drive move bytes for the same
full-generation business protocol; default V3 compatibility and installation
opt-in remain separate from format capability.
| Direction | File | Format | | Direction | File | Format |
| --- | --- | --- | | --- | --- | --- |
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 | | Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
@ -299,7 +260,6 @@ opt-in remain separate from format capability.
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 | | PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals | | PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones | | PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
| PC -> Android | `programs-v2.json` | capability-selected `trainlog-programs` v2 companion: original and current dates, ceded state, ordered revision sequence, and the same deletion tombstones |
| Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance | | Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance |
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 | | Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
| Android -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 | | Android -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 |
@ -322,33 +282,6 @@ V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
semantics. semantics.
### Programs V2 capability matrix in the audited development line
`trainlog-programs` V2 is a separate strict artifact selected only for a peer
advertising `programs-v2`. Its session adds `position`, nullable `current_for`
and `planning_state=active|ceded`; its Program adds `revision_sequence`.
`planned_for`, stable IDs, source entries and tombstones retain their V1
meaning. One generation contains exactly one Programs version. Android v34
stores the V2 projection transactionally and rejects a lower sequence, a
same-sequence different revision, a later V1 downgrade, or a changed plan for
its currently active Program draft. Execution facts return through the
unchanged Program-executions V1 companion and retain their actual timestamps.
| Desktop state | Android capability | Program result | Other domains |
|---|---|---|---|
| No reschedule | `programs-v1` only | V1 original plan | Normal generation |
| No reschedule | `programs-v2` | V2 projection; V1 is omitted | Normal generation |
| Rescheduled live Program | `programs-v2` | V2 absolute current plan | Normal generation |
| Rescheduled live Program | V1 only, absent or stale V2 | Program artifact omitted; run reports incomplete Program convergence after ACK | Other artifacts still synchronize |
| Program draft started offline after the last V2 ACK | `programs-v2` | Android rejects a changed plan without rewriting its draft; explicit conflict resolution is required | Generation rejection is durable |
The desktop commit requires that the latest generation to each previously
contacted Android peer was an acknowledged V2 Program generation no older than
five minutes. This bounds, but cannot eliminate, a phone-side race: Android's
active-draft check is the final guard. A failed or downgraded peer cannot be
reported as current for the Program domain. The existing generation, request,
ACK, tombstone, transport, Sleep and HR wire contracts are unchanged.
## Optional Program executions companion V1 ## Optional Program executions companion V1
`trainlog-program-executions` V1 is an Android-to-desktop full-generation `trainlog-program-executions` V1 is an Android-to-desktop full-generation
@ -1134,7 +1067,7 @@ named by that validated reference. It does not recursively mirror retained
Android generations, desktop publications, or unrelated exchange history. Android generations, desktop publications, or unrelated exchange history.
Local correlation is checked every 50 ms without USB work; complete MTP pulls Local correlation is checked every 50 ms without USB work; complete MTP pulls
are throttled to a three-second cadence. Each MTP operation has its own are throttled to a three-second cadence. Each MTP operation has its own
90-second ceiling while the conversation retains its configured global 30-second ceiling while the conversation retains its configured global
deadline. Exhausting the global deadline while waiting for a correlated deadline. Exhausting the global deadline while waiting for a correlated
reference reports `timeout waiting for correlated android-generation-v1.json`; reference reports `timeout waiting for correlated android-generation-v1.json`;
only an adapter that exceeds its independent operation budget reports only an adapter that exceeds its independent operation budget reports

View file

@ -51,29 +51,26 @@ generalizes pairing to another phone or authorizes an unconfigured desktop.
3. Quiesce old writers only during an authorized rollout. Desktop backup uses 3. Quiesce old writers only during an authorized rollout. Desktop backup uses
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and `backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
foreign-key checks. foreign-key checks.
4. V3 is not an Android backup. At the original September 2026 rollout, the 4. V3 is not an Android backup. Current Android has a complete verified backup
installed schema-v17 phone required a pinned bridge candidate, verified container. For the installed schema-v17 release, build the pinned bridge
signing/version continuity, and a complete retained user backup before the candidate, verify signing/version continuity separately, install only with
newer Android package. That is historical procedure evidence, not a claim explicit approval, create and verify the user backup, and retain it before
about the currently installed phone schema or an instruction to repeat the installing current Android. None of those device/user operations occurred in
bridge. A new rollout must inspect its actual versions and backups first. this software mission.
5. Replacing a binary does not roll back a schema. Before any exchange, a 5. Replacing a binary does not roll back a schema. Before any exchange, a
mutually compatible captured pair may be restored. After causal writes or mutually compatible captured pair may be restored. After causal writes or
ACKs, never restore only one peer: stop synchronization, preserve both stores ACKs, never restore only one peer: stop synchronization, preserve both stores
and all artifacts, and use an explicit paired recovery plan. and all artifacts, and use an explicit paired recovery plan.
6. Per-peer retained-generation ceilings are admission gates: the audited 6. The eight-generation-per-peer ceiling is an admission gate. Exhaustion must
desktop `sync_generation_exchange.py` permits 16 active outgoing entries; preserve pending generations, tombstones, finalizations and ACK ledgers; it
Android `SyncGenerationService` permits eight. Exhaustion preserves pending must never trigger hidden eviction.
generations, tombstones, finalizations and ACK ledgers; it never triggers
hidden eviction. These counts are not a bound on all transient staging or
transport objects.
The grouped rollout approval covered the signed APK, desktop bundle, verified The grouped rollout approval covered the signed APK, desktop bundle, verified
user backups, coordinated service quiescence, pairing, synthetic smoke and user backups, coordinated service quiescence, pairing, synthetic smoke and
non-destructive hardware smoke. Those steps are complete for the recorded non-destructive hardware smoke. Those steps are complete for the recorded
private installation. Drive remains separate and untested. private installation. Drive remains separate and untested.
The software-only candidate recorded for the original rollout was rebuilt under the private root The latest software-only candidate set was rebuilt under the private root
`/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit `/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit
`dd8c91a946a9c836965818d8cc35b8f8bce74658`. Its desktop inventory records every `dd8c91a946a9c836965818d8cc35b8f8bce74658`. Its desktop inventory records every
packaged file digest plus schemas, protocols, entry points and runtime packaged file digest plus schemas, protocols, entry points and runtime
@ -87,7 +84,7 @@ synthetic schema-v17 backup SHA-256 is
`06eb3999c6f3ce2fd8f551b38727a0a67fe6b48dde4c3244b3d50357afa7e6a3`. `06eb3999c6f3ce2fd8f551b38727a0a67fe6b48dde4c3244b3d50357afa7e6a3`.
These are unsigned development evidence, not deployment authorization. These are unsigned development evidence, not deployment authorization.
## Compatible set for the original rollout ## Compatible set
- desktop binary containing schema v21, the Web API and the generation worker; - desktop binary containing schema v21, the Web API and the generation worker;
- `sync_generation_exchange.py` and all matching transaction-neutral codecs; - `sync_generation_exchange.py` and all matching transaction-neutral codecs;
@ -113,16 +110,11 @@ These are unsigned development evidence, not deployment authorization.
Do not delete an unacknowledged generation, the last acknowledged generation, Do not delete an unacknowledged generation, the last acknowledged generation,
its previous valid generation, or causal evidence. An ACK timeout is unresolved, its previous valid generation, or causal evidence. An ACK timeout is unresolved,
not rollback. Sixteen retained outgoing generations exhaust admission and require not rollback. Eight retained outgoing generations exhaust admission and require
operator diagnosis; this tranche adds no tombstone or evidence garbage operator diagnosis; this tranche adds no tombstone or evidence garbage
collection. A migrated database is recovered from its verified backup, never by collection. A migrated database is recovered from its verified backup, never by
assuming an older binary reverses its migration. assuming an older binary reverses its migration.
The audited MTP peer worker has a 90-second per-operation budget, further
limited by the remaining conversation deadline. It is a timeout bound, not a
success guarantee or proof that an interruption cause has disappeared.
The recorded private installation completed physical MTP, service coordination The recorded private installation completed physical MTP, service coordination
and APK/desktop deployment. Actual Drive configuration remains untested; the and APK/desktop deployment. Actual Drive configuration remains untested; the
Android eight-generation admission bound and foreground-phone requirement eight-generation admission bound and foreground-phone requirement remain.
remain; the audited desktop limit is 16.

View file

@ -1,27 +1,5 @@
# Tests and validation # Tests and validation
## Reading the evidence
Counts below belong to the stated checkpoint, source revision and build
configuration. They are historical evidence, not a claim that the present
documentation checkout ran those tests. A native build with `-Dweb=enabled`
registers embedded Web asset tests in Meson; `npm test` separately runs Vitest
against Web source. A default `web=auto` build can omit embedded assets, so a
green source test alone does not validate the packaged Web application.
Skipped Android fixture tests, unrun instrumented tests and physical Bluetooth,
MTP or Drive evidence are distinct outcomes. The primary personal phone is
reserved for authorized manual smoke checks, not host automation.
## Sleep V2 concurrent-edit selection in 0.1.7
Synthetic Desktop and Android regressions cover both edit-time orderings,
including a later user edit that records an earlier sleep event. They check
the complete winning snapshot, retained sibling revisions, no event/intake
union or duplication, causal successor import on the other peer, exact replay,
and rejection when edit instants are equal. The real paired-device validation
completed separate automatic, Web-with-automatic-service, and post-reconnection
Bluetooth generation/ACK cycles without changing an old rejected ACK.
## Sleep revision collision recovery ## Sleep revision collision recovery
Collision regressions distinguish exact replay from same-ID parent or payload Collision regressions distinguish exact replay from same-ID parent or payload
@ -410,23 +388,6 @@ Firefox scenario exports a seven-night French PDF from the embedded production
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
with `pdftoppm` for retained visual inspection. with `pdftoppm` for retained visual inspection.
The completed-sport Sleep overlay is covered by C read-only history
intersection/pagination checks, Web pagination and failure-state tests, and
pure geometry tests for 05:00 placement, midnight, 18:00 clipping, missing and
invalid ends, and the Europe/Paris fall offset. PDF tests assert vector blue
bands, black outlines, factual labels, and unchanged Sleep totals. Synthetic
Firefox validation uses an isolated schema-v37 database and the embedded
`-Dweb=enabled` binary, then inspects a downloaded PDF rendered by `pdftoppm`.
Synthetic French and English reports of 7, 14, 21, and 30 nights are rasterized
for page, margin, and monochrome inspection. No personal health artifact is
stored in the repository.
The private PC/Web deployment was checked separately after the rollout switch:
the running executable and the served JavaScript/CSS hashes matched the
`e2c07f1` bundle. Read-only Firefox inspection found 9 blue segments across
11 existing Sleep rows; a downloaded three-page PDF contained the same sampled
factual start time and a printable sport listing. Its private images and PDF
remain outside the repository.
`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed `WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
field and Vitest fixtures confined to test sources. Coverage proves the 90-day field and Vitest fixtures confined to test sources. Coverage proves the 90-day
activity map and direct totals, all compact/medium/large disclosure levels, activity map and direct totals, all compact/medium/large disclosure levels,

View file

@ -51,7 +51,5 @@ Create, update and retirement first commit only to the desktop database. They
do not call synchronization and do not require a phone. The next user-triggered do not call synchronization and do not require a phone. The next user-triggered
normal synchronization reuses the existing PC catalogue, exercise profile normal synchronization reuses the existing PC catalogue, exercise profile
state, BODY ZONES, equipment companions and causal deletion artifacts through state, BODY ZONES, equipment companions and causal deletion artifacts through
the configured generation engine (Bluetooth primary with direct MTP recovery the existing USB-priority engine with Drive as mirror/fallback. No Web
on the paired installation, or the separate USB/Drive workflow). Without that
opt-in, the default V3 path retains its documented compatibility limits. No Web
Exercises artifact or protocol version was introduced. Exercises artifact or protocol version was introduced.

View file

@ -84,8 +84,7 @@ Inactive execution drafts and completed sessions use the existing causal
operation/state boundary. Active or stale drafts conflict, and completed-session operation/state boundary. Active or stale drafts conflict, and completed-session
deletion records a finalization so older snapshots cannot recreate the session. deletion records a finalization so older snapshots cannot recreate the session.
Programs are planning data owned by desktop SQLite (introduced in v25 and Programs are planning data owned by desktop schema v26. The Programs subtab
extended through v37). The Programs subtab
uses responsive cards and a timeline detail view backed by real imported uses responsive cards and a timeline detail view backed by real imported
definitions, sessions, usage, and provenance data. Import first runs the definitions, sessions, usage, and provenance data. Import first runs the
same strict Core validator in preview mode, then commits only after explicit same strict Core validator in preview mode, then commits only after explicit

View file

@ -1,7 +1,7 @@
project( project(
'trainlog', 'trainlog',
'c', 'c',
version: '0.1.7', version: '0.1.6',
meson_version: '>=1.3.0', meson_version: '>=1.3.0',
default_options: [ default_options: [
'c_std=c17', 'c_std=c17',

View file

@ -11,7 +11,6 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools")) sys.path.insert(0, str(ROOT / "tools"))
import export_programs import export_programs
import export_programs_v2
SCHEMA = """ SCHEMA = """
@ -80,41 +79,6 @@ class ProgramsExchangeTest(unittest.TestCase):
"requested_at": "2026-09-18T12:00:00Z", "requested_at": "2026-09-18T12:00:00Z",
}], payload["deletions"]) }], payload["deletions"])
def test_v2_carries_current_date_and_ceded_state_without_changing_v1(self):
with closing(sqlite3.connect(self.database)) as db:
db.executescript("""
CREATE TABLE program_session_planning(
program_session_id TEXT PRIMARY KEY,current_for TEXT,
state TEXT,operation_id TEXT,updated_at TEXT);
CREATE TABLE program_revision_sequences(
program_id TEXT PRIMARY KEY,revision_sequence INTEGER NOT NULL);
INSERT INTO program_revision_sequences VALUES('pg_live',1);
INSERT INTO program_session_planning VALUES(
'pgs_live','2026-10-01','active','op_fixture','2026-09-30T12:00:00Z');
PRAGMA user_version=37;
""")
db.commit()
initial = export_programs.export_programs(self.database)
current = export_programs_v2.export_programs_v2(self.database)
self.assertEqual(1, initial["version"])
self.assertEqual(2, current["version"])
original = initial["programs"][0]["sessions"][0]
planned = current["programs"][0]["sessions"][0]
self.assertIsNone(original["planned_for"])
self.assertEqual(set(original), {
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences"
})
self.assertEqual("2026-10-01", planned["current_for"])
self.assertEqual(1, current["programs"][0]["revision_sequence"])
self.assertEqual("active", planned["planning_state"])
self.assertEqual(0, planned["position"])
with closing(sqlite3.connect(self.database)) as db:
db.execute("UPDATE program_session_planning SET current_for=NULL,state='ceded'")
db.commit()
ceded = export_programs_v2.export_programs_v2(self.database)
self.assertIsNone(ceded["programs"][0]["sessions"][0]["current_for"])
self.assertEqual("ceded", ceded["programs"][0]["sessions"][0]["planning_state"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -246,131 +246,6 @@ class SleepExchangeTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "positive"): with self.assertRaisesRegex(ValueError, "positive"):
exchange.validate(self.document(invalid)) exchange.validate(self.document(invalid))
def test_contradictory_final_get_up_siblings_remain_unresolved(self):
base = "slr_20000000-0000-4000-8000-000000000002"
desktop_tip = "slr_40000000-0000-4000-8000-000000000004"
android_parent = "slr_50000000-0000-4000-8000-000000000005"
android_tip = "slr_60000000-0000-4000-8000-000000000006"
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(base))))
desktop = entry(desktop_tip, base)
desktop["events"].append({
"event_id": "sle_70000000-0000-4000-8000-000000000007",
"type": "final_get_up", "start_at": "2026-10-25T06:30:00+01:00",
"end_at": None,
})
self.assertEqual((1, 0), exchange.apply(self.db, self.document(desktop)))
before = self.db.execute(
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions "
"ORDER BY revision_id"
).fetchall()
android = entry(android_tip, android_parent)
android["ancestry"] = [android_tip, android_parent, base]
android["events"][0]["end_at"] = "2026-10-25T06:50:00+01:00"
android["events"].append({
"event_id": "sle_80000000-0000-4000-8000-000000000008",
"type": "final_get_up", "start_at": "2026-10-25T06:50:00+01:00",
"end_at": None,
})
with self.assertRaisesRegex(ValueError, "concurrent sleep diary revision"):
exchange.apply(self.db, self.document(android))
self.assertEqual(before, self.db.execute(
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions "
"ORDER BY revision_id"
).fetchall())
self.assertEqual(desktop_tip, self.db.execute(
"SELECT current_revision_id FROM sleep_diary_entries"
).fetchone()[0])
self.assertEqual(1, self.db.execute(
"SELECT COUNT(*) FROM sleep_diary_events WHERE revision_id=? "
"AND event_type='final_get_up'", (desktop_tip,)
).fetchone()[0])
def test_v2_newer_local_edit_converges_without_using_event_time(self):
base = "slr_20000000-0000-4000-8000-000000000002"
local_id = "slr_40000000-0000-4000-8000-000000000004"
remote_id = "slr_50000000-0000-4000-8000-000000000005"
def v2(item):
document = self.document(item)
document["version"] = 2
document["entries"][0]["intakes"][0]["quantity"] = 1
return document
self.assertEqual((1, 0), exchange.apply(self.db, v2(entry(base))))
self.db.commit()
peer = sqlite3.connect(":memory:")
self.db.backup(peer)
try:
local = entry(local_id, base)
local["updated_at"] = "2026-10-25T20:00:00+01:00"
local["events"][0]["end_at"] = "2026-10-25T06:00:00+01:00"
remote = entry(remote_id, base)
remote["updated_at"] = "2026-10-25T19:00:00+01:00"
remote["events"][0]["end_at"] = "2026-10-25T07:00:00+01:00"
remote["events"].append({
"event_id": "sle_60000000-0000-4000-8000-000000000006",
"type": "final_get_up",
"start_at": "2026-10-25T07:00:00+01:00",
"end_at": None,
})
extra_intake = dict(remote["intakes"][0])
extra_intake["intake_id"] = "mdi_60000000-0000-4000-8000-000000000006"
extra_intake["quantity"] = 2
remote["intakes"].append(extra_intake)
self.assertEqual((1, 0), exchange.apply(self.db, v2(local)))
self.assertEqual((1, 0), exchange.apply(peer, v2(remote)))
self.assertEqual((1, 0), exchange.apply(self.db, exchange.build(peer)))
successor = exchange.build(self.db)["entries"][0]
self.assertEqual(remote_id, successor["parent_revision_id"])
self.assertEqual(local["events"], successor["events"])
self.assertEqual(1, len(successor["events"]))
self.assertEqual(local["updated_at"], successor["updated_at"])
self.assertEqual(1, len(successor["intakes"]))
self.assertEqual(1, successor["intakes"][0]["quantity"])
self.assertEqual((0, 1), exchange.apply(self.db, exchange.build(peer)))
self.assertEqual((1, 0), exchange.apply(peer, exchange.build(self.db)))
self.assertEqual((0, 1), exchange.apply(peer, exchange.build(self.db)))
self.assertEqual(
successor["revision_id"],
exchange.build(peer)["entries"][0]["revision_id"],
)
self.assertEqual(4, self.db.execute(
"SELECT COUNT(*) FROM sleep_diary_revisions"
).fetchone()[0])
self.assertEqual("ok", self.db.execute("PRAGMA integrity_check").fetchone()[0])
finally:
peer.close()
def test_v2_newer_remote_edit_wins_and_equal_time_rejects(self):
base = "slr_20000000-0000-4000-8000-000000000002"
local = entry("slr_40000000-0000-4000-8000-000000000004", base)
remote = entry("slr_50000000-0000-4000-8000-000000000005", base)
def v2(item):
document = self.document(item)
document["version"] = 2
document["entries"][0]["intakes"][0]["quantity"] = 1
return document
exchange.apply(self.db, v2(entry(base)))
local["updated_at"] = "2026-10-25T19:00:00+01:00"
remote["updated_at"] = "2026-10-25T20:00:00+01:00"
local["events"][0]["end_at"] = "2026-10-25T07:00:00+01:00"
remote["events"][0]["end_at"] = "2026-10-25T06:00:00+01:00"
exchange.apply(self.db, v2(local))
tied = entry("slr_60000000-0000-4000-8000-000000000006", base)
tied["updated_at"] = local["updated_at"]
with self.assertRaisesRegex(ValueError, "indeterminate edit order"):
exchange.apply(self.db, v2(tied))
self.assertEqual((1, 0), exchange.apply(self.db, v2(remote)))
self.assertEqual(remote["revision_id"], exchange.build(self.db)["entries"][0]["revision_id"])
self.assertEqual(remote["events"], exchange.build(self.db)["entries"][0]["events"])
self.assertEqual((0, 1), exchange.apply(self.db, v2(local)))
def test_draft_is_durable_but_not_exported_until_validated(self): def test_draft_is_durable_but_not_exported_until_validated(self):
revision = "slr_20000000-0000-4000-8000-000000000002" revision = "slr_20000000-0000-4000-8000-000000000002"
item = entry(revision) item = entry(revision)

View file

@ -76,12 +76,11 @@ class DeploymentToolsTest(unittest.TestCase):
) )
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text()) inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.7") self.assertEqual(inventory["product_version"], "0.1.6")
self.assertEqual(inventory["desktop_schema"], 37) self.assertEqual(inventory["desktop_schema"], 36)
self.assertEqual(inventory["android_schema"], 34) self.assertEqual(inventory["android_schema"], 32)
self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"])
self.assertIn("programs-v2", inventory["protocols"])
self.assertIn("program-executions-v1", inventory["protocols"]) self.assertIn("program-executions-v1", inventory["protocols"])
self.assertIn("sleep-diary-v2", inventory["protocols"]) self.assertIn("sleep-diary-v2", inventory["protocols"])
self.assertIn("cardio-sessions-v1", inventory["protocols"]) self.assertIn("cardio-sessions-v1", inventory["protocols"])
@ -115,7 +114,7 @@ class DeploymentToolsTest(unittest.TestCase):
text=True, text=True,
) )
self.assertEqual(linked.returncode, 0, linked.stderr) self.assertEqual(linked.returncode, 0, linked.stderr)
self.assertIn("trainlog 0.1.7", linked.stdout) self.assertIn("trainlog 0.1.6", linked.stdout)
imported = subprocess.run( imported = subprocess.run(
[ [
"python3", "python3",

View file

@ -128,25 +128,21 @@ class SyncErrorClassificationTest(unittest.TestCase):
) )
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self): def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
operation_budget = sync_peer_worker.MTP_OPERATION_TIMEOUT_SECONDS timeout = subprocess.TimeoutExpired(["adapter", "push"], 30)
timeout = subprocess.TimeoutExpired(
["adapter", "push"], operation_budget,
)
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
transport = Path(directory) / "transport" transport = Path(directory) / "transport"
transport.mkdir() transport.mkdir()
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout): with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
with self.assertRaisesRegex( with self.assertRaisesRegex(
RuntimeError, RuntimeError,
rf"^transport_timeout: MTP push did not finish within " r"^transport_timeout: MTP push did not finish within 30 seconds$",
rf"{operation_budget:g} seconds$",
) as caught: ) as caught:
sync_peer_worker.run_adapter( sync_peer_worker.run_adapter(
Path("/adapter"), Path("/adapter"),
"push", "push",
"peer_00000000-0000-4000-8000-000000000001", "peer_00000000-0000-4000-8000-000000000001",
transport, transport,
sync_peer_worker.time.monotonic() + operation_budget + 10, sync_peer_worker.time.monotonic() + 60,
) )
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout") self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")
@ -163,12 +159,6 @@ class SyncErrorClassificationTest(unittest.TestCase):
sync_orchestrator.failure_code(RuntimeError("revision conflict")), sync_orchestrator.failure_code(RuntimeError("revision conflict")),
"data_conflict", "data_conflict",
) )
self.assertEqual(
sync_orchestrator.failure_code(RuntimeError(
"desktop rejected Android generation: concurrent sleep diary revision"
)),
"data_conflict",
)
def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self): def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:

View file

@ -71,58 +71,6 @@ class GenerationTest(unittest.TestCase):
with closing(sqlite3.connect(destination)) as reopened: with closing(sqlite3.connect(destination)) as reopened:
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0]) self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
def test_rescheduled_program_requires_v2_capability_before_publication(self):
with closing(sqlite3.connect(self.database)) as db:
db.executescript("""
CREATE TABLE program_session_planning(
program_session_id TEXT PRIMARY KEY,current_for TEXT,state TEXT);
CREATE TABLE programs(program_id TEXT PRIMARY KEY,deleted_at TEXT);
CREATE TABLE program_sessions(
program_session_id TEXT PRIMARY KEY,program_id TEXT);
INSERT INTO programs VALUES('pg_fixture',NULL);
INSERT INTO program_sessions VALUES('pgs_fixture','pg_fixture');
INSERT INTO program_session_planning VALUES('pgs_fixture','2026-10-01','active');
PRAGMA user_version=37;
""")
db.commit()
artifacts = (
("history", "trainlog-mobile-export", 4, "history.json", True,
"controlled.py", ()),
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
"export_programs.py", ()),
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
"export_programs_v2.py", ()),
)
def exporter(tool, _extra, output, _snapshot):
if tool == "controlled.py":
output.write_text(json.dumps({"format": "trainlog-mobile-export", "version": 4}))
return
output.write_text(json.dumps({"format": "trainlog-programs",
"version": 2 if tool.endswith("_v2.py") else 1,
"programs": [], "deletions": []}))
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
mock.patch.object(generation, "run_export", exporter):
with self.assertRaisesRegex(generation.GenerationError, "Programs V2 capability"):
generation.capture_desktop(self.database, self.root / "legacy", PEER_B, RUN, GEN)
self.assertFalse((self.root / "legacy" / "staging" / GEN).exists())
legacy_stage, legacy_manifest, _ = generation.capture_desktop(
self.database, self.root / "legacy-domain-only", PEER_B, RUN, GEN,
set(), True,
)
self.assertFalse((legacy_stage / "programs-v1.json").exists())
self.assertEqual(["history"], [a["logical_name"] for a in legacy_manifest["artifacts"]])
stage, manifest, _ = generation.capture_desktop(
self.database, self.root / "modern", PEER_B, RUN,
"gen_55555555-5555-4555-8555-555555555555",
{"programs-v2"},
)
self.assertTrue((stage / "programs-v2.json").is_file())
self.assertFalse((stage / "programs-v1.json").exists())
self.assertEqual(["history", "programs-v2"],
[a["logical_name"] for a in manifest["artifacts"]])
def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self): def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"

View file

@ -26,19 +26,6 @@ class SyncPeerWorkerTest(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.temporary.cleanup() self.temporary.cleanup()
def test_rejected_android_generation_retains_consumer_diagnostic(self):
error = worker.rejected_android_generation_error(
{"diagnostic": "concurrent sleep diary revision"}
)
self.assertEqual(
str(error),
"desktop rejected Android generation: concurrent sleep diary revision",
)
self.assertEqual(
str(worker.rejected_android_generation_error({"diagnostic": ""})),
"desktop rejected Android generation",
)
def test_drive_adapter_uses_remaining_conversation_deadline(self): def test_drive_adapter_uses_remaining_conversation_deadline(self):
completed = subprocess.CompletedProcess([], 0, b"", b"") completed = subprocess.CompletedProcess([], 0, b"", b"")
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run: with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
@ -58,8 +45,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run: with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
worker.run_adapter( worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed", Path("/fixed/adapter"), "pull", "peer_fixed",
self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0, self.transport, 100.0, clock=lambda: 50.0,
clock=lambda: 50.0,
) )
self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"]) self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"])
@ -113,8 +99,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "transport_timeout"): with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
worker.run_adapter( worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed", Path("/fixed/adapter"), "pull", "peer_fixed",
self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0, self.transport, 100.0, clock=lambda: 50.0,
clock=lambda: 50.0,
) )
def test_delayed_correlated_generation_survives_multiple_polls(self): def test_delayed_correlated_generation_survives_multiple_polls(self):

View file

@ -126,73 +126,6 @@ class SyncdRoutingTest(unittest.TestCase):
self.assertEqual(1, len(commands)) self.assertEqual(1, len(commands))
self.assertEqual("sy_old", json.loads(state.read_text())["run_id"]) self.assertEqual("sy_old", json.loads(state.read_text())["run_id"])
def test_transient_terminal_request_remains_eligible_after_reconnect(self):
terminal_states = (
("failed", "device_unavailable"),
("failed", "sync_in_progress"),
("failed", "transport_timeout"),
("interrupted", "interrupted"),
)
for phase, error_code in terminal_states:
with (
self.subTest(phase=phase, error_code=error_code),
tempfile.TemporaryDirectory() as directory,
):
root = Path(directory)
request = "sr_22222222-2222-4222-8222-222222222222"
state = root / "trainlog.db.sync-run.json"
state.write_text(
json.dumps(
{
"phase": phase,
"result": phase,
"error_code": error_code,
"request_id": request,
"seen_request_ids": [request],
}
)
)
self._write_request(
root / syncd.FULL_GENERATION_REQUEST_NAME,
request,
"2026-09-25T07:49:01Z",
)
_, seen = syncd.load_request_state(state)
self.assertNotIn(request, seen)
self.assertEqual(
(request, "full_generation", []),
syncd.select_request(root, seen),
)
def test_non_transient_terminal_request_remains_consumed(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
request = "sr_22222222-2222-4222-8222-222222222222"
state = root / "trainlog.db.sync-run.json"
state.write_text(
json.dumps(
{
"phase": "failed",
"result": "failed",
"error_code": "data_conflict",
"request_id": request,
"seen_request_ids": [request],
}
)
)
self._write_request(
root / syncd.FULL_GENERATION_REQUEST_NAME,
request,
"2026-09-25T07:49:01Z",
)
_, seen = syncd.load_request_state(state)
self.assertIn(request, seen)
self.assertIsNone(syncd.select_request(root, seen))
def test_daemon_skips_mtp_probe_while_transport_lock_is_busy(self): def test_daemon_skips_mtp_probe_while_transport_lock_is_busy(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)

View file

@ -271,8 +271,8 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
root = load(args.input) root = load(args.input)
with connect_database(args.database) as db: with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36, 37): if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36):
fail("desktop schema v33-v37 required") fail("desktop schema v33-v36 required")
db.execute("BEGIN IMMEDIATE") db.execute("BEGIN IMMEDIATE")
apply(db, root) apply(db, root)
db.commit() db.commit()

View file

@ -395,8 +395,8 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
root = load(args.input) root = load(args.input)
with connect_database(args.database) as db: with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36, 37): if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36):
fail("desktop schema v34-v37 required") fail("desktop schema v34-v36 required")
db.execute("BEGIN IMMEDIATE") db.execute("BEGIN IMMEDIATE")
apply(db, root) apply(db, root)
db.commit() db.commit()

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0] version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise CausalError(f"desktop schema v20-v37 required, found v{version}") raise CausalError(f"desktop schema v20-v36 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str): def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection): def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0] version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise LifecycleError(f"desktop schema v19-v37 required, found v{version}") raise LifecycleError(f"desktop schema v19-v36 required, found v{version}")
def import_document(connection, document, own_transaction=True): def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37) SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
MEASUREMENT_FIELDS = ( MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
"""Build the raw export using only explicitly ordered SELECT statements.""" """Build the raw export using only explicitly ordered SELECT statements."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0] schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS: if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v37 requis") raise ValueError("schema desktop v15-v36 requis")
root = { root = {
"format": "TRAINLOG_AI_EXPORT", "format": "TRAINLOG_AI_EXPORT",

View file

@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection): def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations.""" """Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction owned_transaction = not connection.in_transaction
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37) supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v37 requis") raise ValueError("schema desktop v18-v36 requis")
columns = {row[1] for row in connection.execute( columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")} "PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns: if "published_at" not in columns:

View file

@ -28,9 +28,9 @@ def main():
args = parser.parse_args() args = parser.parse_args()
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
supported_versions = tuple(range(8, 38)) supported_versions = tuple(range(8, 37))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v37 requis") raise ValueError("schema desktop v8 à v36 requis")
known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute( known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment")) "SELECT equipment_id FROM custom_equipment"))

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
supported_versions = tuple(range(8, 38)) supported_versions = tuple(range(8, 37))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v37 requis") raise ValueError("schema desktop v8 à v36 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact") raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute( equipment = [dict(row) for row in connection.execute(

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database) con = connect_database(args.database)
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] not in ( if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ValueError("schema desktop v12-v37 requis") raise ValueError("schema desktop v12-v36 requis")
rows = con.execute( rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY" "ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
supported_versions = tuple(range(11, 38)) supported_versions = tuple(range(11, 37))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v37 requis") raise ValueError("schema desktop v11-v36 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact") raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = [] exercises = []

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
version = con.execute("PRAGMA user_version").fetchone()[0] version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute(""" rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC # CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current # catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database. # canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 38): if version not in range(8, 37):
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

View file

@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0] schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 38) supported_versions = range(11, 37)
legacy_v2_schema = args.version == 2 and schema_version == 10 legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema: if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v37 requis (v10 accepté pour export V2 explicite)") raise ValueError("schema desktop v11-v36 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute( if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")

View file

@ -16,7 +16,7 @@ def nullable(row, index):
def export_programs(database: Path) -> dict: def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db: with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ValueError("desktop schema v26 through v32 required") raise ValueError("desktop schema v26 through v32 required")
programs = [] programs = []
for row in db.execute( for row in db.execute(

View file

@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""Export the versioned desktop-owned Program planning projection."""
import argparse
import json
from contextlib import closing
from pathlib import Path
from export_programs import export_programs
from trainlog_sqlite import connect_database
def export_programs_v2(database: Path) -> dict:
# WHY: V1's strict session fields are frozen. V2 carries absolute current
# dates and ceded-slot state without changing the imported source date.
value = export_programs(database)
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 37:
raise ValueError("desktop schema v37 required for Programs V2")
for program in value["programs"]:
sequence = db.execute(
"SELECT revision_sequence FROM program_revision_sequences WHERE program_id=?",
(program["program_id"],),
).fetchone()
program["revision_sequence"] = sequence[0] if sequence is not None else 0
planning = {
row[0]: (row[1], row[2])
for row in db.execute(
"SELECT p.program_session_id,p.current_for,p.state "
"FROM program_session_planning p JOIN program_sessions s "
"ON s.program_session_id=p.program_session_id WHERE s.program_id=?",
(program["program_id"],),
)
}
for position, session in enumerate(program["sessions"]):
current, state = planning.get(
session["program_session_id"], (session["planned_for"], "active")
)
session["position"] = position
session["current_for"] = current
session["planning_state"] = state
value["version"] = 2
return value
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
args.output.write_text(
json.dumps(export_programs_v2(args.database), ensure_ascii=False,
sort_keys=True, separators=(",", ":")),
encoding="utf-8",
)
if __name__ == "__main__":
main()

View file

@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict: def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37) supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24, v25, v26 through v32 required") raise ValueError("desktop schema v24, v25, v26 through v32 required")
deliveries = [] deliveries = []

View file

@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database) db=connect_database(args.database)
try: try:
supported_versions = range(15, 38) supported_versions = range(15, 37)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v37 requis") raise ValueError("schema desktop v15-v36 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -61,8 +61,8 @@ def split_target(value: str) -> tuple[str, str]:
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0] version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in range(27, 38): if version not in range(27, 37):
raise CorrectionError(f"heart-rate correction requires schema v27-v37, found v{version}") raise CorrectionError(f"heart-rate correction requires schema v27-v36, found v{version}")
def _table_exists(db: sqlite3.Connection, table: str) -> bool: def _table_exists(db: sqlite3.Connection, table: str) -> bool:

View file

@ -282,8 +282,8 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
root = load(args.input) root = load(args.input)
with connect_database(args.database) as db: with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37): if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v30-v37 required") fail("desktop schema v30-v36 required")
db.execute("BEGIN IMMEDIATE") db.execute("BEGIN IMMEDIATE")
apply(db, root) apply(db, root)
db.commit() db.commit()

View file

@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction; INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind. any failure leaves no draft, child, or import-identity row behind.
""" """
supported_versions = range(18, 38) supported_versions = range(18, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v37 requis") raise ImportFailure("schema desktop v18-v36 requis")
draft, entries = validate(payload, connection) draft, entries = validate(payload, connection)
digest = canonical_digest(payload) digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -206,9 +206,9 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8")) payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
supported_versions = range(8, 38) supported_versions = range(8, 37)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v37 requis") fail("schema desktop v8 à v36 requis")
proof_path = args.mobile_export proof_path = args.mobile_export
if proof_path is None: if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")

View file

@ -53,9 +53,9 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False): def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction.""" """Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 38) supported_versions = range(8, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v37 requis") fail("schema desktop v8 à v36 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact") fail("causal equipment protection requires the staged artifact")
imported = skipped = 0 imported = skipped = 0

View file

@ -56,9 +56,9 @@ def load(path):
def apply_aliases(con, aliases): def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction.""" """Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 38) supported_versions = range(12, 37)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v37 requis") fail("schema desktop v12-v36 requis")
for source, canonical in aliases: for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None: fail(f"cible canonique absente: {canonical}") if target is None: fail(f"cible canonique absente: {canonical}")

View file

@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries.""" """Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 38) supported_versions = range(11, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v37 requis") raise ImportFailure("schema desktop v11-v36 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={} connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct # CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in ( if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
fail("schema desktop v17-v37 requis") fail("schema desktop v17-v36 requis")
applied=equal=ancestor=pending=0;seen=set() applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]: for item in root["exercises"]:
validate(item);eid=item["exercise_id"] validate(item);eid=item["exercise_id"]

View file

@ -847,9 +847,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table. # readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 38): if version not in range(5, 37):
raise ImportFailure( raise ImportFailure(
f"base desktop schema v5 à v37 attendue, version trouvée: {version}" f"base desktop schema v5 à v36 attendue, version trouvée: {version}"
) )
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
require_supported_schema(connection) require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v37") raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36")
if schema_version >= 20: if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute( protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False): def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0 roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON") db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 38) supported_versions = range(15, 37)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v37 requis") fail("schema desktop v15-v36 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set() seen=set()
for index,item in enumerate(root["exercise_feedback"]): for index,item in enumerate(root["exercise_feedback"]):

View file

@ -53,7 +53,6 @@ RUNTIME_TOOLS = [
"post_sync_ai_drive.py", "post_sync_ai_drive.py",
"export_session_preparations.py", "export_session_preparations.py",
"export_programs.py", "export_programs.py",
"export_programs_v2.py",
"program_execution_exchange.py", "program_execution_exchange.py",
] ]
@ -82,14 +81,6 @@ def main() -> int:
raise RuntimeError("compiled generation MTP adapter is missing") raise RuntimeError("compiled generation MTP adapter is missing")
if not sync_once.is_file(): if not sync_once.is_file():
raise RuntimeError("compiled legacy synchronization helper is missing") raise RuntimeError("compiled legacy synchronization helper is missing")
# WHY: web=auto can build a native executable without any embedded UI.
# CONTRACT: a rollout bundle must contain the frontend it will serve.
build_options = json.loads(subprocess.check_output(
["meson", "introspect", "--buildoptions", str(args.build)], text=True
))
web = next((option["value"] for option in build_options if option["name"] == "web"), None)
if web != "enabled":
raise RuntimeError("candidate requires an explicitly embedded Web frontend")
(args.output / "bin").mkdir(parents=True) (args.output / "bin").mkdir(parents=True)
(args.output / "libexec").mkdir(parents=True) (args.output / "libexec").mkdir(parents=True)
(args.output / "tools").mkdir(parents=True) (args.output / "tools").mkdir(parents=True)
@ -128,9 +119,9 @@ def main() -> int:
"source_commit": subprocess.check_output( "source_commit": subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(), ).strip(),
"product_version": "0.1.7", "product_version": "0.1.6",
"desktop_schema": 37, "desktop_schema": 36,
"android_schema": 34, "android_schema": 32,
"protocols": [ "protocols": [
"mobile-export-v3", "mobile-export-v3",
"mobile-history-v4", "mobile-history-v4",
@ -144,7 +135,6 @@ def main() -> int:
"session-preparations-v2", "session-preparations-v2",
"ai-session-drafts-v2", "ai-session-drafts-v2",
"programs-v1", "programs-v1",
"programs-v2",
"program-executions-v1", "program-executions-v1",
"sleep-diary-v2", "sleep-diary-v2",
"cardio-sessions-v1", "cardio-sessions-v1",

View file

@ -87,7 +87,7 @@ def load(path: Path) -> dict:
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]: def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v27 through v32 required") fail("desktop schema v27 through v32 required")
added = 0 added = 0
advanced = 0 advanced = 0

View file

@ -184,8 +184,8 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
root = load(args.input) root = load(args.input)
with connect_database(args.database) as db: with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36, 37): if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36):
fail("desktop schema v31-v37 required") fail("desktop schema v31-v36 required")
db.execute("BEGIN IMMEDIATE") db.execute("BEGIN IMMEDIATE")
apply(db, root) apply(db, root)
db.commit() db.commit()

View file

@ -5,7 +5,6 @@ import argparse
import json import json
import re import re
import sqlite3 import sqlite3
import uuid
from datetime import date, datetime from datetime import date, datetime
from pathlib import Path from pathlib import Path
@ -379,42 +378,6 @@ def revision_is_ancestor(db: sqlite3.Connection, parent_sql: str, owner_id: str,
return False return False
def concurrent_entry_winner(db: sqlite3.Connection, item: dict, local_revision: str,
wire_version: int) -> dict | None:
"""Choose a complete Sleep snapshot only when user-edit instants are ordered.
WHY: an import or ACK is not a new user edit. Revision creation times are
recorded by the editing clients, whereas generation times are transport
times. CONTRACT: only V2 sibling tips with the same entry envelope and no
deletion transition admit last-user-edit-wins. Equal or inconsistent
timestamps remain conflicts, never a peer-order tie break.
"""
if wire_version != 2:
fail("concurrent sleep diary revision")
row = db.execute(
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted,"
"r.created_at FROM sleep_diary_entries e JOIN sleep_diary_revisions r "
"ON r.revision_id=e.current_revision_id WHERE e.entry_id=? AND r.revision_id=?",
(item["entry_id"], local_revision),
).fetchone()
if (row is None or tuple(row[:3]) !=
(item["night_start_date"], item["night_end_date"], item["created_at"]) or
bool(row[4]) != item["deleted"] or row[3] != row[5]):
fail("concurrent sleep diary revision")
local_at = datetime.fromisoformat(timestamp(row[5]).replace("Z", "+00:00"))
remote_at = datetime.fromisoformat(timestamp(item["updated_at"]).replace("Z", "+00:00"))
if local_at == remote_at:
fail("concurrent sleep diary revision has indeterminate edit order")
if local_at < remote_at:
return None
current = db.execute(
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,"
"current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
(item["entry_id"],),
).fetchone()
return _persisted_entry(db, current)
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
validate(root) validate(root)
wire_version = root["version"] wire_version = root["version"]
@ -521,14 +484,8 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
unchanged += 1 unchanged += 1
continue continue
remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:] remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:]
winning_local = None
if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]: if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]:
winning_local = concurrent_entry_winner(db, item, local[0], wire_version) fail("concurrent sleep diary revision")
if known_owner is not None and winning_local is not None:
# A previously retained losing branch cannot cause a fresh
# reconciliation revision on every immutable replay.
unchanged += 1
continue
# WHY: one-tap Sleep creates several local revisions before its first # WHY: one-tap Sleep creates several local revisions before its first
# synchronization. The current snapshot carries their bounded lineage # synchronization. The current snapshot carries their bounded lineage
# by identity, so a new desktop must be able to seed the tip without # by identity, so a new desktop must be able to seed the tip without
@ -548,30 +505,17 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
"current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?", "current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
(item["night_start_date"], item["night_end_date"], item["updated_at"], (item["night_start_date"], item["night_end_date"], item["updated_at"],
item["revision_id"], int(item["deleted"]), item["entry_id"], local[0])) item["revision_id"], int(item["deleted"]), item["entry_id"], local[0]))
if known_owner is None: db.execute("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
db.execute( (item["revision_id"], item["entry_id"], item["parent_revision_id"], item["updated_at"],
"INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id," item["sleep_quality"], item["wake_quality"], item["day_form"], item["treatment_and_notes"]))
"created_at,sleep_quality,wake_quality,day_form,treatment_and_notes) " db.executemany("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
"VALUES(?,?,?,?,?,?,?,?)", [(item["revision_id"], event["event_id"], event["type"], event["start_at"], event["end_at"])
(item["revision_id"], item["entry_id"], item["parent_revision_id"], for event in item["events"]])
item["updated_at"], item["sleep_quality"], item["wake_quality"], db.executemany("INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
item["day_form"], item["treatment_and_notes"]), [(item["revision_id"], intake["intake_id"], intake["medication_id"],
) intake["medication_name"], intake["taken_at"], intake["dose_value"],
db.executemany( intake["dose_unit"], intake.get("quantity", 1), intake["note"], intake["created_at"])
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) " for intake in item["intakes"]])
"VALUES(?,?,?,?,?)",
[(item["revision_id"], event["event_id"], event["type"],
event["start_at"], event["end_at"]) for event in item["events"]],
)
db.executemany(
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,"
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?)",
[(item["revision_id"], intake["intake_id"], intake["medication_id"],
intake["medication_name"], intake["taken_at"], intake["dose_value"],
intake["dose_unit"], intake.get("quantity", 1), intake["note"],
intake["created_at"]) for intake in item["intakes"]],
)
# An imported revision has already crossed the synchronization # An imported revision has already crossed the synchronization
# boundary; recording both markers prevents it appearing as a draft. # boundary; recording both markers prevents it appearing as a draft.
db.execute( db.execute(
@ -583,45 +527,6 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
(item["entry_id"], item["revision_id"], root["generated_at"], (item["entry_id"], item["revision_id"], root["generated_at"],
item["revision_id"], root["generated_at"]), item["revision_id"], root["generated_at"]),
) )
if winning_local is not None:
successor = "slr_" + str(uuid.uuid4())
# INVARIANT: the successor's edit timestamp remains the winning
# user edit, not the later technical reconciliation time. Its
# complete payload is copied exactly; the losing branch stays
# immutable and addressable by its original revision identity.
db.execute(
"INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,"
"created_at,sleep_quality,wake_quality,day_form,treatment_and_notes) "
"VALUES(?,?,?,?,?,?,?,?)",
(successor, item["entry_id"], item["revision_id"],
winning_local["updated_at"], winning_local["sleep_quality"],
winning_local["wake_quality"], winning_local["day_form"],
winning_local["treatment_and_notes"]),
)
db.execute(
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) "
"SELECT ?,event_id,event_type,start_at,end_at "
"FROM sleep_diary_events WHERE revision_id=?",
(successor, winning_local["revision_id"]),
)
db.execute(
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,"
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) "
"SELECT ?,intake_id,medication_id,"
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at "
"FROM sleep_medication_intakes WHERE revision_id=?",
(successor, winning_local["revision_id"]),
)
db.execute(
"UPDATE sleep_diary_entries SET updated_at=?,current_revision_id=? "
"WHERE entry_id=? AND current_revision_id=?",
(winning_local["updated_at"], successor, item["entry_id"], item["revision_id"]),
)
db.execute(
"UPDATE sleep_diary_publication_state SET validated_revision_id=?,"
"validated_at=? WHERE entry_id=?",
(successor, root["generated_at"], item["entry_id"]),
)
applied += 1 applied += 1
return applied, unchanged return applied, unchanged
@ -637,8 +542,8 @@ def main() -> None:
import_command.add_argument("--database", required=True, type=Path) import_command.add_argument("--database", required=True, type=Path)
args = parser.parse_args() args = parser.parse_args()
with connect_database(args.database) as db: with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37): if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v30-v37 required") fail("desktop schema v30-v36 required")
if args.command == "export": if args.command == "export":
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True, args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
separators=(",", ":")), encoding="utf-8") separators=(",", ":")), encoding="utf-8")

View file

@ -51,11 +51,7 @@ MAX_GENERATION = 256 * 1024 * 1024
MAX_NAME = 64 MAX_NAME = 64
MAX_PATH = 240 MAX_PATH = 240
MAX_DIAGNOSTIC = 1024 MAX_DIAGNOSTIC = 1024
# WHY: interrupted MTP publications can leave immutable generations awaiting MAX_RETAINED_PER_PEER = 8
# an ACK alongside the two acknowledged lineage members kept for recovery.
# CONTRACT: admission remains bounded and never erases unresolved generations.
# INVARIANT: captured, published and waiting generations still consume a slot.
MAX_RETAINED_PER_PEER = 16
ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
HEX = re.compile(r"^[0-9a-f]{64}$") HEX = re.compile(r"^[0-9a-f]{64}$")
@ -86,8 +82,6 @@ ARTIFACTS = (
"session-preparations-v2.json", False, "export_session_preparations.py", ()), "session-preparations-v2.json", False, "export_session_preparations.py", ()),
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False, ("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
"export_programs.py", ()), "export_programs.py", ()),
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
"export_programs_v2.py", ()),
("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False, ("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False,
"sleep_diary_exchange.py", ("export",)), "sleep_diary_exchange.py", ("export",)),
) )
@ -227,9 +221,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37) supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24 through v37 required") raise GenerationError("desktop schema v24 through v36 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str: def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -372,9 +366,7 @@ def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path)
def capture_desktop(database: Path, owned_root: Path, consumer: str, def capture_desktop(database: Path, owned_root: Path, consumer: str,
run_id: str | None = None, generation_id: str | None = None, run_id: str | None = None, generation_id: str | None = None):
peer_capabilities: set[str] | None = None,
allow_program_skip: bool = False):
if not valid_id(consumer, "peer"): if not valid_id(consumer, "peer"):
raise GenerationError("invalid expected consumer") raise GenerationError("invalid expected consumer")
generation = generation_id or "gen_" + str(uuid.uuid4()) generation = generation_id or "gen_" + str(uuid.uuid4())
@ -413,35 +405,9 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
producer = peer_identity(source, "desktop") producer = peer_identity(source, "desktop")
source.commit() source.commit()
source.backup(target) source.backup(target)
with closing(connect_database(snapshot)) as planning_db:
has_planning_table = planning_db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' "
"AND name='program_session_planning'"
).fetchone() is not None
has_reschedule = has_planning_table and planning_db.execute(
"SELECT 1 FROM program_session_planning plan "
"JOIN program_sessions session ON session.program_session_id=plan.program_session_id "
"JOIN programs program ON program.program_id=session.program_id "
"WHERE program.deleted_at IS NULL LIMIT 1"
).fetchone() is not None
supports_v2 = peer_capabilities is not None and "programs-v2" in peer_capabilities
# CONTRACT: one generation contains exactly one Program revision per
# identity. A V1-only peer cannot preserve a changed current schedule.
# INVARIANT: reject before publication rather than claiming convergence.
skip_programs = has_reschedule and not supports_v2
if skip_programs and not allow_program_skip:
raise GenerationError("Programs V2 capability required for rescheduled programs")
if supports_v2 and not has_planning_table:
raise GenerationError("Programs V2 requires desktop schema v37")
descriptors = [] descriptors = []
total = 0 total = 0
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS: for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
if skip_programs and logical in ("programs-v1", "programs-v2"):
continue
if logical == "programs-v1" and supports_v2:
continue
if logical == "programs-v2" and not supports_v2:
continue
output = stage / filename output = stage / filename
run_export(tool, extra, output, snapshot) run_export(tool, extra, output, snapshot)
size, checksum = digest_file(output) size, checksum = digest_file(output)
@ -555,7 +521,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone() existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)", db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
(operation["operation_id"], generation, 0 if existed else 1)) (operation["operation_id"], generation, 0 if existed else 1))
programs_path = stage / ("programs-v2.json" if supports_v2 else "programs-v1.json") programs_path = stage / "programs-v1.json"
if programs_path.is_file(): if programs_path.is_file():
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT) captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
for deletion in captured_programs["deletions"]: for deletion in captured_programs["deletions"]:

View file

@ -131,8 +131,7 @@ def failure_code(error: BaseException) -> str:
return "device_unavailable" return "device_unavailable"
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic: if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
return "capacity_exhausted" return "capacity_exhausted"
if ("conflict" in diagnostic or "wrong consumer" in diagnostic or if "conflict" in diagnostic or "wrong consumer" in diagnostic:
"concurrent sleep diary revision" in diagnostic):
return "data_conflict" return "data_conflict"
return "internal_error" return "internal_error"

View file

@ -37,11 +37,7 @@ CAPS = {
ACK_RECOVERY_CAP = "generation-ack-recovery-v1" ACK_RECOVERY_CAP = "generation-ack-recovery-v1"
# WHY: a verified personal phone can need more than 30 seconds to publish an MTP_OPERATION_TIMEOUT_SECONDS = 30.0
# immutable Programs generation over MTP. CONTRACT: this bounds each adapter
# call independently of the configured overall conversation deadline.
# INVARIANT: an adapter overrun remains an ambiguous transport failure.
MTP_OPERATION_TIMEOUT_SECONDS = 90.0
MTP_POLL_INTERVAL_SECONDS = 3.0 MTP_POLL_INTERVAL_SECONDS = 3.0
@ -76,15 +72,6 @@ def emit(run_id: str, phase: str, **values: object) -> None:
print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True) print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True)
def rejected_android_generation_error(ack: dict) -> RuntimeError:
"""Keep the durable peer rejection visible without changing its ACK."""
# WHY: the browser cannot classify a known causal conflict when the worker
# drops the consumer's bounded diagnostic at this process boundary.
diagnostic = ack.get("diagnostic", "")
detail = f": {diagnostic}" if diagnostic else ""
return RuntimeError(f"desktop rejected Android generation{detail}")
def bounded(path: Path, limit: int = 65536) -> bytes: def bounded(path: Path, limit: int = 65536) -> bytes:
with path.open("rb") as stream: with path.open("rb") as stream:
raw = stream.read(limit + 1) raw = stream.read(limit + 1)
@ -818,7 +805,7 @@ def main() -> int:
deadline, deadline,
) )
if ack["result"] != "consumed": if ack["result"] != "consumed":
raise rejected_android_generation_error(ack) raise RuntimeError("desktop rejected Android generation")
emit( emit(
args.run_id, args.run_id,
"local_import_committed", "local_import_committed",
@ -831,13 +818,8 @@ def main() -> int:
# diagnostics to the separately bounded stderr channel. # diagnostics to the separately bounded stderr channel.
with redirect_stdout(sys.stderr): with redirect_stdout(sys.stderr):
stage, manifest, digest = generation.capture_desktop( stage, manifest, digest = generation.capture_desktop(
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id, args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id
set(peer["capabilities"]), True,
) )
program_domain_incompatible = not any(
artifact["logical_name"] in ("programs-v1", "programs-v2")
for artifact in manifest["artifacts"]
)
del stage del stage
published = generation.publish( published = generation.publish(
args.database, outgoing_id, args.transport_root / "desktop-objects" args.database, outgoing_id, args.transport_root / "desktop-objects"
@ -935,14 +917,6 @@ def main() -> int:
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",): if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
raise RuntimeError("generation evidence did not corroborate completion") raise RuntimeError("generation evidence did not corroborate completion")
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id) emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
if program_domain_incompatible:
# WHY: the old peer cannot retain the new absolute schedule, but its
# unrelated domains can still converge through the same generation.
# CONTRACT: never report whole-run success while its Program view is stale.
raise RuntimeError(
"Programs V2 capability required: other domains synchronized, "
"Program planning not published to this peer"
)
ai_post_sync = publish_ai_export_to_android(args, deadline) ai_post_sync = publish_ai_export_to_android(args, deadline)
report = { report = {
"format": "trainlog-sync-worker-report", "format": "trainlog-sync-worker-report",

View file

@ -23,11 +23,6 @@ REQUEST_NAME = "trainlog-sync-request-v1.json"
FULL_GENERATION_REQUEST_NAME = "trainlog-sync-full-generation-request-v1.json" FULL_GENERATION_REQUEST_NAME = "trainlog-sync-full-generation-request-v1.json"
SEEN_REQUEST_LIMIT = 64 SEEN_REQUEST_LIMIT = 64
REQUEST_ID = re.compile(r"sr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") REQUEST_ID = re.compile(r"sr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
RETRYABLE_REQUEST_ERROR_CODES = {
"device_unavailable",
"sync_in_progress",
"transport_timeout",
}
def request_stop( def request_stop(
@ -137,26 +132,8 @@ def load_request_state(path: Path) -> tuple[dict, list[str]]:
seen = [item for item in state.get("seen_request_ids", []) seen = [item for item in state.get("seen_request_ids", [])
if isinstance(item, str) and REQUEST_ID.fullmatch(item)] if isinstance(item, str) and REQUEST_ID.fullmatch(item)]
historical = state.get("request_id") historical = state.get("request_id")
retryable = ( if isinstance(historical, str) and REQUEST_ID.fullmatch(historical) and historical not in seen:
state.get("phase") == "interrupted" seen.append(historical)
or (
state.get("phase") == "failed"
and state.get("error_code") in RETRYABLE_REQUEST_ERROR_CODES
)
)
if isinstance(historical, str) and REQUEST_ID.fullmatch(historical):
# WHY: the daemon records admission before starting the bounded
# generation conversation. A transient disconnect can therefore leave
# the only Android arrival request in the seen ledger even though no
# terminal exchange was possible. CONTRACT: retry only explicit
# transient terminal states; completed and business-rejected requests
# remain exactly-once admissions. INVARIANT: retry keeps the stable
# request_id while the generation engine creates a fresh, idempotent
# run_id and revalidates all durable generation/ACK evidence.
if retryable:
seen = [item for item in seen if item != historical]
elif historical not in seen:
seen.append(historical)
return state, seen[-SEEN_REQUEST_LIMIT:] return state, seen[-SEEN_REQUEST_LIMIT:]
@ -166,8 +143,7 @@ def select_request(root: Path, seen: list[str]) -> tuple[str, str, list[str]] |
WHY: modern Android may publish the same intent on full-generation and WHY: modern Android may publish the same intent on full-generation and
legacy channels, while stale legacy files remain durable on MTP. CONTRACT: legacy channels, while stale legacy files remain durable on MTP. CONTRACT:
full-generation wins and an older alternate intent is retired. INVARIANT: full-generation wins and an older alternate intent is retired. INVARIANT:
one request_id owns at most one active daemon conversation; only an one request_id starts at most one daemon conversation.
explicitly transient terminal result permits its later readmission.
""" """
full = request(root / FULL_GENERATION_REQUEST_NAME) full = request(root / FULL_GENERATION_REQUEST_NAME)
legacy = request(root / REQUEST_NAME) legacy = request(root / REQUEST_NAME)

View file

@ -17,7 +17,7 @@
#include "trainlog/model.h" #include "trainlog/model.h"
#include "trainlog/status.h" #include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 37 #define TRAINLOG_DATABASE_SCHEMA_VERSION 36
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -63,17 +63,4 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
char **output_json, char **output_json,
size_t *output_size); size_t *output_size);
/* WHY: original Program dates are immutable source evidence. CONTRACT: the
* preview is read only; commit requires its exact fact/choice fingerprint,
* current Program revision and a stable operation ID. INVARIANT: one commit
* changes absolute planning dates in one transaction, never history rows. */
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
const char *program_id,
const char *operation_id,
const char *body,
size_t body_size,
bool commit,
char **output_json,
size_t *output_size);
#endif #endif

View file

@ -41,19 +41,6 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
char **output_json, char **output_json,
size_t *output_size); size_t *output_size);
/* WHY: the Sleep agenda needs factual completed sessions without fetching one
* detail per row. CONTRACT: start/end are bounded ISO instants; intervals
* intersect [start,end), and a session with no valid end is a point at start.
* Pages are ordered by factual start and stable ID. No state is mutated.
* INVARIANT: the caller owns output_json and must paginate until more=false. */
TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database,
const char *start,
const char *end,
size_t offset,
size_t limit,
char **output_json,
size_t *output_size);
/* CONTRACT: kind is one of preparation, proposal, draft, or history. The /* CONTRACT: kind is one of preparation, proposal, draft, or history. The
* returned detail is a factual snapshot and performs no lifecycle transition. */ * returned detail is a factual snapshot and performs no lifecycle transition. */
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database, TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,

View file

@ -239,7 +239,6 @@ trainlog_core_sources = files(
'src/web_sessions.c', 'src/web_sessions.c',
'src/web_session_deletions.c', 'src/web_session_deletions.c',
'src/web_programs.c', 'src/web_programs.c',
'src/web_program_reschedule.c',
'src/web_sleep.c', 'src/web_sleep.c',
'src/web_exercises.c', 'src/web_exercises.c',
'src/web_equipment.c', 'src/web_equipment.c',

View file

@ -1578,39 +1578,6 @@ static const char *const MIGRATE_V35_TO_V36_SQL =
"immutable');END;" "immutable');END;"
"PRAGMA user_version=36;COMMIT;"; "PRAGMA user_version=36;COMMIT;";
/* WHY: a Program's imported date is evidence, while an explicit reschedule
* owns its current target and an optional relinquishment of a future slot.
* CONTRACT: absent planning rows retain the original date and active state;
* operation identities bind replay to an exact request digest. INVARIANT:
* migration changes no execution, preparation, source date, or history row. */
static const char *const MIGRATE_V36_TO_V37_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS program_session_planning("
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
"ON DELETE RESTRICT,"
"current_for TEXT,"
"state TEXT NOT NULL CHECK(state IN('active','ceded')),"
"operation_id TEXT NOT NULL,updated_at TEXT NOT NULL,"
"CHECK((state='active' AND current_for IS NOT NULL) OR "
"(state='ceded' AND current_for IS NULL)));"
"CREATE TABLE IF NOT EXISTS program_reschedule_operations("
"operation_id TEXT PRIMARY KEY,"
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
"request_sha256 TEXT NOT NULL CHECK(length(request_sha256)=64),"
"expected_revision TEXT NOT NULL,"
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
"CREATE INDEX IF NOT EXISTS program_reschedule_operations_program "
"ON program_reschedule_operations(program_id,created_at);"
"CREATE TABLE IF NOT EXISTS program_revision_sequences("
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
"revision_sequence INTEGER NOT NULL CHECK(revision_sequence>=1));"
"CREATE TRIGGER IF NOT EXISTS programs_revision_sequence AFTER UPDATE OF revision_id "
"ON programs WHEN OLD.revision_id<>NEW.revision_id BEGIN "
"INSERT INTO program_revision_sequences(program_id,revision_sequence) "
"VALUES(NEW.program_id,1) ON CONFLICT(program_id) DO UPDATE SET "
"revision_sequence=revision_sequence+1;END;"
"PRAGMA user_version=37;COMMIT;";
/* WHY: schema v29 was already opened on the private 0.1.4 review installation /* WHY: schema v29 was already opened on the private 0.1.4 review installation
* before medication capture joined the same unreleased migration. CONTRACT: * before medication capture joined the same unreleased migration. CONTRACT:
* this additive repair is identical to the tail of v28 -> v29 and runs only * this additive repair is identical to the tail of v28 -> v29 and runs only
@ -2446,7 +2413,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 || version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 || version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
version == 36 || version == 37) { version == 36) {
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else { } else {
if (version == 1) { if (version == 1) {
@ -2618,9 +2585,6 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 36) { if (status == TRAINLOG_STATUS_OK && version < 36) {
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL); status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
} }
if (status == TRAINLOG_STATUS_OK && version < 37) {
status = execute_sql(database, MIGRATE_V36_TO_V37_SQL);
}
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database); status = ensure_v18_ai_draft_publication_state(database);
} }
@ -2640,7 +2604,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic, set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity, output_diagnostic_capacity,
version == 0 ? "create schema v37" : "migrate database to schema v37", version == 0 ? "create schema v36" : "migrate database to schema v36",
database->connection, database->connection,
SQLITE_ERROR); SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -646,26 +646,26 @@ static bool add_measurements(TrainlogDatabase *database,
static bool static bool
add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) { add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) {
static const char SQL[] = static const char SQL[] =
/* WHY: imported dates are historical after Program rescheduling.
* CONTRACT: select one pending, non-ceded session with its current date.
* INVARIANT: a present planning row with NULL current_for stays undated. */
"WITH eligible AS (SELECT ps2.program_id,ps2.program_session_id,ps2.title,ps2.position,"
"CASE WHEN pp.program_session_id IS NULL THEN ps2.planned_for ELSE pp.current_for END "
"AS current_for FROM program_sessions ps2 LEFT JOIN program_session_planning pp ON "
"pp.program_session_id=ps2.program_session_id LEFT JOIN program_session_executions pe2 "
"ON pe2.program_session_id=ps2.program_session_id WHERE COALESCE(pp.state,'active')"
"!='ceded' AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted')),"
"ranked AS (SELECT eligible.*,ROW_NUMBER() OVER (PARTITION BY program_id ORDER BY "
"CASE WHEN current_for IS NULL THEN 1 ELSE 0 END,current_for,position,"
"program_session_id) AS rank FROM eligible) "
"SELECT p.program_id,p.title,COUNT(ps.program_session_id)," "SELECT p.program_id,p.title,COUNT(ps.program_session_id),"
"SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END)," "SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END),"
"next.title,next.program_session_id,next.current_for " "(SELECT ps2.title FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 ON "
"pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1),"
"(SELECT ps2.program_session_id FROM program_sessions ps2 LEFT JOIN "
"program_session_executions pe2 ON pe2.program_session_id=ps2.program_session_id WHERE "
"ps2.program_id=p.program_id AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted') "
"ORDER BY CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position "
"LIMIT 1),"
"(SELECT ps2.planned_for FROM program_sessions ps2 LEFT JOIN program_session_executions "
"pe2 "
"ON pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1) "
"FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN " "FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN "
"program_session_executions pe ON pe.program_session_id=ps.program_session_id LEFT JOIN " "program_session_executions pe ON pe.program_session_id=ps.program_session_id WHERE "
"ranked next ON next.program_id=p.program_id AND next.rank=1 WHERE "
"p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at " "p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at "
"DESC,p.program_id " "DESC "
"LIMIT 1"; "LIMIT 1";
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
yyjson_mut_val *program = yyjson_mut_obj(document); yyjson_mut_val *program = yyjson_mut_obj(document);

View file

@ -1,645 +0,0 @@
#include "trainlog/web_programs.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <openssl/evp.h>
#include <sqlite3.h>
#include <yyjson.h>
#include "database_internal.h"
#include "trainlog/id.h"
typedef struct PlanningRow {
const char *id;
const char *original;
const char *current;
const char *execution;
const char *planning;
const char *assigned;
bool cede;
} PlanningRow;
static TrainlogStatus
planning_error(const char *reason, TrainlogStatus status, char **output, size_t *size) {
size_t length = strlen(reason) + 16U;
*output = malloc(length);
if (*output == NULL) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
*size = (size_t)snprintf(*output, length, "{\"error\":\"%s\"}", reason);
return status;
}
static bool planning_digest(
const char *first, size_t first_size, const char *second, size_t second_size, char output[65]) {
EVP_MD_CTX *context = EVP_MD_CTX_new();
unsigned char digest[32];
unsigned int length = 0U;
bool okay = context != NULL && EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
EVP_DigestUpdate(context, first, first_size) == 1 &&
EVP_DigestUpdate(context, second, second_size) == 1 &&
EVP_DigestFinal_ex(context, digest, &length) == 1 && length == 32U;
EVP_MD_CTX_free(context);
if (!okay) {
return false;
}
for (size_t index = 0U; index < 32U; ++index) {
(void)snprintf(output + index * 2U, 3U, "%02x", digest[index]);
}
output[64] = '\0';
return true;
}
static bool valid_civil_date(const char *value) {
unsigned year, month, day;
int days;
if (value == NULL || strlen(value) != 10U || value[4] != '-' || value[7] != '-' ||
sscanf(value, "%4u-%2u-%2u", &year, &month, &day) != 3 || year < 1900U || year > 9999U ||
month < 1U || month > 12U) {
return false;
}
for (size_t index = 0U; index < 10U; ++index) {
if (index != 4U && index != 7U && (value[index] < '0' || value[index] > '9')) {
return false;
}
}
static const int MONTH_DAYS[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
days = MONTH_DAYS[month - 1U];
if (month == 2U && (year % 4U == 0U && (year % 100U != 0U || year % 400U == 0U))) {
days = 29;
}
return day >= 1U && day <= (unsigned)days;
}
static bool
add_nullable(yyjson_mut_doc *document, yyjson_mut_val *object, const char *key, const char *value) {
return value == NULL ? yyjson_mut_obj_add_null(document, object, key)
: yyjson_mut_obj_add_strcpy(document, object, key, value);
}
static bool request_valid(yyjson_val *root, bool commit) {
yyjson_obj_iter iterator;
yyjson_val *key;
size_t count = 0U;
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != (commit ? 6U : 5U)) {
return false;
}
iterator = yyjson_obj_iter_with(root);
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
const char *name = yyjson_get_str(key);
if (strcmp(name, "start_session_id") != 0 && strcmp(name, "through_session_id") != 0 &&
strcmp(name, "start_date") != 0 && strcmp(name, "ceded_session_ids") != 0 &&
strcmp(name, "expected_revision") != 0 &&
!(commit && strcmp(name, "preview_sha256") == 0)) {
return false;
}
++count;
}
return count == (commit ? 6U : 5U) && yyjson_is_str(yyjson_obj_get(root, "start_session_id")) &&
yyjson_is_str(yyjson_obj_get(root, "through_session_id")) &&
yyjson_is_str(yyjson_obj_get(root, "expected_revision")) &&
yyjson_get_len(yyjson_obj_get(root, "start_session_id")) <= 128U &&
yyjson_get_len(yyjson_obj_get(root, "through_session_id")) <= 128U &&
yyjson_get_len(yyjson_obj_get(root, "expected_revision")) <= 128U &&
valid_civil_date(yyjson_get_str(yyjson_obj_get(root, "start_date"))) &&
yyjson_is_arr(yyjson_obj_get(root, "ceded_session_ids")) &&
yyjson_arr_size(yyjson_obj_get(root, "ceded_session_ids")) <= 64U &&
(!commit || (yyjson_is_str(yyjson_obj_get(root, "preview_sha256")) &&
yyjson_get_len(yyjson_obj_get(root, "preview_sha256")) == 64U));
}
static bool has_preparation(TrainlogDatabase *database, const char *session_id) {
sqlite3_stmt *statement = NULL;
bool present = true;
if (sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM session_preparations WHERE "
"source_program_session_id=? AND withdrawn_at IS NULL LIMIT 1",
-1,
&statement,
NULL) == SQLITE_OK &&
sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) == SQLITE_OK) {
present = sqlite3_step(statement) != SQLITE_DONE;
}
(void)sqlite3_finalize(statement);
return present;
}
static TrainlogStatus peer_planning_fresh(TrainlogDatabase *database) {
static const char SQL[] =
"SELECT EXISTS(SELECT 1 FROM "
"(SELECT DISTINCT consumer_peer_id FROM sync_generations "
"WHERE producer_kind='desktop') peer WHERE "
"NOT EXISTS(SELECT 1 FROM sync_generations generation "
"JOIN sync_generation_artifacts artifact "
"ON artifact.generation_id=generation.generation_id "
"WHERE generation.generation_id=(SELECT latest.generation_id "
"FROM sync_generations latest WHERE latest.consumer_peer_id=peer.consumer_peer_id "
"AND latest.producer_kind='desktop' "
"ORDER BY julianday(latest.generated_at) DESC,latest.generation_id DESC LIMIT 1) "
"AND generation.status='acknowledged' AND artifact.logical_name='programs-v2' "
"AND julianday(generation.acknowledged_at)>=julianday('now','-5 minutes')))";
sqlite3_stmt *statement = NULL;
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
status =
sqlite3_column_int(statement, 0) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT;
}
(void)sqlite3_finalize(statement);
return status;
}
static TrainlogStatus build_preview(TrainlogDatabase *database,
const char *program_id,
yyjson_val *request,
char **output,
size_t *output_size) {
char *detail = NULL;
size_t detail_size = 0U;
yyjson_doc *source = NULL;
yyjson_mut_doc *response = NULL;
yyjson_val *program;
yyjson_val *sessions;
yyjson_val *ceded;
yyjson_mut_val *root;
yyjson_mut_val *moves;
PlanningRow rows[64] = {0};
const char *slots[64] = {0};
size_t first = 64U;
size_t last = 64U;
size_t count;
size_t slot_count = 0U;
size_t next_slot = 0U;
char fingerprint[65];
char *choices = NULL;
size_t choices_size = 0U;
TrainlogStatus status =
trainlog_web_programs_detail_json(database, program_id, &detail, &detail_size);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
source = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
program = source == NULL ? NULL : yyjson_doc_get_root(source);
sessions = program == NULL ? NULL : yyjson_obj_get(program, "sessions");
ceded = yyjson_obj_get(request, "ceded_session_ids");
count = yyjson_arr_size(sessions);
if (program == NULL || count > 64U ||
!yyjson_equals_str(yyjson_obj_get(program, "state"), "active") ||
!yyjson_equals_str(yyjson_obj_get(program, "revision_id"),
yyjson_get_str(yyjson_obj_get(request, "expected_revision"))) ||
!yyjson_is_str(yyjson_obj_get(program, "end_date"))) {
status = planning_error(
"stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
for (size_t index = 0U; index < count; ++index) {
yyjson_val *session = yyjson_arr_get(sessions, index);
rows[index].id = yyjson_get_str(yyjson_obj_get(session, "program_session_id"));
rows[index].original = yyjson_get_str(yyjson_obj_get(session, "planned_for"));
rows[index].current = yyjson_get_str(yyjson_obj_get(session, "current_for"));
rows[index].execution = yyjson_get_str(yyjson_obj_get(session, "execution_state"));
rows[index].planning = yyjson_get_str(yyjson_obj_get(session, "planning_state"));
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "start_session_id"))) ==
0) {
first = index;
}
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "through_session_id"))) ==
0) {
last = index;
}
}
if (first >= count || last >= count || first > last ||
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
status = planning_error(
"invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done;
}
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
yyjson_val *id = yyjson_arr_get(ceded, index);
bool matched = false;
if (!yyjson_is_str(id) || yyjson_get_len(id) > 128U) {
status = planning_error(
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done;
}
for (size_t candidate = first; candidate <= last; ++candidate) {
if (strcmp(rows[candidate].id, yyjson_get_str(id)) == 0 && !rows[candidate].cede) {
rows[candidate].cede = true;
matched = true;
break;
}
}
if (!matched) {
status = planning_error(
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done;
}
}
for (size_t index = first; index <= last; ++index) {
if (strcmp(rows[index].execution, "todo") != 0 ||
has_preparation(database, rows[index].id) || rows[index].original == NULL ||
!valid_civil_date(rows[index].original)) {
status = planning_error(
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
if (strcmp(rows[index].planning, "ceded") == 0 && !rows[index].cede) {
status = planning_error(
"ceded_session_requires_selection", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
// WHY: a second explicit decision starts from the durable current
// schedule. A ceded identity has no current date, so its original slot
// is available only when the user selects that identity again.
const char *slot = rows[index].current == NULL ? rows[index].original : rows[index].current;
if (strcmp(slot, yyjson_get_str(yyjson_obj_get(request, "start_date"))) >= 0) {
slots[slot_count++] = slot;
}
}
// Selected Program slots define permitted days; no rest day is synthesized.
// Position order may differ from dates, so sort candidate dates explicitly.
for (size_t outer = 0U; outer < slot_count; ++outer) {
for (size_t inner = outer + 1U; inner < slot_count; ++inner) {
if (strcmp(slots[inner], slots[outer]) < 0) {
const char *swap = slots[outer];
slots[outer] = slots[inner];
slots[inner] = swap;
}
}
}
// A previously ceded original day may now be occupied by an active
// session. It remains one calendar slot for this operation.
size_t unique_slots = 0U;
for (size_t index = 0U; index < slot_count; ++index) {
if (unique_slots == 0U || strcmp(slots[index], slots[unique_slots - 1U]) != 0) {
slots[unique_slots++] = slots[index];
}
}
slot_count = unique_slots;
for (size_t index = first; index <= last; ++index) {
if (rows[index].cede) {
continue;
}
if (next_slot >= slot_count ||
strcmp(slots[next_slot], yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
status = planning_error("insufficient_or_duplicate_slots",
TRAINLOG_STATUS_INVALID_ARGUMENT,
output,
output_size);
goto done;
}
rows[index].assigned = slots[next_slot++];
}
// CONTRACT: the choice digest includes stable identities in their supplied
// order. The fact digest includes the complete current Program projection.
{
yyjson_mut_doc *choice_document = yyjson_mut_doc_new(NULL);
yyjson_mut_val *choice_root =
choice_document == NULL ? NULL : yyjson_mut_obj(choice_document);
if (choice_document == NULL || choice_root == NULL) {
yyjson_mut_doc_free(choice_document);
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
yyjson_mut_doc_set_root(choice_document, choice_root);
(void)yyjson_mut_obj_add_strcpy(
choice_document,
choice_root,
"start_session_id",
yyjson_get_str(yyjson_obj_get(request, "start_session_id")));
(void)yyjson_mut_obj_add_strcpy(
choice_document,
choice_root,
"through_session_id",
yyjson_get_str(yyjson_obj_get(request, "through_session_id")));
(void)yyjson_mut_obj_add_strcpy(choice_document,
choice_root,
"start_date",
yyjson_get_str(yyjson_obj_get(request, "start_date")));
yyjson_mut_val *ids = yyjson_mut_arr(choice_document);
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
(void)yyjson_mut_arr_add_strcpy(
choice_document, ids, yyjson_get_str(yyjson_arr_get(ceded, index)));
}
(void)yyjson_mut_obj_add_val(choice_document, choice_root, "ceded_session_ids", ids);
choices = yyjson_mut_write(choice_document, YYJSON_WRITE_NOFLAG, &choices_size);
yyjson_mut_doc_free(choice_document);
}
if (choices == NULL ||
!planning_digest(detail, detail_size, choices, choices_size, fingerprint)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
response = yyjson_mut_doc_new(NULL);
root = response == NULL ? NULL : yyjson_mut_obj(response);
moves = response == NULL ? NULL : yyjson_mut_arr(response);
if (response == NULL || root == NULL || moves == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
yyjson_mut_doc_set_root(response, root);
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
(void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
(void)yyjson_mut_obj_add_strcpy(
response, root, "revision_id", yyjson_get_str(yyjson_obj_get(program, "revision_id")));
(void)yyjson_mut_obj_add_strcpy(response, root, "preview_sha256", fingerprint);
(void)yyjson_mut_obj_add_val(response, root, "moves", moves);
for (size_t index = first; index <= last; ++index) {
yyjson_mut_val *move = yyjson_mut_obj(response);
(void)yyjson_mut_obj_add_strcpy(response, move, "program_session_id", rows[index].id);
(void)add_nullable(response, move, "original_for", rows[index].original);
(void)add_nullable(response, move, "old_for", rows[index].current);
(void)add_nullable(response, move, "new_for", rows[index].assigned);
const char *change =
rows[index].cede ? "ceded"
: rows[index].current != NULL && strcmp(rows[index].current, rows[index].assigned) == 0
? "unchanged"
: "rescheduled";
(void)yyjson_mut_obj_add_strcpy(response, move, "change", change);
(void)yyjson_mut_arr_add_val(moves, move);
}
*output = yyjson_mut_write(response, YYJSON_WRITE_NOFLAG, output_size);
status = *output == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
done:
free(choices);
yyjson_mut_doc_free(response);
yyjson_doc_free(source);
free(detail);
return status;
}
static TrainlogStatus apply_preview(TrainlogDatabase *database,
const char *operation_id,
const char *program_id,
const char *expected_revision,
const char *request_digest,
const char *preview,
char **output,
size_t *output_size) {
yyjson_doc *document = yyjson_read(preview, strlen(preview), YYJSON_READ_NOFLAG);
yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document);
yyjson_val *moves = root == NULL ? NULL : yyjson_obj_get(root, "moves");
sqlite3_stmt *statement = NULL;
char revision[80];
char now[32];
time_t clock_now = time(NULL);
struct tm utc;
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
if (moves == NULL || clock_now == (time_t)-1 || gmtime_r(&clock_now, &utc) == NULL ||
strftime(now, sizeof(now), "%Y-%m-%dT%H:%M:%SZ", &utc) == 0U ||
trainlog_id_generate("pgr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
if (sqlite3_prepare_v2(database->connection,
"UPDATE programs SET revision_id=?,updated_at=? WHERE program_id=? "
"AND revision_id=? AND deleted_at IS NULL AND state='active'",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
status = TRAINLOG_STATUS_CONFLICT;
goto done;
}
(void)sqlite3_finalize(statement);
statement = NULL;
if (sqlite3_prepare_v2(database->connection,
"INSERT INTO program_session_planning "
"(program_session_id,current_for,state,operation_id,updated_at) "
"VALUES(?,?,?,?,?) ON CONFLICT(program_session_id) DO UPDATE SET "
"current_for=excluded.current_for,state=excluded.state,"
"operation_id=excluded.operation_id,updated_at=excluded.updated_at",
-1,
&statement,
NULL) != SQLITE_OK) {
goto done;
}
for (size_t index = 0U; index < yyjson_arr_size(moves); ++index) {
yyjson_val *move = yyjson_arr_get(moves, index);
yyjson_val *new_for = yyjson_obj_get(move, "new_for");
if (yyjson_equals_str(yyjson_obj_get(move, "change"), "unchanged")) {
continue;
}
sqlite3_reset(statement);
sqlite3_clear_bindings(statement);
if (sqlite3_bind_text(statement,
1,
yyjson_get_str(yyjson_obj_get(move, "program_session_id")),
-1,
SQLITE_TRANSIENT) != SQLITE_OK ||
(yyjson_is_null(new_for)
? sqlite3_bind_null(statement, 2)
: sqlite3_bind_text(
statement, 2, yyjson_get_str(new_for), -1, SQLITE_TRANSIENT)) != SQLITE_OK ||
sqlite3_bind_text(statement,
3,
yyjson_equals_str(yyjson_obj_get(move, "change"), "ceded") ? "ceded"
: "active",
-1,
SQLITE_STATIC) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
goto done;
}
}
(void)sqlite3_finalize(statement);
statement = NULL;
{
size_t length = strlen(preview) + strlen(revision) + 52U;
*output = malloc(length);
if (*output == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
*output_size = (size_t)snprintf(*output,
length,
"%.*s,\"applied\":true,\"new_revision_id\":\"%s\"}",
(int)strlen(preview) - 1,
preview,
revision);
}
if (sqlite3_prepare_v2(database->connection,
"INSERT INTO program_reschedule_operations VALUES(?,?,?,?,?,?)",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, request_digest, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, *output, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 6, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
goto done;
}
status = TRAINLOG_STATUS_OK;
done:
(void)sqlite3_finalize(statement);
yyjson_doc_free(document);
return status;
}
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
const char *program_id,
const char *operation_id,
const char *body,
size_t body_size,
bool commit,
char **output_json,
size_t *output_size) {
yyjson_doc *request_doc = NULL;
yyjson_val *request;
sqlite3_stmt *statement = NULL;
char request_digest[65];
char *preview = NULL;
size_t preview_size = 0U;
TrainlogStatus status;
if (database == NULL || program_id == NULL || body == NULL || output_json == NULL ||
output_size == NULL || (commit && (operation_id == NULL || operation_id[0] == '\0')) ||
body_size > 16384U || (operation_id != NULL && strlen(operation_id) > 128U)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
request_doc = yyjson_read(body, body_size, YYJSON_READ_NOFLAG);
request = request_doc == NULL ? NULL : yyjson_doc_get_root(request_doc);
if (!request_valid(request, commit) ||
!planning_digest(body, body_size, "", 0U, request_digest)) {
yyjson_doc_free(request_doc);
return planning_error("invalid_reschedule_request",
TRAINLOG_STATUS_INVALID_ARGUMENT,
output_json,
output_size);
}
if (sqlite3_exec(
database->connection, commit ? "BEGIN IMMEDIATE" : "BEGIN", NULL, NULL, NULL) !=
SQLITE_OK) {
yyjson_doc_free(request_doc);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (commit) {
if (sqlite3_prepare_v2(database->connection,
"SELECT program_id,request_sha256,response_json FROM "
"program_reschedule_operations WHERE operation_id=?",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
if (sqlite3_step(statement) == SQLITE_ROW) {
const char *known_program = (const char *)sqlite3_column_text(statement, 0);
const char *known_digest = (const char *)sqlite3_column_text(statement, 1);
const char *saved = (const char *)sqlite3_column_text(statement, 2);
if (known_program != NULL && known_digest != NULL && saved != NULL &&
strcmp(known_program, program_id) == 0 &&
strcmp(known_digest, request_digest) == 0) {
*output_json = strdup(saved);
*output_size = *output_json == NULL ? 0U : strlen(*output_json);
status = *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
} else {
status = planning_error("operation_identity_conflict",
TRAINLOG_STATUS_CONFLICT,
output_json,
output_size);
}
goto finish;
}
(void)sqlite3_finalize(statement);
statement = NULL;
if (sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_requests WHERE request_id=? "
"UNION ALL SELECT 1 FROM program_deletions WHERE request_id=?",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
int cross_command = sqlite3_step(statement);
(void)sqlite3_finalize(statement);
statement = NULL;
if (cross_command != SQLITE_DONE) {
status = cross_command == SQLITE_ROW ? planning_error("operation_identity_conflict",
TRAINLOG_STATUS_CONFLICT,
output_json,
output_size)
: TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
// WHY: the desktop's last imported draft snapshot cannot prove that
// Android stayed idle while offline. A recent acknowledged V2 Program
// generation is required; Android independently rejects a changed
// schedule if a Program draft starts after that acknowledgement.
status = peer_planning_fresh(database);
if (status != TRAINLOG_STATUS_OK) {
if (status == TRAINLOG_STATUS_CONFLICT) {
status = planning_error("program_peer_not_fresh", status, output_json, output_size);
}
goto finish;
}
}
status = build_preview(database, program_id, request, &preview, &preview_size);
if (status != TRAINLOG_STATUS_OK) {
if (preview != NULL) {
*output_json = preview;
*output_size = preview_size;
preview = NULL;
}
goto finish;
}
if (!commit) {
*output_json = preview;
*output_size = preview_size;
preview = NULL;
goto finish;
}
{
yyjson_doc *preview_doc = yyjson_read(preview, preview_size, YYJSON_READ_NOFLAG);
yyjson_val *preview_root = preview_doc == NULL ? NULL : yyjson_doc_get_root(preview_doc);
const char *fingerprint = yyjson_get_str(yyjson_obj_get(preview_root, "preview_sha256"));
if (fingerprint == NULL ||
strcmp(fingerprint, yyjson_get_str(yyjson_obj_get(request, "preview_sha256"))) != 0) {
status = planning_error(
"stale_reschedule_preview", TRAINLOG_STATUS_CONFLICT, output_json, output_size);
} else {
status = apply_preview(database,
operation_id,
program_id,
yyjson_get_str(yyjson_obj_get(request, "expected_revision")),
request_digest,
preview,
output_json,
output_size);
}
yyjson_doc_free(preview_doc);
}
finish:
(void)sqlite3_finalize(statement);
free(preview);
yyjson_doc_free(request_doc);
if (status == TRAINLOG_STATUS_OK && commit) {
if (sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
return status;
}
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
if (status != TRAINLOG_STATUS_OK && *output_json == NULL) {
return planning_error("reschedule_unavailable", status, output_json, output_size);
}
return status;
}

View file

@ -365,12 +365,9 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database,
!add_column_text(document, session, "title", statement, 2) || !add_column_text(document, session, "title", statement, 2) ||
!add_column_text(document, session, "session_type", statement, 3) || !add_column_text(document, session, "session_type", statement, 3) ||
!add_column_text(document, session, "planned_for", statement, 4) || !add_column_text(document, session, "planned_for", statement, 4) ||
!add_column_text(document, session, "current_for", statement, 5) || !add_column_text(document, session, "note", statement, 5) ||
!add_column_text(document, session, "planning_state", statement, 6) || !add_column_text(document, session, "execution_state", statement, 6) ||
!add_column_text(document, session, "note", statement, 7) || !add_column_text(document, session, "execution_session_id", statement, 7) ||
!add_column_text(document, session, "execution_state", statement, 8) ||
!add_column_text(document, session, "execution_session_id", statement, 9) ||
!add_column_text(document, session, "execution_started_at", statement, 10) ||
!yyjson_mut_obj_add_val(document, session, "occurrences", entries) || !yyjson_mut_obj_add_val(document, session, "occurrences", entries) ||
!yyjson_mut_arr_add_val(sessions, session)) { !yyjson_mut_arr_add_val(sessions, session)) {
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
@ -385,24 +382,15 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
yyjson_mut_val *sessions) { yyjson_mut_val *sessions) {
static const char SESSION_SQL[] = static const char SESSION_SQL[] =
"SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for," "SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
"CASE WHEN pp.state='ceded' THEN NULL ELSE COALESCE(pp.current_for,ps.planned_for) END," "ps.note,CASE WHEN pe.state='completed' THEN 'completed' "
"COALESCE(pp.state,'active'),ps.note,"
"CASE WHEN pe.state='completed' THEN 'completed' "
"WHEN pe.state='in_progress' THEN 'in_progress' " "WHEN pe.state='in_progress' THEN 'in_progress' "
"WHEN pe.state='deleted' THEN 'deleted' " "WHEN pe.state='deleted' THEN 'deleted' "
"WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE " "WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE "
"sp.source_program_session_id=ps.program_session_id AND sp.withdrawn_at IS NULL) " "sp.source_program_session_id=ps.program_session_id AND sp.withdrawn_at IS NULL) "
"THEN 'prepared' ELSE 'todo' END," "THEN 'prepared' ELSE 'todo' END,"
"CASE WHEN pe.state='deleted' THEN NULL ELSE pe.session_id END," "CASE WHEN pe.state='deleted' THEN NULL ELSE pe.session_id END "
/* WHY: completion belongs to the linked session's actual start date. "FROM program_sessions ps LEFT JOIN program_session_executions pe "
* CONTRACT: expose that timestamp only for completed executions.
* INVARIANT: the imported planned_for date and history are read only. */
"CASE WHEN pe.state='completed' THEN actual.started_at ELSE NULL END "
"FROM program_sessions ps LEFT JOIN program_session_planning pp "
"ON pp.program_session_id=ps.program_session_id "
"LEFT JOIN program_session_executions pe "
"ON pe.program_session_id=ps.program_session_id " "ON pe.program_session_id=ps.program_session_id "
"LEFT JOIN sessions actual ON actual.session_id=pe.session_id "
"WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id"; "WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id";
sqlite3_stmt *session_statement = NULL; sqlite3_stmt *session_statement = NULL;
int step; int step;
@ -1171,25 +1159,7 @@ static TrainlogStatus program_replay_archive(TrainlogDatabase *database,
result = SQLITE_OK; result = SQLITE_OK;
} }
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
if (result != SQLITE_OK || *found) { return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
statement = NULL;
result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_reschedule_operations WHERE operation_id=?",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
(void)sqlite3_finalize(statement);
return result == SQLITE_ROW ? TRAINLOG_STATUS_CONFLICT
: result == SQLITE_DONE ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;
} }
static TrainlogStatus program_apply_archive(TrainlogDatabase *database, static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
@ -1381,9 +1351,7 @@ static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database,
} }
result = sqlite3_prepare_v2(database->connection, result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_requests WHERE request_id=?1 " "SELECT 1 FROM program_requests WHERE request_id=?1",
"UNION ALL SELECT 1 FROM program_reschedule_operations "
"WHERE operation_id=?1",
-1, -1,
&statement, &statement,
NULL); NULL);
@ -1635,7 +1603,7 @@ static char *program_build_preparation_body(yyjson_val *session,
command, command,
root, root,
"planned_for", "planned_for",
yyjson_val_mut_copy(command, yyjson_obj_get(session, "current_for"))); yyjson_val_mut_copy(command, yyjson_obj_get(session, "planned_for")));
(void)yyjson_mut_obj_add_val( (void)yyjson_mut_obj_add_val(
command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note"))); command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note")));
(void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft"); (void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft");
@ -1678,10 +1646,6 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
yyjson_doc_free(document); yyjson_doc_free(document);
return TRAINLOG_STATUS_NOT_FOUND; return TRAINLOG_STATUS_NOT_FOUND;
} }
if (!yyjson_equals_str(yyjson_obj_get(session, "planning_state"), "active")) {
yyjson_doc_free(document);
return TRAINLOG_STATUS_CONFLICT;
}
body = program_build_preparation_body(session, program_id, program_session_id, &body_size); body = program_build_preparation_body(session, program_id, program_session_id, &body_size);
yyjson_doc_free(document); yyjson_doc_free(document);
if (body == NULL) { if (body == NULL) {

View file

@ -705,22 +705,14 @@ static enum MHD_Result queue_owned_programs_json(struct MHD_Connection *connecti
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL); return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
} }
if (status == TRAINLOG_STATUS_CONFLICT) { if (status == TRAINLOG_STATUS_CONFLICT) {
enum MHD_Result result =
queue_json(connection,
MHD_HTTP_CONFLICT,
json == NULL ? "{\"error\":\"program_conflict\"}\n" : json,
NULL);
free(json); free(json);
return result; return queue_json(
connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL);
} }
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) { if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
enum MHD_Result result =
queue_json(connection,
MHD_HTTP_UNPROCESSABLE_CONTENT,
json == NULL ? "{\"error\":\"invalid_program\"}\n" : json,
NULL);
free(json); free(json);
return result; return queue_json(
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL);
} }
return queue_owned_sessions_json(connection, status, json, json_size); return queue_owned_sessions_json(connection, status, json, json_size);
} }
@ -938,8 +930,6 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
static const char PREFIX[] = "/api/v1/sessions/program/"; static const char PREFIX[] = "/api/v1/sessions/program/";
static const char ARCHIVE_SUFFIX[] = "/archive"; static const char ARCHIVE_SUFFIX[] = "/archive";
static const char DELETE_SUFFIX[] = "/delete"; static const char DELETE_SUFFIX[] = "/delete";
static const char PREVIEW_SUFFIX[] = "/reschedule/preview";
static const char APPLY_SUFFIX[] = "/reschedule/apply";
const char *identity; const char *identity;
const char *sessions_marker; const char *sessions_marker;
char *json = NULL; char *json = NULL;
@ -960,38 +950,6 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
identity = url + strlen(PREFIX); identity = url + strlen(PREFIX);
identity_length = strlen(identity); identity_length = strlen(identity);
sessions_marker = strstr(identity, "/sessions/"); sessions_marker = strstr(identity, "/sessions/");
if ((identity_length > strlen(PREVIEW_SUFFIX) &&
strcmp(identity + identity_length - strlen(PREVIEW_SUFFIX), PREVIEW_SUFFIX) == 0) ||
(identity_length > strlen(APPLY_SUFFIX) &&
strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0)) {
bool commit = strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0;
size_t suffix_length = commit ? strlen(APPLY_SUFFIX) : strlen(PREVIEW_SUFFIX);
char program_id[TRAINLOG_ID_MAX + 1U];
const char *operation_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
if (!program_mutation_allowed(context, connection) ||
!copy_path_component(
program_id, sizeof(program_id), identity, identity_length - suffix_length) ||
(commit && (operation_id == NULL || operation_id[0] == '\0'))) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
status = trainlog_web_programs_reschedule_json(context->database,
program_id,
operation_id,
request->body,
request->body_size,
commit,
&json,
&json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
if (identity_length > strlen(ARCHIVE_SUFFIX) && if (identity_length > strlen(ARCHIVE_SUFFIX) &&
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) { strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
return handle_program_archive(context, connection, identity, identity_length, method); return handle_program_archive(context, connection, identity, identity_length, method);
@ -1935,14 +1893,6 @@ static enum MHD_Result handle_request(void *closure,
context->database, search, offset, limit, &json, &json_size); context->database, search, offset, limit, &json, &json_size);
return queue_owned_sessions_json(connection, status, json, json_size); return queue_owned_sessions_json(connection, status, json, json_size);
} }
if (strcmp(url, "/api/v1/sessions/sport") == 0) {
const char *start =
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start");
const char *end = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "end");
TrainlogStatus status = trainlog_web_sessions_sport_json(
context->database, start, end, offset, limit, &json, &json_size);
return queue_owned_sessions_json(connection, status, json, json_size);
}
for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) { for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) {
if (strcmp(url, collections[index].path) == 0) { if (strcmp(url, collections[index].path) == 0) {
TrainlogWebSessionsPageQuery query = { TrainlogWebSessionsPageQuery query = {

View file

@ -10,7 +10,6 @@
#include <yyjson.h> #include <yyjson.h>
#include "database_internal.h" #include "database_internal.h"
#include "timestamp.h"
#include "trainlog/id.h" #include "trainlog/id.h"
typedef struct ListDefinition { typedef struct ListDefinition {
@ -96,117 +95,6 @@ write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database,
const char *start,
const char *end,
size_t offset,
size_t limit,
char **output_json,
size_t *output_size) {
static const char SQL[] =
"SELECT s.session_id,s.session_type,s.started_at,s.ended_at,"
"COALESCE((SELECT group_concat(name, ', ') FROM (SELECT DISTINCT x.name AS name FROM "
"session_exercises se JOIN exercises x ON x.id=se.exercise_row_id WHERE "
"se.session_row_id=s.id ORDER BY se.position LIMIT 8)),s.session_type) "
"FROM sessions s WHERE unixepoch(s.started_at) IS NOT NULL AND "
"unixepoch(s.started_at)<unixepoch(?2) AND "
"(CASE WHEN unixepoch(s.ended_at)>unixepoch(s.started_at) "
"THEN unixepoch(s.ended_at)>unixepoch(?1) ELSE unixepoch(s.started_at)>=unixepoch(?1) "
"END) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
"c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=1) "
"ORDER BY unixepoch(s.started_at),s.session_id LIMIT ?3 OFFSET ?4";
sqlite3_stmt *statement = NULL;
yyjson_mut_doc *document = NULL;
yyjson_mut_val *root;
yyjson_mut_val *items;
TrainlogStatus status;
TrainlogStatus end_status;
size_t count = 0U;
int step = SQLITE_DONE;
TrainlogTimestampKey start_key;
TrainlogTimestampKey end_key;
if (database == NULL || start == NULL || end == NULL || output_json == NULL ||
output_size == NULL || limit == 0U || limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX ||
offset > INT64_MAX || strlen(start) > 40U || strlen(end) > 40U ||
!trainlog_timestamp_parse(start, strlen(start), &start_key) ||
!trainlog_timestamp_parse(end, strlen(end), &end_key) ||
trainlog_timestamp_compare(&start_key, &end_key) >= 0) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
status = trainlog_database_read_snapshot_begin(database);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, start, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, end, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)(limit + 1U)) != SQLITE_OK ||
sqlite3_bind_int64(statement, 4, (sqlite3_int64)offset) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish_sport;
}
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
items = document == NULL ? NULL : yyjson_mut_arr(document);
if (root == NULL || items == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finish_sport;
}
yyjson_mut_doc_set_root(document, root);
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
!yyjson_mut_obj_add_uint(document, root, "offset", offset) ||
!yyjson_mut_obj_add_val(document, root, "items", items)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finish_sport;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *item;
if (count == limit) {
++count;
break;
}
item = yyjson_mut_obj(document);
if (item == NULL || !add_nullable_text(document, item, "identity", statement, 0) ||
!add_nullable_text(document, item, "session_type", statement, 1) ||
!add_nullable_text(document, item, "started_at", statement, 2) ||
!add_nullable_text(document, item, "ended_at", statement, 3) ||
!add_nullable_text(document, item, "label", statement, 4) ||
!yyjson_mut_arr_add_val(items, item)) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish_sport;
}
++count;
}
if (step != SQLITE_DONE && count <= limit) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish_sport;
}
if (!yyjson_mut_obj_add_bool(document, root, "more", count > limit) ||
!yyjson_mut_obj_add_uint(document, root, "next_offset", offset + limit)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finish_sport;
}
status = TRAINLOG_STATUS_OK;
finish_sport:
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
status == TRAINLOG_STATUS_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
if (status == TRAINLOG_STATUS_OK) {
status = end_status;
}
if (status != TRAINLOG_STATUS_OK) {
yyjson_mut_doc_free(document);
return status;
}
return write_document(document, output_json, output_size);
}
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database, TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
const TrainlogWebSessionsPageQuery *query, const TrainlogWebSessionsPageQuery *query,
char **output_json, char **output_json,

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR); TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strncmp(diagnostic, CHECK(strncmp(diagnostic,
"migrate database to schema v37: SQLite rc=", "migrate database to schema v36: SQLite rc=",
strlen("migrate database to schema v37: SQLite rc=")) == 0); strlen("migrate database to schema v36: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL);
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
CHECK(fd >= 0); CHECK(fd >= 0);
CHECK(close(fd) == 0); CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=38;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_exec(raw, "PRAGMA user_version=37;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 38 is newer") != NULL); CHECK(strstr(diagnostic, "schema version 37 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0); CHECK(unlink(path) == 0);
return true; return true;

View file

@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR); TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v37") != NULL); CHECK(strstr(diagnostic, "migrate database to schema v36") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -225,8 +225,7 @@ static bool test_factual_models(void) {
{ {
yyjson_val *program = yyjson_obj_get(root, "active_program"); yyjson_val *program = yyjson_obj_get(root, "active_program");
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_title")), "Next") == 0); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_title")), "Next") == 0);
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") == CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") == 0);
0);
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")), CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")),
"2026-09-21") == 0); "2026-09-21") == 0);
} }
@ -251,123 +250,6 @@ static bool test_factual_models(void) {
return true; return true;
} }
static bool check_next_session(TrainlogDatabase *database,
const char *expected_id,
const char *expected_title,
const char *expected_date) {
yyjson_doc *document = NULL;
yyjson_val *program;
yyjson_val *id;
yyjson_val *title;
yyjson_val *date;
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
program = yyjson_obj_get(yyjson_doc_get_root(document), "active_program");
CHECK(!yyjson_is_null(program));
id = yyjson_obj_get(program, "next_session_id");
title = yyjson_obj_get(program, "next_session_title");
date = yyjson_obj_get(program, "next_session_planned_for");
CHECK(expected_id == NULL ? yyjson_is_null(id)
: yyjson_is_str(id) && strcmp(yyjson_get_str(id), expected_id) == 0);
CHECK(expected_title == NULL
? yyjson_is_null(title)
: yyjson_is_str(title) && strcmp(yyjson_get_str(title), expected_title) == 0);
CHECK(expected_date == NULL
? yyjson_is_null(date)
: yyjson_is_str(date) && strcmp(yyjson_get_str(date), expected_date) == 0);
yyjson_doc_free(document);
return true;
}
static bool test_current_program_plan(void) {
TrainlogDatabase *database = NULL;
yyjson_doc *document = NULL;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
/* WHY: source dates remain historical; current planning and execution are
* independent facts. These rows exercise their precedence without touching
* any user database or invoking the rescheduling command. */
CHECK(sqlite3_exec(database->connection,
"INSERT INTO programs VALUES('pg_current','Current',NULL,'active',NULL,"
"NULL,'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1,"
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
"NULL);"
"INSERT INTO program_sessions VALUES"
"('ps_ceded','pg_current',0,'Optional','training','2026-09-29',NULL),"
"('ps_s2c','pg_current',1,'S2 C','training','2026-09-30',NULL),"
"('ps_later','pg_current',2,'Later','training','2026-10-03',NULL),"
"('ps_tie','pg_current',3,'Tie','training','2026-10-02',NULL),"
"('ps_undated','pg_current',4,'Undated','training',NULL,NULL);",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_ceded", "Optional", "2026-09-29"));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_planning VALUES"
"('ps_ceded',NULL,'ceded','op_a','2026-10-01T00:00:00Z'),"
"('ps_s2c','2026-10-02','active','op_b','2026-10-01T00:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES"
"('ps_s2c','pg_current','se_fixture_1','in_progress',"
"'2026-10-02T12:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
CHECK(sqlite3_exec(database->connection,
"UPDATE program_session_executions SET state='completed' "
"WHERE program_session_id='ps_s2c';",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_tie", "Tie", "2026-10-02"));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES"
"('ps_tie','pg_current','se_fixture_2','completed','2026-10-02T12:00:00Z'),"
"('ps_later','pg_current','se_fixture_3','deleted','2026-10-03T12:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_undated", "Undated", NULL));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES"
"('ps_undated','pg_current','se_fixture_4','completed',"
"'2026-10-04T12:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, NULL, NULL, NULL));
CHECK(sqlite3_exec(database->connection,
"UPDATE programs SET state='archived' WHERE program_id='pg_current';",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
yyjson_doc_free(document);
document = NULL;
CHECK(sqlite3_exec(database->connection,
"UPDATE programs SET state='active',deleted_at='2026-10-05T00:00:00Z' "
"WHERE program_id='pg_current';",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
yyjson_doc_free(document);
trainlog_database_close(database);
return true;
}
int main(void) { int main(void) {
return test_empty_and_periods() && test_factual_models() && test_current_program_plan() ? 0 : 1; return test_empty_and_periods() && test_factual_models() ? 0 : 1;
} }

Some files were not shown because too many files have changed in this diff Show more