Compare commits

..

21 commits

Author SHA1 Message Date
fy59
9a8708c63b release: prepare v0.1.6 2026-09-25 04:57:10 +02:00
fy59
bb20aa44ef docs: redesign README with Catppuccin identity 2026-09-25 04:43:41 +02:00
fy59
45bb44e45d fix(cardio): truncate recovered session capture at factual end 2026-09-24 18:36:02 +02:00
fy59
bd795495df fix(sync): recover divergent sleep revision identities 2026-09-24 14:31:53 +02:00
fy59
a652644e24 fix(android): recover continuous session finalization 2026-09-24 13:29:30 +02:00
fy59
8646422bef fix(web): anchor sleep rows to factual timezone 2026-09-24 08:42:31 +02:00
fy59
77b5bbca85 fix(web): center sleep hours and refine date controls 2026-09-24 08:35:13 +02:00
fy59
ffcdc3efcf fix(web): align sleep axis and range PDF export 2026-09-24 08:18:53 +02:00
fy59
23717ba6da fix(sleep): record wake taps as timed intervals 2026-09-24 07:54:29 +02:00
fy59
de4b77d59b fix(web): synchronize sleep selection and PDF projection 2026-09-24 07:16:47 +02:00
fy59
4cb911d5c4 docs: record sleep persistence and agenda evidence 2026-09-24 06:54:11 +02:00
fy59
a60e1aa056 fix(web): reuse sleep projection in agenda timeline 2026-09-24 06:52:10 +02:00
fy59
696dac82a3 test(android): prove sleep intake lifecycle retention 2026-09-24 06:52:03 +02:00
fy59
fe79c44e51 docs(web): record sleep overlay private deployment 2026-09-24 06:31:34 +02:00
fy59
2ce458d286 feat(web): enrich sleep heart-rate timeline overlays 2026-09-24 06:29:57 +02:00
fy59
3a51211f01 fix(sync): restore automatic sleep bluetooth synchronization 2026-09-24 06:00:29 +02:00
fy59
2755d29f2d docs(web): record post-review validation and private deployment 2026-09-24 05:10:33 +02:00
fy59
713238fd0e refactor(web): finish post-review layout and visual corrections 2026-09-24 05:09:54 +02:00
fy59
d7ca6a1924 fix(sync): recover sleep sync and canonical web mutations 2026-09-24 05:05:38 +02:00
fy59
e7fbd6ff86 refactor(web): refine dashboard analysis and settings UI 2026-09-23 22:14:25 +02:00
fy59
7adeb59b06 feat(web): add factual equipment merge and analysis aggregates 2026-09-23 22:14:25 +02:00
148 changed files with 7854 additions and 1007 deletions

View file

@ -1,9 +1,136 @@
# Changelog
## Unreleased — 0.1.6
## 0.1.6 — 2026-09-25
Development opened after the stable v0.1.5 release. No 0.1.6 feature tranche
is selected by this version-opening commit.
- Corrected delayed session finalization so a heart-rate capture cannot extend
beyond the explicit factual session end. Android now transactionally removes
strict post-end BPM/RR tails while retaining an exact-cutoff sample. For an
already acknowledged capture, the separately versioned
`trainlog-heart-rate-corrections` V1 companion carries an immutable
`capture_id|cutoff` correction through the existing causal ledger without
widening frozen causal-deletions V1. Old Heart Rate V1 generation replay
applies that cutoff before identity comparison and cannot resurrect removed
measurements. A guarded dry-run recovery tool reports before/after counts and
statistics without rewriting published manifests or ACKs.
- Completed the paired-device recovery for session
`se_fa845379-794d-4bb6-8cd3-48187ebbd134`: Android and desktop now retain
3,429 BPM samples and 3,429 RR values through the inclusive 11:16 cutoff,
and two successive full MTP generation exchanges completed with durable
consumed ACKs. Replaying the original 4,919-sample artifact cannot restore
the removed tail. Generation parent selection now excludes archived or
legacy acknowledgement rows lacking durable ACK evidence, while consumers
accept the producer's declared parent when it is one of multiple retained
current tips. Android Sleep V2 persistence also names medication-intake
columns explicitly, preserving `quantity`, note, and creation time across
the additive v33 layout.
- Recovered a real divergent Sleep revision identity without choosing an
arbitrary winner. The unpublished desktop branch was re-identified with its
exact content, the immutable Android revision retained its published ID and
parent, and a fresh successor preserved the non-conflicting qualities,
events and medication occurrences. The original immutable generation now
has a `consumed` / `sqlite-commit-full` ACK and the repaired 24 September
session is available through Desktop and Web with its timeline, feedback,
cardio and Program provenance.
- Added Desktop schema v36 and Android schema v33 append-only guards for Sleep
revision payloads. Both importers now reject a known historical revision ID
whose parent or canonical content differs, even when that revision is below
the current tip. A dedicated transactional recovery utility and regressions
cover exact replay, parent/content collisions, medication quantities,
rollback, immutable generation retry, and long UUID/parent chains.
- Fixed Android finalization of Program-derived sessions containing a completed
target-only continuous occurrence. The repository now materializes the
performed duration from the persisted exercise timeline, including preserved
drafts closed by an older build, while keeping planned set counts independent
from valid actual sets, repetitions and loads. A strictly identity-bound
private recovery build may supply the factual session end; strict late
heart-rate BPM/RR tails are now removed transactionally at that boundary.
- Hardened desktop Sleep generation consumption when an identical durable
revision has no local publication row. Content comparison now reads the
durable revision directly, acknowledges an exact match, and reports a real
revision-identity conflict instead of an empty rejection diagnostic when the
same ID carries different content.
Development opened after the stable v0.1.5 release. The Web refinement tranche
adds persistent French/English settings, denser shared surfaces, ISO-week
Dashboard activity, factual exercise-count overview charts, measured-timeline
muscle-group drill-down, normalized measurement summaries, one shared refined
BODY ZONES silhouette, and a compact Program header. A new Equipment workspace
lists factual references and transactionally absorbs a custom duplicate into a
canonical identity while retaining history/MAX context and recording a causal
deletion marker; immutable embedded drafts cause an explicit conflict.
The post-review correction keeps acknowledged-generation capacity recoverable
for legacy Android rows without ACK-ledger evidence, proves two successive
Sleep-only generation/ACK exchanges, and never evicts an unacknowledged
generation. Web can now causally remove only an orphaned zero-occurrence active
draft while retaining protection for real active drafts. Mutation UUIDs use
`crypto.getRandomValues()` on loopback HTTP origins. Sleep timeline geometry,
Dashboard sizing, Program progress contrast, chart proportions, duration
coverage messaging, and the right-aligned Settings/Sync action cluster are
also refined.
- Restored automatic Sleep-only Bluetooth synchronization after a real-night
trace exposed several restart and first-import gaps. The user-service
installer now removes only its obsolete `syncd` ExecStart override and
installs the RFCOMM agent;
Android resumes the same unsuperseded immutable generation across a new
conversation run and accepts its exact generation/digest ACK even though
the immutable ACK retains the original run identity. Ledger-proven
superseded branches remain retained without exhausting admission. Desktop
progress validation accepts the existing ACK-recovery phase, and a fresh
desktop can seed a non-root Sleep V2 current snapshot when its validated
ancestry is present. Regression
coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR
through import, ACK, replay and a second Sleep-only generation with no
Android workout session.
- Enriched the selected Sleep night's heart-rate chart with declared sleep
ranges, explicit awakening ranges and point events, medication markers that
preserve unit dose and quantity, exact start/end bounds, and every
intervening hourly tick. When no declared sleep interval exists, the Web
presentation alone shows the conservative bedtime +45 minutes to final
get-up -10 minutes estimate, labels it as estimated/non-medical, and never
writes it to Sleep data. Nights without synchronized HR retain these factual
and estimated timeline overlays without inventing BPM samples. The private
`trainlog.perf` deployment and read-only Firefox smoke validate the latest
real HR night and the latest real night containing medications from code
commit `2ce458d`.
- Reused that same read-only fallback projection in the multi-night Sleep
agenda, with a visibly estimated hatched interval and no Sleep write. The
agenda now derives an exact 18:00-to-18:00 hourly grid, presents at least
two-hour labels on ordinary desktop widths, expands to every hour when wide,
and reduces to six-hour anchors on narrow screens. A read-only real-device
investigation of the 2026-09-23 night found no medication intake in any
retained local revision and established that later synchronization only
published the already-empty current revision. Android regression coverage
now proves two pre-bed intakes, including quantity two, through repeated
repository reopen, bedtime, wake, final get-up, generation, desktop ACK,
replay, stale-ancestor input and a subsequent revision. The private service
serves Web code commit `a60e1aa`; a non-mutating Firefox smoke on the real
night confirms the identical 22:50–04:04 agenda/detail projection and the
responsive 13/25/5-label axis densities.
- Unified the Web Sleep agenda, selected-night HR detail, and vector PDF on one
temporal projection. Every structured long-awake interval remains a distinct
band, row selection updates the detail dates and content without persistence,
and preview/download export exactly the active night with observations
recomputed for that subset. Sleep duration now consistently sums factual
sleep intervals or, only when none exist, uses the displayed Couché +45 min
to Levé -10 min estimate minus structured awakenings; the estimate is never
written to SQLite.
- Changed future Android one-tap Réveil capture from punctual `night_get_up`
facts to structured `long_awake` windows under the user-approved 30-minute
default. A repeated tap inside the window extends its end to 30 minutes after
the latest tap, while Levé clamps it to the factual final-get-up timestamp.
The policy is revisioned, undoable, and independent of BPM/RR inference.
- Centered the Sleep agenda's hour labels inside a shared 24-column visual grid
and added an inclusive night-start date range for PDF preview and export.
The duplicate terminal `18` is removed; detail/HR night selection remains
independent from the report range, whose date fields now use a grouped
Trainlog form treatment.
- Fixed a real Sleep agenda offset bug when revision metadata used UTC `Z` but
factual events used the user's local offset: the row now anchors its complete
18:00 axis to one factual night offset. Agenda night headings use French
`DD/MM/YYYY` rather than persistence-oriented ISO dates in the French UI.
## 0.1.5 — 2026-09-23

577
README.md
View file

@ -1,383 +1,124 @@
# Trainlog
<p align="center">
<img src="docs/assets/trainlog-banner.svg" alt="Trainlog — local-first training data system" width="100%">
</p>
Synchronization uses one generation/ACK business engine across versioned
local transports. The paired 0.1.4 deployment prefers Bluetooth Classic RFCOMM
and falls back to direct USB/MTP; the separately configured private Drive path
remains available to the legacy mirror/fallback workflow. No transport copies
SQLite databases between devices.
<p align="center">
<img src="docs/assets/trainlog-badges.svg" alt="Trainlog badges: local-first, C17, Android, local Web, GPLv3" width="720">
</p>
Trainlog is a local-first workout and body-data system. Trainlog Core owns
business truth and canonical desktop persistence. Its native Android app is the
field companion, its C17/Notcurses desktop TUI is the administration and
technical surface, and its local browser Web sibling provides the implemented
Dashboard, Sessions, Programmes, Exercises, and factual Analyse/Sleep Diary
surfaces.
Trainlog is a **local-first system for recording, synchronizing, and examining
training and body data**. Its Android companion handles capture in the field;
the C17 desktop core keeps the canonical long-term history; a keyboard-first
Notcurses TUI and a loopback-only Web application provide complementary ways
to inspect and manage it.
Android and desktop each own a local SQLite database. The desktop database is
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
over its authenticated local transports and optional configured Drive workflow;
it never copies SQLite database files between devices.
The system is built around explicit facts, stable identities, versioned JSON
exchanges, and user-controlled storage. Plans never become performed work until
they are actually recorded, continuous activities are not disguised as sets,
and estimates are kept distinct from measurements.
## Product split
> **Release line:** The latest stable release is
> [`v0.1.6`](https://github.com/labfytools/trainlog/releases/tag/v0.1.6).
| Surface | Primary responsibility |
|---|---|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
| Local Web (0.1.6 development) | Display Dashboard, Programmes, Sessions, Exercises and factual Analyse workspaces. Analyse includes the Sleep Diary editor, multi-night 18:00-to-18:00 agenda, factual heart-rate timelines and local vector PDF export. |
## ✦ Showcase
No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
CLI/HTTP adapter, embedded frontend, read-only Dashboard API, factual tiles,
visualizations, and private layout persistence are implemented. This does not
make the remaining placeholder routes functional.
The top-level `/programmes` route is an operational daily calendar for active
Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface.
Web Exercises administration, the factual Dashboard/Analyse read model, and the
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Stable
0.1.5 adds persistent CYCPLUS acquisition, the global `♥ BPM` indicator,
training exercise timestamps, structured Android Sleep capture, dedicated
synchronized `cardio` sessions, versioned measured Cardio calibration,
fresh-signal BPM-guided phases and factual Web heart-rate timelines in
History/Sleep.
Version 0.1.6 development is open without selecting a new feature tranche or
reopening the frozen 0.1.5 release contracts.
<p align="center">
<img src="docs/reviews/evidence/sleep-diary-alignment-firefox.png" alt="Trainlog Web Analyse workspace showing the Sleep Diary agenda and factual heart-rate timeline" width="100%">
</p>
## Releases
<p align="center"><sub>Web Analyse in Trainlog 0.1.6 — synchronized Sleep facts and measured heart-rate data on one timeline.</sub></p>
When a Trainlog release is published, its prebuilt assets are available from
both official mirrors:
The repository currently tracks real-browser evidence for the Web interface.
Its Android overview asset is a clearly labelled static design study with
fictional data, not a runtime screenshot; no current TUI screenshot is tracked.
Those assets are therefore not presented here as product captures.
- [GitHub Releases](https://github.com/labfytools/trainlog/releases)
- [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
## ✦ Features
Each published stable release provides:
| Area | Available in the current development tree |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Training capture** | Metadata-driven sets, repetitions, durations and continuous activities; heterogeneous actual sets; durable active draft; exercise timelines; explicit measured MAX results. |
| **Planning** | Manual preparations, imported Programs, Android delivery, execution provenance and strict separation between targets and facts. |
| **Context** | Exercise and equipment catalogues, BODY ZONES, body measurements, immediate feedback, wording revisions and J+1 follow-ups. |
| **Cardio** | Android BLE heart-rate acquisition, factual BPM/RR history, cardio sessions, measured calibration and fresh-signal phase guidance. |
| **Sleep** | One-tap Android capture, causal Sleep Diary revisions, medication occurrences, synchronized factual timelines and local vector PDF export. |
| **Desktop** | C17 Core, canonical SQLite history, keyboard-first Notcurses administration, correction, statistics, graphs and synchronization diagnostics. |
| **Local Web** | Loopback-only Dashboard, Analyse, Programmes, Sessions, Exercises, Equipment, Settings and synchronization surfaces backed by typed Core services. |
| **Synchronization** | One generation/ACK business engine over authenticated Bluetooth Classic RFCOMM, direct USB/MTP recovery and a separately configured private Drive workflow; SQLite files are never exchanged. |
- a signed Android APK;
- a Linux x86-64 desktop runtime bundle;
- SHA-256 checksums.
### Boundaries and work in progress
Both mirrors publish the same Trainlog product version and release assets. The
latest stable version is **0.1.5** on Android and desktop; this is one shared
Trainlog version, not separate interface versions. Version 0.1.4 is the
previous stable release.
- `TRAINLOG_FORMAT_V1` remains frozen. The active mobile snapshot is V3; V1
and V2 remain readable, while V4 codecs are staged but not selected by the
active transport.
- Full-generation synchronization is deployed only behind a trusted local
opt-in; the default automatic exchange remains V3.
- AI history export is active. AI session proposals still have a pending real
Drive plus Android-triggered bidirectional smoke test.
- Session Generator V1 is implemented but hidden while the V2 planning model
is developed.
- Web Analyse is implemented; its recorded visual-review gate remains open.
Trainlog presents French by default, with English selectable
from **Settings → Language** on Android and the desktop TUI. The selection is
local to that installation and updates the visible interface immediately; it
does not translate user exercise/catalogue names or change training data,
stable IDs, databases, schemas, exchange artifacts, synchronization protocols,
AI, MAX, or feedback. Synchronization status is rendered locally from typed
status and counters; raw protocol and operational summaries are not translated
or injected into the other surface.
The canonical status and limitations live in
[`docs/current_state.md`](docs/current_state.md); future work belongs in the
[`roadmap`](docs/roadmap.md).
See the [documentation](docs/README.md) and [changelog](CHANGELOG.md) for more
details.
## Installation
### Download a prebuilt release
For a published release, download Trainlog from either official mirror:
- [GitHub](https://github.com/labfytools/trainlog/releases)
- [Forgejo](https://git.labfytools.com/fy59/trainlog/releases)
Both mirrors contain the same Android APK, Linux x86-64 runtime bundle, and SHA-256
checksums for each stable Trainlog release. Verify the downloaded binaries
against `SHA256SUMS` before installing them.
The Linux artifact is an architecture-specific tar archive whose published
filename intentionally remains `trainlog-tui-linux-x86_64-v<version>`. It
contains the TUI/Web executable, synchronization daemon and helpers, catalogs,
and a hashed inventory from the same release commit. Its native executables are
dynamically linked, so the host must provide compatible runtime libraries
listed under [Dependencies](#dependencies).
```bash
mkdir trainlog-v<version>
tar -xf trainlog-tui-linux-x86_64-v<version> -C trainlog-v<version>
./trainlog-v<version>/bin/trainlog
```
The extracted bundle can remain in a user-managed installation directory.
Linking its launchers from `bin/` into `~/.local/bin` is optional.
For Android, download the release APK, allow installation from the browser or
file manager when Android requests it, and install the package. Android 8.0
(API 26) or later is required. Grant Trainlog all-files access only when direct
`Documents/Trainlog` synchronization is needed. Desktop synchronization also
requires the Linux side and its MTP dependencies to be installed and configured.
Android and the TUI in each stable release share the same Trainlog product
version on both mirrors. Database schemas and JSON protocol versions are
independent.
### Daily synchronization on an opted-in installation
After explicitly enabling the paired local synchronization path, keep Bluetooth
enabled on Android and the desktop and select **Synchronize** in Trainlog when
an explicit run is wanted. The current paired deployment prefers authenticated
Bluetooth Classic RFCOMM and uses direct USB/MTP as the wired recovery path.
The existing private Drive workflow remains separately configured. Wait until
the correlated generation and acknowledgements complete; a failure remains
durable and should be diagnosed before retrying rather than deleting protocol,
database, or backup files.
### Build from source
Desktop:
```bash
meson setup build
meson compile -C build
meson test -C build --print-errorlogs
```
Android:
```bash
cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleRelease
```
`assembleRelease` produces a distributable stable APK only when release signing
is configured by the release operator. The repository does not contain signing
credentials and does not silently generate them.
Release hosts provide `TRAINLOG_RELEASE_STORE_FILE`,
`TRAINLOG_RELEASE_STORE_PASSWORD`, `TRAINLOG_RELEASE_KEY_ALIAS`, and
`TRAINLOG_RELEASE_KEY_PASSWORD`, or a private
`~/.config/trainlog/release-signing.properties` file containing the equivalent
`storeFile`, `storePassword`, `keyAlias`, and `keyPassword` keys. Environment
variables take precedence. Keystores and local credential files are ignored by
Git; neither belongs in source control, build logs, or release notes.
The Android release key is the permanent update identity for the application.
The release operator must preserve an encrypted external backup of both the
keystore and its credentials. Losing either prevents future compatible Android
updates; generating a replacement key is not a normal release procedure.
## Dependencies
### Linux runtime dependencies
The published v0.1.5 x86-64 desktop runtime is dynamically linked. Its tagged
source directly requires compatible versions of:
- glibc and the GCC support runtime;
- SQLite 3;
- libuuid;
- utf8proc;
- libudev;
- libmtp;
- Notcurses Core;
- the standard math library.
Version 0.1.4 additionally links GNU libmicrohttpd and yyjson for the local Web
adapter and Dashboard layout configuration. Those are not retroactive
requirements of the pre-Web v0.1.1 tagged source.
Distribution packages may pull additional transitive libraries, including
libusb, ncursesw, unistring, gpm, libgcrypt, libgpg-error, and libdeflate.
Package names and ABI versions vary by distribution; inspect the release's
`ldd` evidence and install packages from the target distribution rather than
assuming one universal package command.
### Optional runtime and integration dependencies
- Android is optional when using the desktop TUI alone. USB/MTP synchronization
requires the Android companion, libudev, libmtp, and an unlocked connected
device.
- Bluetooth synchronization requires BlueZ on Linux plus Python 3.11 or newer
with D-Bus and GObject bindings. It is a Trainlog synchronization transport,
not a heart-rate sensor implementation.
- `trainlog-syncd` installation expects a systemd user session. Manual TUI
synchronization does not require the user service.
- `rclone` is required for desktop Google Drive synchronization and automatic
AI Drive workflows. Core capture, local history, analytics, and direct MTP
synchronization continue to work without `rclone`.
### Build dependencies
Desktop builds require:
- Meson and Ninja;
- a C17 compiler toolchain;
- `pkg-config`;
- Notcurses Core development headers;
- SQLite, libuuid, utf8proc, libudev, libmtp, GNU libmicrohttpd, and yyjson
development headers for the current development source;
- Python 3 for generated sources, validators, import/export helpers, and tests.
The embedded Web frontend additionally uses Node and npm at build time only.
Prepare its exact locked dependencies explicitly, then enable the release
frontend build:
```bash
cd web
npm ci
npm run typecheck
npm test
cd ..
meson setup build -Dweb=enabled
meson compile -C build
```
Meson never runs `npm install` or downloads packages. Run `npm ci` explicitly
for the exact lockfile before configuring a Web-enabled build. The default `web=auto`
embeds the frontend when Node, npm, and the prepared `web/node_modules` are
available; otherwise it preserves a desktop-only build and `trainlog -w`
reports that frontend support is absent. Release builds must use
`-Dweb=enabled`. `-Dweb=disabled` is the explicit desktop-only choice. Node,
npm, `node_modules`, and `web/dist` are not runtime dependencies.
For an existing build directory, change the option with `meson configure
build -Dweb=enabled` (or the required value) instead of running `meson setup`
as though the directory were new.
### Optional systemd user service for local Web development
The versioned `systemd/trainlog-web.service` unit can keep `trainlog -w`
available on `127.0.0.1:8080` during an interactive user session. Without
systemd user lingering, enabling it does not guarantee startup before login.
This is a development convenience, not a requirement of the final Trainlog
distribution.
It intentionally runs the canonical user entry point
`~/.local/bin/trainlog`; in a source checkout that entry point may be a symlink
to the current `build/tui/trainlog` binary, but the symlink must resolve to an
executable before the service is started.
The unit prevents privilege acquisition, gives the process a private temporary
directory, makes system paths read-only, and applies kernel/control-group and
setuid restrictions that are compatible with the local HTTP server. It does
not protect or hide the home directory because SQLite and Trainlog
configuration must remain writable under `~/.local/share/trainlog` and
`~/.config/trainlog`. Failed starts use a ten-second restart delay and are
limited to three attempts per minute, preventing a persistent port-8080
conflict from producing an uncontrolled loop.
Install the development unit as a symlink so repository updates are picked up
after `daemon-reload`:
```bash
mkdir -p ~/.config/systemd/user
ln -s "$(pwd)/systemd/trainlog-web.service" ~/.config/systemd/user/trainlog-web.service
systemctl --user daemon-reload
systemctl --user enable --now trainlog-web.service
```
Useful lifecycle and diagnostic commands are:
```bash
systemctl --user status trainlog-web
systemctl --user restart trainlog-web
systemctl --user stop trainlog-web
systemctl --user start trainlog-web
journalctl --user -u trainlog-web
```
An operator may separately configure a local nginx proxy such as
`http://trainlog.perf`. That hostname and proxy are machine-local conveniences,
not a public Trainlog domain or a runtime dependency, and Trainlog does not
configure them automatically.
Uninstalling the integration disables the unit before removing only its user
configuration symlink:
```bash
systemctl --user disable --now trainlog-web.service
rm ~/.config/systemd/user/trainlog-web.service
systemctl --user daemon-reload
```
Android builds require JDK 17, an Android SDK supporting the configured API
levels, and the Gradle wrapper committed in this repository. People installing
the APK do not need Java, Gradle, or the Android SDK.
## Documentation
| Topic | Document | Purpose |
|---|---|---|
| Documentation map | [docs/README.md](docs/README.md) | Canonical index and ownership map. |
| Current state | [docs/current_state.md](docs/current_state.md) | Current implemented schemas, capabilities, validation, and limitations. |
| Architecture | [docs/architecture.md](docs/architecture.md) | Component, storage, process, and ownership boundaries. |
| Android | [docs/android.md](docs/android.md) | Field-companion behavior and local persistence. |
| Desktop TUI | [docs/tui.md](docs/tui.md) | Notcurses workflows, correction, and analytics. |
| Database | [docs/database.md](docs/database.md) | Desktop persistence schema and migration contracts. |
| Exercise model | [docs/exercise_data_model.md](docs/exercise_data_model.md) | Exercise, occurrence, load, and MAX semantics. |
| Synchronization | [docs/sync_exchange.md](docs/sync_exchange.md) | Active MTP flow and companion artifacts. |
| Exchange formats | [docs/exchange_format.md](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
| Training feedback | [docs/training_feedback.md](docs/training_feedback.md) | Immediate feedback, revisions, and J+1 follow-ups. |
| Tests | [docs/tests.md](docs/tests.md) | Durable validation commands and coverage strategy. |
| Roadmap | [docs/roadmap.md](docs/roadmap.md) | Current cursor and future work only. |
| Development contract | [AGENTS.md](AGENTS.md) | Repository invariants and contribution rules. |
| Change history | [CHANGELOG.md](CHANGELOG.md) | Chronological implementation history. |
## Architecture overview
## ✦ Architecture
```text
Android local SQLite
|
| versioned JSON snapshots and requests
v
Documents/Trainlog on Android storage
|
| direct MTP / libmtp
v
trainlog_sync_run() <---- trainlog-syncd or desktop TUI
|
+---- import Android snapshot into desktop SQLite
+---- publish desktop catalog companions to Android
+---- write a synchronization receipt
desktop SQLite = canonical long-term history
┌──────────────────────────────┐
│ Android field companion │
│ private SQLite capture store │
└──────────────┬───────────────┘
│ versioned JSON generations + acknowledgements
│ Bluetooth RFCOMM primary · direct USB/MTP recovery
▼
┌──────────────────────────────┐ optional, separately configured
│ Synchronization orchestration│◄───── private Drive workflow
└──────────────┬───────────────┘
│ typed imports, commands and read models
▼
┌──────────────────────────────┐
│ Trainlog Core · C17 │
│ desktop SQLite = canonical │
│ long-term history │
└──────────────┬───────────────┘
│
┌──────┴──────────┐
▼ ▼
┌──────────────┐ ┌──────────────────────┐
│ Notcurses TUI│ │ 127.0.0.1 Web adapter│──► browser
└──────────────┘ └──────────────────────┘
```
The optional desktop AI flow exports read-only history and exchanges session
proposals through Google Drive via the external `rclone` process. Android does
not contain cloud credentials and does not run `rclone`.
The TUI, Web application, and Android client are sibling adapters with
different responsibilities. Business rules, calculations, persistence, and
typed read models belong to Core; presentation code does not reconstruct them
from raw database tables. See the full
[`architecture contract`](docs/architecture.md).
## Current status
## ✦ Your data, your system
As of 2026-09-18:
- Trainlog runs on the user's own Android and Linux systems; no hosted Trainlog
account or proprietary cloud service is required for core capture, history,
analytics, or direct synchronization.
- Android and desktop each keep a local SQLite database; the desktop database
is the canonical long-term history.
- Devices exchange bounded, versioned JSON artifacts—not database files.
- The local Web server binds to `127.0.0.1` by default and never silently falls
back to a LAN address or another port.
- Direct Android storage uses `Documents/Trainlog`; desktop USB access uses
`libudev` and `libmtp` without requiring a GVFS/FUSE mount.
- The optional Drive path is operator-configured through external `rclone`.
Android holds no cloud credentials.
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v29 and Android SQLite schema v26;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;
- mobile export V3 is active; V1/V2 remain readable legacy inputs; explicit V4
history and execution-draft codecs are staged but not selected by transport;
- `STATS_V1=IMPLEMENTED`, `TRAINING_KNOWLEDGE_V1=PASS`, and
`SESSION_GENERATOR_V1=PASS` (the generator is hidden pending V2);
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
Android-triggered bidirectional smoke test.
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
`/programmes` is an operational active-Program calendar, while
Sessions → Programmes remains the administration surface;
`TRAINLOG_WEB_EXERCISES_V1=PASS`; `/analyse` now provides the 0.1.4 factual
Dashboard/Analyse read-model foundation pending visual review;
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
private desktop/Android deployment, real Firefox presentation, correlated
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
services; automatic synchronization still selects V3. The trusted local
opt-in path is deployed on the paired private installation and has completed
real direct-MTP restart/replay validation; it is not a public rollout.
## ✦ Build and run
The latest executable result belongs in
[current state](docs/current_state.md), not in multiple README narratives.
### Desktop and TUI
## Desktop development quick start
Requirements include Meson, Ninja, SQLite3, utf8proc, libuuid, libudev,
libmtp, and Notcurses.
The desktop build requires Meson, Ninja, a C17 toolchain, `pkg-config`, Python
3, and development files for SQLite, libuuid, utf8proc, libudev, libmtp,
Notcurses Core, GNU libmicrohttpd, and yyjson.
```bash
meson setup build
@ -386,13 +127,36 @@ meson test -C build --print-errorlogs
./build/tui/trainlog
```
The database path is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
The desktop database is stored at
`$XDG_DATA_HOME/trainlog/trainlog.db`, or
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
## Android development quick start
### Embedded local Web application
Use Java 17 and provide the Android SDK through `ANDROID_HOME` or the untracked
`android/local.properties` file.
Node and npm are build-time dependencies only. Meson does not download npm
packages.
```bash
cd web
npm ci
npm run typecheck
npm test
cd ..
meson setup build-web -Dweb=enabled
meson compile -C build-web
./build-web/tui/trainlog --web
```
The Web application listens on `127.0.0.1:8080` by default. Use
`--port <port>` for an explicit alternative. For an existing build directory,
use `meson configure build -Dweb=enabled` instead of running `meson setup`
again.
### Android
Android builds use the committed Gradle wrapper, JDK 17, and an Android SDK for
the configured API levels (`minSdk 26`, `targetSdk 36`, `compileSdk 37`).
```bash
cd android
@ -400,38 +164,81 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug
```
Instrumented tests belong on an emulator. Do not run
`connectedDebugAndroidTest` on the primary personal phone.
Instrumented tests belong on an emulator; the primary personal phone is
reserved for manual, non-destructive installation and synchronization checks.
Release APK signing is intentionally operator-local and is not configured with
credentials from this repository.
## Synchronization quick start
<details>
<summary>Optional user-session services</summary>
After a desktop build, the repository provides an installer for the private
automatic synchronization services:
```bash
meson compile -C build
bash tools/install_syncd_user.sh
systemctl --user is-active trainlog-syncd.service
```
On Android, grant all-files access for the fixed `Documents/Trainlog`
directory, then use **Synchronisation → Synchroniser maintenant**. Hardware MTP
validation requires an unlocked connected device and remains an explicit manual
check.
This modifies the current user's `~/.local/bin` and systemd user configuration.
Bluetooth generation transport starts only when its separate Trainlog
configuration explicitly enables it. The loopback Web development unit in
`systemd/trainlog-web.service` is another optional integration; neither service
is required for manual TUI use.
## Development principles
</details>
- Preserve frozen, versioned compatibility boundaries.
- Use stable IDs; display names are not identities.
- Keep performed work separate from plans and continuous activity separate
from fake sets.
- Keep Android capture, desktop analytics, persistence, transport, and
rendering responsibilities distinct.
- Prefer explicit failure to silent data loss or corruption.
- Treat migrations, idempotence, tests, and documentation as part of feature
completion.
- Keep scientific catalogs separate from runtime user data and preserve stated
uncertainty.
Prebuilt stable APKs and Linux x86-64 runtime bundles are published on both
official mirrors: [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
and [GitHub Releases](https://github.com/labfytools/trainlog/releases). Verify
downloaded artifacts against the accompanying `SHA256SUMS`.
## License and project notes
## ✦ Repository layout
See [LICENSE](LICENSE). Forgejo is the primary repository; GitHub is a mirror.
Repository layout, detailed contracts, and historical review records are linked
from [docs/README.md](docs/README.md).
```text
trainlog/
├── android/ Kotlin + Jetpack Compose field companion
├── tui/ C17 Core, Notcurses TUI, Web adapter and native tests
├── web/ React + TypeScript + Vite frontend
├── catalog/ versioned scientific and product knowledge
├── format/ JSON schemas and frozen format material
├── contracts/ machine-readable presentation contracts
├── tools/ validators, import/export and sync utilities
├── tests/ cross-surface and protocol regression tests
├── systemd/ optional user-session integration
└── docs/ canonical documentation and review evidence
```
## ✦ Documentation
| Start here | Scope |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| [`docs/current_state.md`](docs/current_state.md) | Implemented capabilities, versions, validation evidence and active limitations. |
| [`docs/architecture.md`](docs/architecture.md) | Component, storage, process, transport and ownership boundaries. |
| [`docs/roadmap.md`](docs/roadmap.md) | Current cursor and future work. |
| [`docs/android.md`](docs/android.md) | Android capture, persistence and synchronization behavior. |
| [`docs/tui.md`](docs/tui.md) | Notcurses workflows, correction, analytics and technical tools. |
| [`docs/sync_exchange.md`](docs/sync_exchange.md) | Active synchronization flow, replay and acknowledgement semantics. |
| [`docs/exchange_format.md`](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
| [`docs/exercise_data_model.md`](docs/exercise_data_model.md) | Exercise, occurrence, planning, load and MAX semantics. |
| [`docs/database.md`](docs/database.md) | Desktop SQLite schema and migration contracts. |
| [`docs/tests.md`](docs/tests.md) | Canonical validation commands and coverage strategy. |
The complete ownership map is in [`docs/README.md`](docs/README.md), with
chronological detail in the [`CHANGELOG`](CHANGELOG.md).
## ✦ Project status
| Boundary | Current state |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| Product | `0.1.6` stable |
| Desktop persistence | SQLite schema v36 |
| Android persistence | SQLite schema v33 |
| Mobile exchange | V3 active; V1/V2 readable; V4 staged only |
| Desktop terminal | C17 + Notcurses, active backend |
| Local Web | Dashboard and operational Analyse, Programmes, Sessions, Exercises, Equipment, Settings and sync surfaces |
| Interface language | French by default; English selectable on Android and desktop |
| Compatibility | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
Forgejo is the primary repository; GitHub is its release mirror. Trainlog is
licensed under the [GNU General Public License v3](LICENSE).

View file

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

View file

@ -1,3 +1,4 @@
import java.time.OffsetDateTime
import java.util.Properties
plugins {
@ -45,6 +46,37 @@ val trainlogVersionCode =
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
} ?: 7
/*
* WHY: a user may need to finish one preserved draft after a completion bug
* has been repaired, while its factual end time predates the repair build.
* CONTRACT: both values are private-build inputs, must be supplied together,
* and are embedded only after strict identity/timestamp validation.
* INVARIANT: ordinary builds use empty values and every non-matching future
* session continues to finalize at the live clock time.
*/
val recoverySessionId =
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_SESSION_ID").orNull.orEmpty()
val recoveryEndedAt =
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_ENDED_AT").orNull.orEmpty()
if (recoverySessionId.isEmpty() != recoveryEndedAt.isEmpty()) {
throw GradleException(
"TRAINLOG_ANDROID_RECOVERY_SESSION_ID and " +
"TRAINLOG_ANDROID_RECOVERY_ENDED_AT must be supplied together",
)
}
if (recoverySessionId.isNotEmpty()) {
val sessionPattern =
Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
if (!sessionPattern.matches(recoverySessionId)) {
throw GradleException("TRAINLOG_ANDROID_RECOVERY_SESSION_ID is invalid")
}
try {
OffsetDateTime.parse(recoveryEndedAt)
} catch (_: Exception) {
throw GradleException("TRAINLOG_ANDROID_RECOVERY_ENDED_AT is invalid")
}
}
android {
namespace = "com.labfytools.trainlog"
compileSdk = 37
@ -56,6 +88,8 @@ android {
versionCode = trainlogVersionCode
versionName = "0.1.6"
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}
@ -86,6 +120,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
sourceSets {

View file

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

View file

@ -101,6 +101,19 @@ internal fun hasBluetoothConnectPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
/**
* Decide whether a newly connected RFCOMM peer may publish an automatic request.
*
* WHY: Sleep-only changes have no workout event to wake the desktop path, so
* Bluetooth arrival itself must remain the trigger. CONTRACT: user opt-in and
* the existing cooldown are both mandatory. INVARIANT: reconnection never
* bypasses the bounded request cadence or requires an explicit UI action.
*/
internal fun shouldPublishBluetoothArrivalRequest(
autoSyncEnabled: Boolean,
cooldownReady: Boolean,
): Boolean = autoSyncEnabled && cooldownReady
@SuppressLint("MissingPermission")
internal fun bondedBluetoothDesktops(context: Context): List<BondedBluetoothDesktop> {
if (!hasBluetoothConnectPermission(context)) return emptyList()
@ -526,9 +539,15 @@ internal class BluetoothSyncClient(
}
private fun publishArrivalRequest() {
if (!BackgroundSyncSettings(context).enabled) return
val now = System.currentTimeMillis()
if (!settings.shouldPublishAutomaticRequest(now)) return
if (
!shouldPublishBluetoothArrivalRequest(
BackgroundSyncSettings(context).enabled,
settings.shouldPublishAutomaticRequest(now),
)
) {
return
}
val directory = canonicalExchangeDirectory()
if (!directory.isDirectory) return
val coordinator =

View file

@ -45,6 +45,13 @@ internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
}
}
internal fun acknowledgementMatchesCapturedGeneration(
acknowledgement: JSONObject,
captured: CapturedSyncGeneration,
): Boolean =
acknowledgement.optString("generation_id") == captured.generationId &&
acknowledgement.optString("manifest_sha256") == captured.manifestSha256
/** Owns the bounded, user-initiated Android side of a generation conversation. */
internal class SyncGenerationCoordinator(
private val repository: TrainlogRepository,
@ -69,15 +76,9 @@ internal class SyncGenerationCoordinator(
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
fun query(sql: String, arguments: Array<String>): CapturedSyncGeneration? = db.rawQuery(
sql,
arguments,
).use { cursor ->
if (!cursor.moveToFirst()) null
else {
@ -93,6 +94,29 @@ internal class SyncGenerationCoordinator(
captured
}
}
query(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
) ?: if (consumerPeerId == null) null else query(
/* WHY: an RFCOMM reset creates a new conversation run while
* the immutable generation still awaits its correlated ACK.
* CONTRACT: resume only the unsuperseded generation for the
* same peer; the reference adopts the new run while manifest
* and generation identity remain unchanged. */
"SELECT g.generation_id,g.manifest_sha256,g.staging_path FROM sync_generations g " +
"WHERE g.consumer_peer_id=? AND g.status IN('captured','published','waiting_acknowledgement') " +
"AND NOT EXISTS(SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements a " +
"ON a.generation_id=sibling.generation_id WHERE sibling.parent_generation_id=g.parent_generation_id " +
"AND sibling.generation_id<>g.generation_id AND sibling.status='acknowledged') " +
"ORDER BY g.generated_at DESC LIMIT 2",
arrayOf(consumerPeerId),
)
}
private fun request(directory: File): JSONObject? {
@ -570,12 +594,15 @@ internal class SyncGenerationCoordinator(
afterPublication?.invoke()
lease.throwIfYieldRequested()
// CONTRACT: durable objects from earlier conversations remain in
// the exchange directory. Only this run's exact generation ACK
// can advance its lineage; stale bytes are retained and ignored.
// the exchange directory. A cross-run retry preserves the
// immutable manifest's original run_id, so correlate its ACK by
// generation identity and manifest digest. acceptAcknowledgement
// then revalidates the complete durable ACK against SQLite.
// INVARIANT: unrelated or same-ID/different-bytes ACKs remain
// ignored; an exact replay is deliberately accepted.
val desktopAck =
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
it.optString("run_id") == captured.runId &&
it.optString("generation_id") == captured.generationId
acknowledgementMatchesCapturedGeneration(it, captured)
}, pollTransport, lease)
service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)

View file

@ -72,6 +72,9 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
"sleep-diary" to Kind("trainlog-sleep-diary", 2, "sleep-diary-v2.json", false),
"heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false),
"heart-rate-corrections" to
Kind("trainlog-heart-rate-corrections", 1,
"heart-rate-corrections-v1.json", false),
"cardio-calibrations" to
Kind("trainlog-cardio-calibrations", 1, "cardio-calibrations-v1.json", false),
"cardio-guidance" to
@ -294,7 +297,26 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
repository.inSyncGenerationTransaction { db ->
val retained =
db.rawQuery(
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged') AND NOT EXISTS(SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
/* WHY: early deployed generation builds could advance a
* producer row to acknowledged without retaining the ACK
* ledger row. Such terminal rows cannot be archived
* safely because Trainlog must not fabricate evidence,
* but they must not exhaust the active admission window
* forever. A failed conversation can also leave an
* unacknowledged branch whose sibling later receives a
* durable ACK; that superseded branch remains retained
* for audit but can never be the causal tip. INVARIANT:
* only a ledger-backed acknowledged sibling proves that
* an unacknowledged branch no longer consumes admission. */
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND " +
"((status IN('captured','published','waiting_acknowledgement') AND NOT EXISTS(" +
"SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements sibling_ack " +
"ON sibling_ack.generation_id=sibling.generation_id WHERE " +
"sibling.parent_generation_id=g.parent_generation_id AND " +
"sibling.generation_id<>g.generation_id AND sibling.status='acknowledged')) OR " +
"(status='acknowledged' AND EXISTS(SELECT 1 FROM sync_acknowledgements k " +
"WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" +
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
arrayOf(consumerPeerId),
)
.use {
@ -340,10 +362,35 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
)
}
val generatedAt = OffsetDateTime.now().toString()
/* WHY: a fully acknowledged fork can remain in the immutable
* ledger after its payload has been durably archived.
* CONTRACT: archived evidence stays queryable but no longer
* participates in the active producer-lineage tip selection.
* INVARIANT: only unarchived rows backed by a full durable ACK can
* become the parent; two such active tips still reject capture. */
val parent =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2",
"SELECT g.generation_id FROM sync_generations g WHERE " +
"g.producer_peer_id=? AND g.consumer_peer_id=? AND " +
"g.status='acknowledged' AND EXISTS(SELECT 1 FROM " +
"sync_acknowledgements k WHERE " +
"k.generation_id=g.generation_id AND " +
"k.result='consumed' AND " +
"k.durability='sqlite-commit-full') AND " +
"NOT EXISTS(SELECT 1 FROM " +
"sync_generation_archives a WHERE " +
"a.generation_id=g.generation_id) AND NOT EXISTS(" +
"SELECT 1 FROM sync_generations c WHERE " +
"c.parent_generation_id=g.generation_id AND " +
"c.status='acknowledged' AND EXISTS(SELECT 1 FROM " +
"sync_acknowledgements ck WHERE " +
"ck.generation_id=c.generation_id AND " +
"ck.result='consumed' AND " +
"ck.durability='sqlite-commit-full') AND " +
"NOT EXISTS(SELECT 1 FROM " +
"sync_generation_archives ca WHERE " +
"ca.generation_id=c.generation_id)) LIMIT 2",
arrayOf(producer, consumerPeerId),
)
.use {
@ -421,6 +468,24 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(operationId, generationId, if (existed) 0 else 1),
)
}
val heartRateCorrections =
JSONObject(checkNotNull(artifacts["heart-rate-corrections"]))
.getJSONArray("corrections")
for (index in 0 until heartRateCorrections.length()) {
val operationId =
heartRateCorrections.getJSONObject(index).getString("operation_id")
val existed =
db.rawQuery(
"SELECT 1 FROM sync_causal_publications " +
"WHERE operation_id=? LIMIT 1",
arrayOf(operationId),
)
.use { it.moveToFirst() }
db.execSQL(
"INSERT INTO sync_causal_publications VALUES(?,?,?)",
arrayOf(operationId, generationId, if (existed) 0 else 1),
)
}
val sleep = JSONObject(checkNotNull(artifacts["sleep-diary"]))
val sleepEntries = sleep.getJSONArray("entries")
for (index in 0 until sleepEntries.length()) {
@ -834,26 +899,38 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
}
}
val producerPeer = manifest.getJSONObject("producer").getString("peer_id")
val expectedParent =
/* WHY: the producer can archive one transient fork while this
* consumer retains both immutable consumed audit rows.
* CONTRACT: a new generation extends any current consumed tip;
* a null, old, or unrelated parent remains invalid.
* INVARIANT: fork history is never rewritten to select a tip. */
val tipPredicate =
"g.producer_peer_id=? AND g.result='consumed' AND " +
"NOT EXISTS(SELECT 1 FROM sync_consumed_generations c " +
"WHERE c.parent_generation_id=g.generation_id AND " +
"c.result='consumed')"
val hasCurrentTip =
db.rawQuery(
"SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2",
"SELECT 1 FROM sync_consumed_generations g WHERE " +
tipPredicate + " LIMIT 1",
arrayOf(producerPeer),
)
.use {
if (!it.moveToFirst()) null
else {
val value = it.getString(0)
if (it.moveToNext())
throw SyncGenerationException(
"consumed generation lineage has multiple tips"
)
value
}
}
.use { it.moveToFirst() }
val suppliedParent =
if (manifest.isNull("parent_generation_id")) null
else manifest.getString("parent_generation_id")
if (suppliedParent != expectedParent)
val suppliedParentIsTip =
suppliedParent != null &&
db.rawQuery(
"SELECT 1 FROM sync_consumed_generations g WHERE " +
"g.generation_id=? AND " + tipPredicate + " LIMIT 1",
arrayOf(suppliedParent, producerPeer),
)
.use { it.moveToFirst() }
if (
(!hasCurrentTip && suppliedParent != null) ||
(hasCurrentTip && !suppliedParentIsTip)
)
throw SyncGenerationException("generation lineage is stale or unrelated")
val a = frozen.artifacts
fun apply(name: String, result: () -> Any) {

View file

@ -80,6 +80,8 @@ import java.security.MessageDigest
import java.time.Duration
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatterBuilder
import java.time.temporal.ChronoField
import java.time.temporal.WeekFields
import java.util.Locale
import java.util.UUID
@ -88,6 +90,11 @@ private val PROFILE_REVISION_NAMESPACE = UUID.fromString("4f4c8ea6-18f6-5e48-9d6
private val PROFILE_REVISION_PATTERN = Regex("^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
private val PROTOTYPE_PROFILE_REVISION_PATTERN = Regex("^pr1\\|(sets|continuous)\\|(reps|duration)\\|([0-9]+)$")
private const val PROFILE_HISTORY_MAX = 32
private val SLEEP_QUICK_TIMESTAMP = DateTimeFormatterBuilder()
.appendPattern("uuuu-MM-dd'T'HH:mm:ss")
.appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
.appendOffsetId()
.toFormatter()
/** Fixed-width UUIDv5 identity over the direct causal parent and resulting profile. */
internal fun exerciseProfileRevision(parent: String, recording: String, tracking: String, fields: Int): String {
@ -708,7 +715,10 @@ class TrainlogRepository(
arrayOf(revisionId,event.eventId.ifEmpty { sleepId("sle") },event.type.wireValue,event.startAt,event.endAt),
) }
draft.intakes.forEach { intake -> db.execSQL(
"INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?,?)",
"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.ifEmpty { sleepId("mdi") }, intake.medicationId,
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
intake.quantity,
@ -858,15 +868,37 @@ class TrainlogRepository(
fun quickSleepWake(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult =
mutateActiveSleep(at) { current ->
current.quickDraft(
at,
events = current.events + SleepDiaryEvent(
val wake = OffsetDateTime.parse(at)
val inferredEnd = wake.plusMinutes(30).format(SLEEP_QUICK_TIMESTAMP)
val openIndex = current.events.indexOfLast { event ->
event.type == SleepEventType.LONG_AWAKE &&
event.endAt != null &&
!wake.isBefore(OffsetDateTime.parse(event.startAt)) &&
!wake.isAfter(OffsetDateTime.parse(event.endAt))
}
/*
* WHY: one-tap capture cannot require a second interaction from a
* partly awake user, but a point event cannot drive an awakening
* interval in agenda/PDF projections.
* CONTRACT: Réveil records a 30-minute LONG_AWAKE interval. A new
* tap inside that window resets its inferred end to 30 minutes
* after the latest tap; Levé may subsequently clamp it.
* INVARIANT: this is explicit product policy, never an inference
* from BPM/RR, and every persisted LONG_AWAKE has start < end.
*/
val events = if (openIndex >= 0) {
current.events.mapIndexed { index, event ->
if (index == openIndex) event.copy(endAt = inferredEnd) else event
}
} else {
current.events + SleepDiaryEvent(
sleepId("sle"),
SleepEventType.NIGHT_GET_UP,
SleepEventType.LONG_AWAKE,
at,
null,
),
)
inferredEnd,
)
}
current.quickDraft(at, events = events)
}
fun quickSleepMedication(
@ -933,9 +965,24 @@ class TrainlogRepository(
val activeBefore = activeSleepDiaryEntry() ?: return SleepQuickActionResult.NoActiveNight
val result =
mutateActiveSleep(at) { current ->
val finalGetUp = OffsetDateTime.parse(at)
val boundedEvents = current.events.map { event ->
if (
event.type == SleepEventType.LONG_AWAKE &&
event.endAt != null &&
OffsetDateTime.parse(event.startAt).isBefore(finalGetUp) &&
OffsetDateTime.parse(event.endAt).isAfter(finalGetUp)
) {
// CONTRACT: final get-up is a factual upper bound; an
// inferred 30-minute awakening never extends past it.
event.copy(endAt = at)
} else {
event
}
}
current.quickDraft(
at,
events = current.events + SleepDiaryEvent(
events = boundedEvents + SleepDiaryEvent(
sleepId("sle"),
SleepEventType.FINAL_GET_UP,
at,
@ -1413,6 +1460,18 @@ class TrainlogRepository(
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
arrayOf(entryId),
).use { if (it.moveToFirst()) it.getString(0) to it.getInt(1) else null }
val knownOwner = db.rawQuery(
"SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(revisionId),
).use { if (it.moveToFirst()) it.getString(0) else null }
if (knownOwner != null &&
(knownOwner != entryId ||
!sleepDiaryRevisionMatches(db, item, wireVersion, entryId, revisionId, parent))
) {
return SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content",
)
}
if (local?.first == revisionId) {
val same = db.rawQuery(
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," +
@ -1552,7 +1611,10 @@ class TrainlogRepository(
draft.events.forEach { event -> db.execSQL("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
arrayOf(revisionId,event.eventId,event.type.wireValue,event.startAt,event.endAt)) }
draft.intakes.forEach { intake -> db.execSQL(
"INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?,?)",
"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,
@ -1567,6 +1629,78 @@ class TrainlogRepository(
}
}
private fun sleepDiaryRevisionMatches(
db: SQLiteDatabase,
item: JSONObject,
wireVersion: Int,
entryId: String,
revisionId: String,
parentRevisionId: String?,
): Boolean {
/* WHY: an incoming snapshot can name an ancestor rather than the
* current tip. CONTRACT: stale replay is idempotent only after its
* immutable revision-owned payload has been compared byte-semantically.
* INVARIANT: descendants never hide identity reuse in history. */
var same = db.rawQuery(
"SELECT entry_id,parent_revision_id,created_at,sleep_quality,wake_quality,day_form," +
"COALESCE(treatment_and_notes,'')," +
"(SELECT COUNT(*) FROM sleep_diary_events e WHERE e.revision_id=r.revision_id)," +
"(SELECT COUNT(*) FROM sleep_medication_intakes i WHERE i.revision_id=r.revision_id) " +
"FROM sleep_diary_revisions r WHERE revision_id=?",
arrayOf(revisionId),
).use { current ->
current.moveToFirst() && current.getString(0) == entryId &&
(if (current.isNull(1)) null else current.getString(1)) == parentRevisionId &&
current.getString(2) == item.getString("updated_at") &&
(if (current.isNull(3)) null else current.getString(3)) ==
(if (item.isNull("sleep_quality")) null else item.getString("sleep_quality")) &&
(if (current.isNull(4)) null else current.getString(4)) ==
(if (item.isNull("wake_quality")) null else item.getString("wake_quality")) &&
(if (current.isNull(5)) null else current.getString(5)) ==
(if (item.isNull("day_form")) null else item.getString("day_form")) &&
current.getString(6) == item.getString("treatment_and_notes") &&
current.getInt(7) == item.getJSONArray("events").length() &&
current.getInt(8) == item.getJSONArray("intakes").length()
}
val events = item.getJSONArray("events")
for (index in 0 until events.length()) {
val event = events.getJSONObject(index)
same = same && db.rawQuery(
"SELECT event_type,start_at,end_at FROM sleep_diary_events " +
"WHERE revision_id=? AND event_id=?",
arrayOf(revisionId, event.getString("event_id")),
).use { current ->
current.moveToFirst() && current.getString(0) == event.getString("type") &&
current.getString(1) == event.getString("start_at") &&
(if (current.isNull(2)) null else current.getString(2)) ==
(if (event.isNull("end_at")) null else event.getString("end_at"))
}
}
val intakes = item.getJSONArray("intakes")
for (index in 0 until intakes.length()) {
val intake = intakes.getJSONObject(index)
same = same && db.rawQuery(
"SELECT medication_id,medication_name,taken_at,dose_value,dose_unit,quantity," +
"COALESCE(note,''),created_at FROM sleep_medication_intakes " +
"WHERE revision_id=? AND intake_id=?",
arrayOf(revisionId, intake.getString("intake_id")),
).use { current ->
current.moveToFirst() &&
current.getString(0) == intake.getString("medication_id") &&
current.getString(1) == intake.getString("medication_name") &&
current.getString(2) == intake.getString("taken_at") &&
(if (current.isNull(3)) null else current.getDouble(3)) ==
(if (intake.isNull("dose_value")) null else intake.getDouble("dose_value")) &&
(if (current.isNull(4)) null else current.getString(4)) ==
(if (intake.isNull("dose_unit")) null else intake.getString("dose_unit")) &&
current.getInt(5) == (if (wireVersion == 1) 1 else intake.getInt("quantity")) &&
current.getString(6) == intake.getString("note") &&
current.getString(7) == intake.getString("created_at")
}
}
return same
}
private fun markSleepDiaryImported(
db: SQLiteDatabase,
entryId: String,
@ -1814,6 +1948,55 @@ class TrainlogRepository(
"SELECT run_id FROM cardio_guidance_runs WHERE session_id=?)",
arrayOf(endedAt, entryId, row.first),
)
val continuous =
db.rawQuery(
"SELECT de.id,de.data_fields,ca.draft_exercise_row_id " +
"FROM draft_session_exercises de " +
"LEFT JOIN draft_continuous_activity ca " +
"ON ca.draft_exercise_row_id=de.id " +
"WHERE de.draft_id=1 AND de.entry_id=? " +
"AND de.recording_mode='continuous'",
arrayOf(entryId),
).use { cursor ->
if (!cursor.moveToFirst()) null
else Triple(
cursor.getLong(0),
cursor.getInt(1),
!cursor.isNull(2),
)
}
if (continuous != null && !continuous.third) {
check(continuous.second == 0) {
"Continuous speed or distance must be recorded before the exercise is finished."
}
val measuredSeconds =
Duration.between(
OffsetDateTime.parse(row.third),
OffsetDateTime.parse(endedAt),
).seconds
check(measuredSeconds in 1..MAX_PLAN_DURATION_SECONDS.toLong()) {
"Measured continuous exercise duration is invalid."
}
/*
* WHY: a Program occurrence starts as prescription-only, but
* pressing Finish makes its already-recorded timeline interval
* an actual performance fact.
* CONTRACT: only a target-only continuous occurrence without
* supplemental fields is materialized from its own measured
* start/end markers; an entered fact is never overwritten.
* INVARIANT: timeline completion and performed-duration
* creation commit or roll back together.
*/
db.insertOrThrow(
"draft_continuous_activity",
null,
ContentValues().apply {
put("draft_exercise_row_id", continuous.first)
put("duration_seconds", measuredSeconds)
},
)
}
val timing =
SessionExerciseTiming(row.first, entryId, row.second, row.third, endedAt)
db.setTransactionSuccessful()
@ -1827,6 +2010,61 @@ class TrainlogRepository(
}
}
/**
* WHY: an older build could close a target-only continuous timeline while
* leaving its performed row absent, making the preserved draft impossible
* to finalize after upgrade.
* CONTRACT: only completed timeline facts for continuous occurrences with
* no supplemental fields may supply the missing performed duration.
* INVARIANT: existing performed rows and recorded timestamps are never
* changed; every inserted duration is positive, bounded, and transaction
* owned by the caller.
*/
private fun materializeCompletedContinuousTimings(
db: SQLiteDatabase,
sessionId: String,
) {
val missing = mutableListOf<Triple<Long, String, String>>()
db.rawQuery(
"SELECT de.id,t.started_at,t.ended_at,de.data_fields " +
"FROM draft_session_exercises de " +
"JOIN active_session_draft d ON d.id=de.draft_id " +
"JOIN session_timeline_exercises t ON t.session_id=d.session_id " +
"AND t.entry_id=de.entry_id " +
"LEFT JOIN draft_continuous_activity ca " +
"ON ca.draft_exercise_row_id=de.id " +
"WHERE d.id=1 AND d.session_id=? " +
"AND de.recording_mode='continuous' AND t.ended_at IS NOT NULL " +
"AND ca.draft_exercise_row_id IS NULL",
arrayOf(sessionId),
).use { cursor ->
while (cursor.moveToNext()) {
check(cursor.getInt(3) == 0) {
"Continuous speed or distance is missing from a completed exercise."
}
missing += Triple(cursor.getLong(0), cursor.getString(1), cursor.getString(2))
}
}
missing.forEach { (draftExerciseRowId, startedAt, endedAt) ->
val measuredSeconds =
Duration.between(
OffsetDateTime.parse(startedAt),
OffsetDateTime.parse(endedAt),
).seconds
check(measuredSeconds in 1..MAX_PLAN_DURATION_SECONDS.toLong()) {
"Measured continuous exercise duration is invalid."
}
db.insertOrThrow(
"draft_continuous_activity",
null,
ContentValues().apply {
put("draft_exercise_row_id", draftExerciseRowId)
put("duration_seconds", measuredSeconds)
},
)
}
}
private fun readCardioCalibrationProfile(
db: SQLiteDatabase,
calibrationId: String,
@ -3396,6 +3634,7 @@ class TrainlogRepository(
artifacts["body-zones"] = buildExerciseBodyZonesJson(true)
artifacts["feedback"] = buildTrainingFeedbackJson(true)
artifacts["causal-deletions"] = buildCausalDeletionExportV1Json()
artifacts["heart-rate-corrections"] = buildHeartRateCorrectionsV1Json()
artifacts["program-executions"] = buildProgramExecutionsV1Json()
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
artifacts["heart-rate"] = buildHeartRateV1Json()
@ -5465,15 +5704,32 @@ class TrainlogRepository(
}
}
fun finalizeActiveSessionDraft(): FinalizeActiveDraftResult =
finalizeActiveSessionDraftInternal(null)
/**
* Finalize the active singleton atomically. An explicit offset-aware end
* is reserved for factual recovery; null retains the live-clock behavior.
*/
fun finalizeActiveSessionDraft(endedAt: String? = null): FinalizeActiveDraftResult =
finalizeActiveSessionDraftInternal(null, endedAt)
/** Finalize exactly one lifecycle identity; retry after commit is idempotent. */
fun finalizeExecutionDraft(sessionId: String): FinalizeActiveDraftResult =
finalizeActiveSessionDraftInternal(sessionId)
/**
* Finalize exactly one lifecycle identity; retry after commit is
* idempotent. The optional factual end follows the same bounds as active
* singleton finalization.
*/
fun finalizeExecutionDraft(
sessionId: String,
endedAt: String? = null,
): FinalizeActiveDraftResult =
finalizeActiveSessionDraftInternal(sessionId, endedAt)
private fun finalizeActiveSessionDraftInternal(expectedSessionId: String?):
private fun finalizeActiveSessionDraftInternal(
expectedSessionId: String?,
requestedEndedAt: String?,
):
FinalizeActiveDraftResult {
val finalizedAt = requestedEndedAt ?: OffsetDateTime.now().toString()
val parsedFinalizedAt = TrainlogTimestamp.parse(finalizedAt)
?: return FinalizeActiveDraftResult.Invalid("Invalid session end timestamp.")
var db: SQLiteDatabase? = null
var transactionOpen = false
return try {
@ -5492,7 +5748,7 @@ class TrainlogRepository(
return FinalizeActiveDraftResult.Saved(expectedSessionId)
}
}
val active =
var active =
loadActiveSessionDraft(db)
if (active == null) {
db.endTransaction()
@ -5521,7 +5777,15 @@ class TrainlogRepository(
"ACTIVE_EXERCISE:$activeEntryId",
)
}
val finalizedAt = OffsetDateTime.now().toString()
val sessionStartedAt = TrainlogTimestamp.parse(active.draft.startedAt!!)
?: error("Active execution draft has an invalid started_at.")
if (parsedFinalizedAt < sessionStartedAt) {
db.endTransaction()
transactionOpen = false
return FinalizeActiveDraftResult.Invalid(
"Session cannot end before it starts.",
)
}
val latestExerciseEnd =
db.rawQuery(
"SELECT MAX(ended_at) FROM session_timeline_exercises WHERE session_id=?",
@ -5532,7 +5796,7 @@ class TrainlogRepository(
if (
latestExerciseEnd != null &&
TrainlogTimestamp.parse(latestExerciseEnd)!! >
TrainlogTimestamp.parse(finalizedAt)!!
parsedFinalizedAt
) {
db.endTransaction()
transactionOpen = false
@ -5541,6 +5805,11 @@ class TrainlogRepository(
)
}
materializeCompletedContinuousTimings(db, lifecycleSessionId)
active = checkNotNull(loadActiveSessionDraft(db)) {
"Active execution draft disappeared during finalization."
}
val completed =
SessionDraft(
exercises =
@ -5577,12 +5846,54 @@ class TrainlogRepository(
"WHERE session_id=? AND ended_at IS NULL",
arrayOf(finalizedAt, sessionId),
)
db.execSQL(
"UPDATE heart_rate_captures SET ended_at=?,active_slot=NULL," +
"active_exercise_entry_id=NULL WHERE active_slot=1 AND context_id=? " +
"AND context_kind IN('session','cardio')",
arrayOf(finalizedAt, sessionId),
)
val activeCapture =
db.rawQuery(
"SELECT capture_id FROM heart_rate_captures " +
"WHERE active_slot=1 AND context_id=? " +
"AND context_kind IN('session','cardio')",
arrayOf(sessionId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
if (activeCapture != null) {
val removedSequences = mutableListOf<Long>()
db.rawQuery(
"SELECT sequence,observed_at FROM heart_rate_samples " +
"WHERE capture_id=? ORDER BY sequence",
arrayOf(activeCapture),
).use { cursor ->
while (cursor.moveToNext()) {
val observedAt = cursor.getString(1)
val observedKey = TrainlogTimestamp.parse(observedAt)
?: error("Heart-rate sample has an invalid timestamp.")
if (observedKey > parsedFinalizedAt) {
removedSequences += cursor.getLong(0)
}
}
}
/*
* WHY: delayed recovery can establish a factual session end
* earlier than samples recorded while the failed draft stayed
* open; those later measurements are not part of the session.
* CONTRACT: the session/timeline use the requested factual
* end inclusively. Samples strictly after it are deleted, and
* their RR children follow through the declared cascade.
* INVARIANT: every retained sample is at or before the stopped
* capture end; retained sample identities are never rewritten.
*/
removedSequences.forEach { sequence ->
check(
db.delete(
"heart_rate_samples",
"capture_id=? AND sequence=?",
arrayOf(activeCapture, sequence.toString()),
) == 1,
)
}
db.execSQL(
"UPDATE heart_rate_captures SET ended_at=?,active_slot=NULL," +
"active_exercise_entry_id=NULL WHERE capture_id=? AND active_slot=1",
arrayOf(finalizedAt, activeCapture),
)
}
closeCardioGuidanceRunForSession(db, sessionId, finalizedAt)
/* INVARIANT: completion moves the exact Program provenance from
@ -7065,7 +7376,10 @@ class TrainlogRepository(
private fun buildCausalDeletionExportV1Json(db: SQLiteDatabase): String {
val operations = JSONArray()
db.rawQuery("SELECT operation_id,target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256 FROM sync_causal_operations ORDER BY operation_id", null).use { cursor ->
db.rawQuery("SELECT operation_id,target_kind,target_id,creator_id," +
"predecessor_revision_id,created_at,payload_sha256 FROM sync_causal_operations " +
"WHERE target_kind IN('session','execution_draft','exercise','body_observation'," +
"'custom_equipment','feedback','body_zone_relation') ORDER BY operation_id", null).use { cursor ->
while (cursor.moveToNext()) {
check(operations.length() < MAX_CAUSAL_OPERATIONS) { "Causal protection set exceeds artifact bound." }
operations.put(JSONObject().put("operation_id", cursor.getString(0)).put("target_kind", cursor.getString(1))
@ -7078,6 +7392,50 @@ class TrainlogRepository(
.put("generated_at", OffsetDateTime.now().toString()).put("operations", operations).toString()
}
/**
* WHY: Heart Rate V1 bytes and causal-deletions V1 semantics are frozen.
* CONTRACT: acknowledged capture cutoffs therefore travel in their own
* strict V1 companion while reusing the durable causal operation ledger.
* INVARIANT: this exporter never emits a heart-rate operation through the
* frozen general-deletion artifact.
*/
internal fun buildHeartRateCorrectionsV1Json(): String {
val corrections = JSONArray()
database.readableDatabase.rawQuery(
"SELECT operation_id,target_id,creator_id,predecessor_revision_id," +
"created_at,payload_sha256 FROM sync_causal_operations " +
"WHERE target_kind='heart_rate_tail' ORDER BY operation_id",
null,
).use { cursor ->
while (cursor.moveToNext()) {
check(corrections.length() < MAX_HEART_RATE_CORRECTIONS)
val target = cursor.getString(1).split('|', limit = 2)
check(target.size == 2 && sleepIdentity(target[0], "hrc") &&
TrainlogTimestamp.parse(target[1]) != null)
corrections.put(
JSONObject()
.put("operation_id", cursor.getString(0))
.put("capture_id", target[0])
.put("cutoff", target[1])
.put("creator_id", cursor.getString(2))
.put("predecessor_revision_id", cursor.getString(3))
.put("created_at", cursor.getString(4))
.put("payload_sha256", cursor.getString(5)),
)
}
}
return JSONObject()
.put("format", "trainlog-heart-rate-corrections")
.put("version", 1)
.put("generated_at", OffsetDateTime.now().toString())
.put("corrections", corrections)
.toString()
.also {
check(it.toByteArray(StandardCharsets.UTF_8).size <=
MAX_HEART_RATE_CORRECTION_ARTIFACT_BYTES)
}
}
fun applyCausalDeletionExportV1Json(json: String): CausalDeleteResult {
if (json.toByteArray().size > MAX_CAUSAL_ARTIFACT_BYTES || !hasStrictJsonShape(json)) return CausalDeleteResult.Invalid
val root = try { JSONObject(json) } catch (_: Exception) { return CausalDeleteResult.Invalid }
@ -11932,6 +12290,8 @@ private const val MAX_SYNC_NOTE_UTF8_BYTES = 4096
private const val MAX_PENDING_EXECUTION_DRAFTS = 15
private const val MAX_CAUSAL_OPERATIONS = 4096
private const val MAX_CAUSAL_ARTIFACT_BYTES = 4 * 1024 * 1024
private const val MAX_HEART_RATE_CORRECTIONS = 256
private const val MAX_HEART_RATE_CORRECTION_ARTIFACT_BYTES = 4 * 1024 * 1024
private const val MAX_PLAN_SETS = 64
private const val MAX_PLAN_REPS = 10000
private const val MAX_PLAN_DURATION_SECONDS = 86400
@ -11990,7 +12350,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
32,
33,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -12336,6 +12696,14 @@ private class TrainlogDatabaseHelper(
createSleepQuickPublicationTable(db)
version = 32
}
if (version < 33 && newVersion >= 33) {
/* WHY: a revision UUID is shared causal identity, so changing any
* owned payload row in place can make peers disagree permanently.
* CONTRACT: v33 makes Sleep revision payloads append-only; edits
* continue to append through repository APIs with a fresh UUID. */
createSleepImmutabilityTriggers(db)
version = 33
}
if (version != newVersion) {
error(
@ -12475,6 +12843,28 @@ private class TrainlogDatabaseHelper(
entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,
revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,
PRIMARY KEY(generation_id,entry_id))""".trimIndent())
createSleepImmutabilityTriggers(db)
}
private fun createSleepImmutabilityTriggers(db: SQLiteDatabase) {
/* INVARIANT: only owner tips/publication state may advance in place.
* Revision rows and their event/intake children are append-only. */
listOf(
Triple("sleep_diary_revisions", "update", "sleep diary revision is immutable"),
Triple("sleep_diary_revisions", "delete", "sleep diary revision is immutable"),
Triple("sleep_diary_events", "update", "sleep diary event is immutable"),
Triple("sleep_diary_events", "delete", "sleep diary event is immutable"),
Triple("sleep_medication_intakes", "update", "sleep medication intake is immutable"),
Triple("sleep_medication_intakes", "delete", "sleep medication intake is immutable"),
Triple("sleep_medication_revisions", "update", "sleep medication revision is immutable"),
Triple("sleep_medication_revisions", "delete", "sleep medication revision is immutable"),
).forEach { (table, operation, message) ->
db.execSQL(
"CREATE TRIGGER IF NOT EXISTS ${table}_immutable_$operation " +
"BEFORE ${operation.uppercase()} ON $table BEGIN " +
"SELECT RAISE(ABORT,'$message'); END",
)
}
}
private fun createSleepQuickPublicationTable(db: SQLiteDatabase) {

View file

@ -52,6 +52,8 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.BuildConfig
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.EquipmentLoadSemantics
@ -75,7 +77,18 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import java.text.Normalizer
import java.util.Locale
import com.labfytools.trainlog.R
/**
* CONTRACT: a private recovery build may override finalization time for one
* exact stable lifecycle identity only. Empty build inputs and every other
* draft retain the normal repository clock default.
*/
private fun recoveryEndedAt(draft: ActiveSessionDraft): String? =
BuildConfig.ACTIVE_SESSION_RECOVERY_ENDED_AT.takeIf {
BuildConfig.ACTIVE_SESSION_RECOVERY_ID.isNotEmpty() &&
BuildConfig.ACTIVE_SESSION_RECOVERY_ID == draft.sessionId &&
it.isNotEmpty()
}
@Composable
fun SessionScreen(
@ -638,7 +651,9 @@ fun SessionScreen(
} else {
when (
val result =
repository.finalizeActiveSessionDraft()
repository.finalizeActiveSessionDraft(
recoveryEndedAt(currentDraft),
)
) {
is FinalizeActiveDraftResult.Saved -> {
onSessionSaved()
@ -870,7 +885,11 @@ fun SessionScreen(
) {
is SessionExerciseTimingResult.Finished -> {
timelineRevision++
when (val result = repository.finalizeActiveSessionDraft()) {
when (
val result = repository.finalizeActiveSessionDraft(
recoveryEndedAt(currentDraft),
)
) {
is FinalizeActiveDraftResult.Saved -> {
pendingFinalizeActiveEntryId = null
onSessionSaved()

View file

@ -544,7 +544,7 @@
<string name="sleep_quick_wake">👁 WAKE</string>
<string name="sleep_quick_get_up">☀ GET UP</string>
<string name="sleep_bed_recorded">Bed time recorded.</string>
<string name="sleep_wake_recorded">Wake recorded.</string>
<string name="sleep_wake_recorded">30-minute awakening recorded.</string>
<string name="sleep_get_up_recorded">Get-up recorded.</string>
<string name="sleep_medication_recorded">%1$s recorded.</string>
<string name="sleep_undo">UNDO</string>

View file

@ -554,7 +554,7 @@
<string name="sleep_quick_wake">👁 RÉVEIL</string>
<string name="sleep_quick_get_up">☀ LEVÉ</string>
<string name="sleep_bed_recorded">Couché enregistré.</string>
<string name="sleep_wake_recorded">Réveil enregistré.</string>
<string name="sleep_wake_recorded">Réveil de 30 minutes enregistré.</string>
<string name="sleep_get_up_recorded">Levé enregistré.</string>
<string name="sleep_medication_recorded">%1$s enregistré.</string>
<string name="sleep_undo">ANNULER</string>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(32, scalar(db, "PRAGMA user_version"))
assertEquals(33, scalar(db, "PRAGMA user_version"))
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(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()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(32, scalar(db, "PRAGMA user_version"))
assertEquals(33, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and
* 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") }

View file

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

View file

@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
reopened.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(32, 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("ok", text(db, "PRAGMA integrity_check"))
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,32 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BluetoothSyncTransportContractTest {
@Test
fun connectedPeerStartsAutoSyncWithoutUserActionButHonorsCooldown() {
assertTrue(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = true,
cooldownReady = true,
)
)
assertFalse(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = false,
cooldownReady = true,
)
)
assertFalse(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = true,
cooldownReady = false,
)
)
}
@Test
fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() {
assertTrue(

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@ -178,4 +179,280 @@ class SessionTimelineRepositoryTest {
context.deleteDatabase(name)
}
}
@Test
fun programSessionFinalizesMeasuredContinuousTargetAndExtraPerformedSet() {
val name = "program-session-finish-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val programId = "pg_11111111-1111-4111-8111-111111111111"
val programSessionId = "pgs_22222222-2222-4222-8222-222222222222"
val warmupId = "ex_33333333-3333-4333-8333-333333333333"
val rowId = "ex_44444444-4444-4444-8444-444444444444"
val pressId = "ex_55555555-5555-4555-8555-555555555555"
val curlId = "ex_66666666-6666-4666-8666-666666666666"
fun catalogExercise(
exerciseId: String,
exerciseName: String,
recordingMode: String,
trackingMode: String,
) =
JSONObject()
.put("exercise_id", exerciseId)
.put("name", exerciseName)
.put("recording_mode", recordingMode)
.put("tracking_mode", trackingMode)
.put("data_fields", 0)
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray()
.put(catalogExercise(warmupId, "Program warmup", "continuous", "duration"))
.put(catalogExercise(rowId, "Program row", "sets", "reps"))
.put(catalogExercise(pressId, "Program press", "sets", "reps"))
.put(catalogExercise(curlId, "Program arm curl", "sets", "reps")),
)
.toString()
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
fun occurrence(
entryId: String,
exerciseId: String,
targetSets: Int?,
targetReps: Int?,
targetDuration: Int?,
targetWeight: Double?,
restSeconds: Int,
) =
JSONObject()
.put("entry_id", entryId)
.put("exercise_id", exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", if (targetWeight == null) "none" else "external")
.put("rest_seconds", restSeconds)
.put("target_sets", targetSets ?: JSONObject.NULL)
.put("target_reps", targetReps ?: JSONObject.NULL)
.put("target_duration_seconds", targetDuration ?: JSONObject.NULL)
.put("target_weight_kg", targetWeight ?: JSONObject.NULL)
.put("notes", JSONObject.NULL)
val program =
JSONObject()
.put("program_id", programId)
.put("revision_id", "pgr_77777777-7777-4777-8777-777777777777")
.put("title", "Session finalization regression")
.put("note", JSONObject.NULL)
.put("state", "active")
.put("start_date", "2026-09-24")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-24T08:00:00Z")
.put("updated_at", "2026-09-24T08:00:00Z")
.put("source_format", "trainlog-program")
.put("source_version", 1)
.put("source_payload_sha256", "a".repeat(64))
.put(
"sessions",
JSONArray().put(
JSONObject()
.put("program_session_id", programSessionId)
.put("title", "Workout")
.put("session_type", "training")
.put("planned_for", "2026-09-24")
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray()
.put(
occurrence(
"pge_88888888-8888-4888-8888-888888888888",
warmupId,
null,
null,
480,
null,
0,
),
)
.put(
occurrence(
"pge_99999999-9999-4999-8999-999999999999",
rowId,
3,
10,
null,
39.0,
90,
),
)
.put(
occurrence(
"pge_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
pressId,
3,
10,
null,
32.0,
90,
),
)
.put(
occurrence(
"pge_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
curlId,
2,
10,
null,
23.0,
60,
),
),
),
),
)
val programs =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-24T08:01:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(programs),
)
assertEquals(
StartProgramSessionResult.Started,
repository.startSyncedProgramSession(programId, programSessionId),
)
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(opened.draft.sessionId)
val sessionStart = OffsetDateTime.parse(checkNotNull(opened.draft.startedAt))
val warmup = opened.draft.exercises[0]
val row = opened.draft.exercises[1]
val press = opened.draft.exercises[2]
val curl = opened.draft.exercises[3]
val performed =
opened.draft.copy(
exercises =
listOf(
warmup,
row.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 39.0) },
),
press.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 32.0) },
),
curl.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 23.0) },
),
),
)
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
val warmupStart = sessionStart.plusSeconds(10)
val warmupEnd = warmupStart.plusSeconds(433)
assertTrue(
repository.startActiveSessionExercise(warmup.entryId, warmupStart.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
repository.finishActiveSessionExercise(warmup.entryId, warmupEnd.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val materialized = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(433, materialized.draft.exercises[0].continuousDurationSeconds)
/* Recreate the exact v49 persisted state: its timeline was closed,
* but the target-only continuous occurrence had no performed row. */
context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null).use { db ->
db.execSQL(
"DELETE FROM draft_continuous_activity WHERE draft_exercise_row_id=(" +
"SELECT id FROM draft_session_exercises WHERE draft_id=1 AND entry_id=?)",
arrayOf(warmup.entryId),
)
}
val legacy = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(0, legacy.draft.exercises[0].continuousDurationSeconds)
listOf(row, press, curl).forEachIndexed { index, exercise ->
val startedAt = warmupEnd.plusSeconds(30L + index * 90L)
val endedAt = startedAt.plusSeconds(60)
assertTrue(
repository.startActiveSessionExercise(exercise.entryId, startedAt.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
repository.finishActiveSessionExercise(exercise.entryId, endedAt.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
}
assertTrue(
repository.saveDraftExerciseFeedback(
curl.entryId,
"Valid effort feedback",
warmupEnd.plusSeconds(400).toString(),
) is SaveFeedbackResult.Saved,
)
val measurement =
ParsedHeartRateMeasurement(
bpm = 107,
sensorContactDetected = true,
energyExpended = null,
rrIntervals1024 = listOf(620),
)
val finalizationAt = warmupEnd.plusSeconds(500)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
finalizationAt.minusSeconds(1).toString(),
measurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val lateSampleAt = finalizationAt.plusSeconds(10)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
lateSampleAt.toString(),
measurement.copy(bpm = 99),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val finalized = repository.finalizeActiveSessionDraft(finalizationAt.toString())
assertEquals(
sessionId,
(finalized as FinalizeActiveDraftResult.Saved).sessionId,
)
val detail = checkNotNull(repository.getSessionDetail(sessionId))
assertEquals(finalizationAt.toString(), detail.summary.endedAt)
assertEquals(4, detail.exercises.size)
assertEquals(433, detail.exercises[0].continuousDurationSeconds)
assertEquals(2, detail.exercises[3].plan?.sets)
assertEquals(3, detail.exercises[3].sets.size)
assertTrue(detail.exercises[3].sets.all { it.reps == 10 && it.weightKg == 23.0 })
assertEquals(1, detail.exercises[3].feedback.size)
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
assertEquals(finalizationAt.toString(), timelineSession.getString("ended_at"))
assertEquals(4, timelineSession.getJSONArray("exercises").length())
val heart = JSONObject(repository.buildHeartRateV1Json())
val capture = heart.getJSONArray("captures").getJSONObject(0)
assertEquals(finalizationAt.toString(), capture.getString("ended_at"))
assertEquals(1, capture.getJSONArray("samples").length())
assertEquals(1, capture.getJSONArray("samples").getJSONObject(0)
.getJSONArray("rr_intervals_1024").length())
assertNull(repository.activeHeartRateCapture())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -1,6 +1,8 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEvent
@ -11,6 +13,7 @@ import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@ -73,6 +76,141 @@ class SleepDiaryRepositoryTest {
} finally { repository.close(); context.deleteDatabase(name) }
}
@Test
fun persistedSleepRevisionPayloadIsAppendOnly() {
val name = "sleep-immutable-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
val saved = try {
repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-25T07:00:00+01:00",
sleepQuality = SleepQuality.B,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
} finally {
repository.close()
}
val raw = SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
)
try {
assertEquals(33, raw.version)
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
arrayOf(saved.revisionId),
)
}
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"DELETE FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(saved.revisionId),
)
}
assertEquals(
"B",
raw.rawQuery(
"SELECT sleep_quality FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(saved.revisionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
},
)
} finally {
raw.close()
context.deleteDatabase(name)
}
}
@Test
fun successiveSleepEditsKeepUniqueIdentitiesAndExactParentage() {
val name = "sleep-revision-chain-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
val identities = mutableListOf<String>()
val expectedParents = mutableMapOf<String, String?>()
val expectedNotes = mutableMapOf<String, String>()
try {
val created = repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-25T07:00:00+01:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "revision-root",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
identities += created.revisionId
expectedParents[created.revisionId] = null
expectedNotes[created.revisionId] = "revision-root"
repeat(64) { index ->
val current = repository.listSleepDiary().single()
val note = "revision-$index"
val hour = 8 + index / 60
val minute = index % 60
val saved = repository.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = current.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = "2026-10-25T%02d:%02d:00+01:00".format(hour, minute),
sleepQuality = current.sleepQuality,
wakeQuality = current.wakeQuality,
dayForm = current.dayForm,
treatmentAndNotes = note,
events = current.events,
intakes = current.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
expectedParents[saved.revisionId] = current.revisionId
expectedNotes[saved.revisionId] = note
identities += saved.revisionId
}
} finally {
repository.close()
}
assertEquals(65, identities.toSet().size)
val raw = SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
)
try {
identities.forEach { revisionId ->
raw.rawQuery(
"SELECT parent_revision_id,treatment_and_notes " +
"FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(revisionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
val parent = if (cursor.isNull(0)) null else cursor.getString(0)
assertEquals(expectedParents[revisionId], parent)
assertEquals(expectedNotes[revisionId], cursor.getString(1))
}
}
} finally {
raw.close()
context.deleteDatabase(name)
}
}
@Test
fun ancestryAllowsMultiHopRemoteAdvance() {
val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db"
@ -188,6 +326,15 @@ class SleepDiaryRepositoryTest {
assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality)
assertEquals(SleepQuality.TB, destination.listSleepDiary().single().dayForm)
assertEquals("Medication revised", destination.listSleepMedications().single().name)
val reusedAncestor = org.json.JSONObject(firstSnapshot)
reusedAncestor.getJSONArray("entries").getJSONObject(0).put("sleep_quality", "M")
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content",
),
destination.applySleepDiaryV1Json(reusedAncestor.toString()),
)
} finally {
source.close()
destination.close()

View file

@ -142,8 +142,18 @@ class SleepQuickCaptureTest {
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
assertEquals(
2,
beforeGetUp.events.count { it.type == SleepEventType.NIGHT_GET_UP },
beforeGetUp.events.count { it.type == SleepEventType.LONG_AWAKE },
)
assertEquals(
listOf(
"2026-09-24T02:17:00+02:00" to "2026-09-24T02:47:00+02:00",
"2026-09-24T04:42:00+02:00" to "2026-09-24T05:12:00+02:00",
),
beforeGetUp.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
assertTrue(beforeGetUp.events.none { it.type == SleepEventType.NIGHT_GET_UP })
assertEquals(2, beforeGetUp.intakes.size)
assertEquals(2, beforeGetUp.intakes.first().quantity)
assertEquals(5.0, checkNotNull(beforeGetUp.intakes.first().doseValue), 0.0)
@ -217,7 +227,7 @@ class SleepQuickCaptureTest {
assertEquals(
listOf("2026-09-24T02:00:00+02:00"),
night.events
.filter { it.type == SleepEventType.NIGHT_GET_UP }
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt },
)
} finally {
@ -226,6 +236,37 @@ class SleepQuickCaptureTest {
}
}
@Test
fun repeatedWakeExtendsOneWindowAndFinalGetUpClampsIt() {
val name = "sleep-wake-window-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
repository.quickSleepBedTime("2026-09-23T22:05:00+02:00")
repository.quickSleepWake("2026-09-24T04:11:00+02:00")
repository.quickSleepWake("2026-09-24T04:14:00+02:00")
val extended = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:44:00+02:00"),
extended.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
repository.quickSleepFinalGetUp("2026-09-24T04:14:30+02:00")
val completed = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:14:30+02:00"),
completed.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun bedtimeAndFinalGetUpWithoutMeasurementFabricateNoCapture() {
val name = "sleep-no-sensor-" + UUID.randomUUID()

View file

@ -11,6 +11,7 @@ import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepMedication
import com.labfytools.trainlog.model.TrackingMode
import java.io.File
import java.nio.file.Files
@ -30,6 +31,299 @@ import org.robolectric.annotation.Config
class SyncGenerationServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun sleepOnlyNightCrossesDesktopAckReplayAndSecondRevisionWithoutSession() {
val sourceName = "sleep-only-generation-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-sleep-only-generation-").toFile()
val desktopDb = java.io.File(root, "desktop.sqlite")
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val generationTool =
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
var source = TrainlogRepository(context, sourceName)
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
try {
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
generationTool,
"peer-id",
"--database",
desktopDb.absolutePath,
)
)!!
.groupValues[1]
val medicationCreated =
source.saveSleepMedication(
SleepMedication(
medicationId = "",
revisionId = "",
createdAt = "2026-09-23T20:00:00+02:00",
updatedAt = "2026-09-23T20:00:00+02:00",
name = "Synthetic sleep-only medication",
defaultDoseValue = 5.0,
defaultDoseUnit = "mg",
form = "tablet",
note = "",
active = true,
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val pendingRoot =
source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-23",
nightEndDate = "2026-09-24",
createdAt = "2026-09-23T22:00:00+02:00",
updatedAt = "2026-09-23T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
intakes = emptyList(),
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val preBed =
source.quickSleepMedication(
medicationCreated.entryId,
"2026-09-23T22:20:00+02:00",
quantity = 2,
) as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(pendingRoot.entryId, preBed.receipt.entryId)
source.quickSleepMedication(
medicationCreated.entryId,
"2026-09-23T22:21:00+02:00",
) as TrainlogRepository.SleepQuickActionResult.Applied
source.inSyncGenerationTransaction { db ->
assertEquals(
2,
db.rawQuery(
"SELECT COUNT(*) FROM sleep_medication_intakes WHERE revision_id=" +
"(SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?)",
arrayOf(preBed.receipt.entryId),
).use { cursor ->
cursor.moveToFirst()
cursor.getInt(0)
},
)
}
source.close()
source = TrainlogRepository(context, sourceName)
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
val bed =
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
assertEquals(2, source.listSleepDiary().single().intakes.size)
source.close()
source = TrainlogRepository(context, sourceName)
val firstMeasurement =
ParsedHeartRateMeasurement(
bpm = 61,
sensorContactDetected = true,
energyExpended = 7,
rrIntervals1024 = listOf(1000),
)
assertTrue(
source.recordLiveHeartRateForActiveSleep(
"Synthetic HR",
"2026-09-23T22:30:01+02:00",
firstMeasurement,
) is TrainlogRepository.HeartRateMutationResult.Applied
)
assertTrue(
source.quickSleepWake("2026-09-24T02:17:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied
)
assertEquals(2, source.listSleepDiary().single().intakes.size)
source.close()
source = TrainlogRepository(context, sourceName)
assertTrue(
source.recordLiveHeartRateForActiveSleep(
"Synthetic HR",
"2026-09-24T02:17:01+02:00",
firstMeasurement.copy(bpm = 65, rrIntervals1024 = listOf(980)),
) is TrainlogRepository.HeartRateMutationResult.Applied
)
assertTrue(
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied
)
source.close()
source = TrainlogRepository(context, sourceName)
assertTrue(source.listSessions().isEmpty())
val night = source.listSleepDiary().single()
val capture =
JSONObject(source.buildHeartRateV1Json())
.getJSONArray("captures")
.getJSONObject(0)
assertEquals(night.entryId, capture.getString("context_id"))
assertEquals(2, capture.getJSONArray("samples").length())
assertEquals(listOf(2, 1), night.intakes.map { it.quantity })
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val diaryArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "sleep-diary-v2.json").readText())
val heartArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText())
val historyArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "history-v4.json").readText())
assertEquals(0, historyArtifact.getJSONArray("sessions").length())
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
assertEquals(night.entryId, publishedNight.getString("entry_id"))
assertEquals(2, publishedNight.getJSONArray("intakes").length())
assertEquals(
listOf(1, 2),
(0 until publishedNight.getJSONArray("intakes").length()).map { index ->
publishedNight.getJSONArray("intakes").getJSONObject(index).getInt("quantity")
}.sorted(),
)
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
val published = service.publish(captured, java.io.File(root, "android-objects"))
val ack = java.io.File(root, "sleep-only-ack.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ack.absolutePath,
)
val ackDocument = JSONObject(ack.readText())
assertEquals(
"desktop ACK diagnostic: ${ackDocument.getString("diagnostic")}",
"consumed",
ackDocument.getString("result"),
)
assertEquals("acknowledged", service.acceptAcknowledgement(ack.readBytes()))
assertEquals("unchanged", service.acceptAcknowledgement(ack.readBytes()))
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
java.io.File(root, "sleep-only-replay-ack.json").absolutePath,
)
assertEquals(
// The desktop fixture owns one reserved calibration session;
// the empty Android history must not add a workout.
"1|2|2,1|2|2|1",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
"(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
"(SELECT group_concat(quantity,',') FROM (SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}' ORDER BY taken_at,intake_id)) || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
"(SELECT count(*) FROM sessions);",
)
.trim(),
)
val staleWithoutIntakes = JSONObject(source.buildSleepDiaryV1Json())
val staleEntry = staleWithoutIntakes.getJSONArray("entries").getJSONObject(0)
staleEntry.put("revision_id", pendingRoot.revisionId)
staleEntry.put("parent_revision_id", JSONObject.NULL)
staleEntry.put("ancestry", org.json.JSONArray().put(pendingRoot.revisionId))
staleEntry.put("updated_at", "2026-09-23T22:00:00+02:00")
staleEntry.put("events", org.json.JSONArray())
staleEntry.put("intakes", org.json.JSONArray())
val staleResult = source.applySleepDiaryV1Json(staleWithoutIntakes.toString())
assertTrue(staleResult is TrainlogRepository.SleepDiaryImportResult.Applied)
assertEquals(1, (staleResult as TrainlogRepository.SleepDiaryImportResult.Applied).unchanged)
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
val revised =
source.saveSleepDiary(
SleepDiaryDraft(
entryId = night.entryId,
expectedRevision = night.revisionId,
nightStartDate = night.nightStartDate,
nightEndDate = night.nightEndDate,
createdAt = night.createdAt,
updatedAt = "2026-09-24T07:00:00+02:00",
sleepQuality = night.sleepQuality,
wakeQuality = night.wakeQuality,
dayForm = night.dayForm,
treatmentAndNotes = "Sleep-only revision two",
events = night.events,
intakes = night.intakes,
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
revised.entryId,
revised.revisionId,
"2026-09-24T07:01:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved
)
val second = service.capture(root, desktopPeer)
val secondPublished =
service.publish(second, java.io.File(root, "android-objects-second"))
val secondAck = java.io.File(root, "sleep-only-second-ack.json")
run(
"python3",
generationTool,
"consume-desktop",
secondPublished.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
secondAck.absolutePath,
)
assertEquals("acknowledged", service.acceptAcknowledgement(secondAck.readBytes()))
assertEquals(
revised.revisionId,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id='${night.entryId}';",
)
.trim(),
)
assertEquals(2, source.listSleepDiary().single().intakes.size)
assertTrue(source.listSessions().isEmpty())
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
@Test
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
val repositoryRoot =
@ -322,6 +616,13 @@ class SyncGenerationServiceTest {
destinationService.peerId(),
runId,
)
val retryRun = "sy_${UUID.randomUUID()}"
val crossRunResume =
SyncGenerationCoordinator(source)
.resumableGeneration(retryRun, destinationService.peerId())
assertEquals(outbound.generationId, crossRunResume?.generationId)
assertEquals(retryRun, crossRunResume?.runId)
assertEquals(outbound.manifestSha256, crossRunResume?.manifestSha256)
val outboundDirectory =
sourceService.publish(outbound, java.io.File(root, "source-objects"))
sourceService.acceptAcknowledgement(
@ -353,6 +654,74 @@ class SyncGenerationServiceTest {
}
}
@Test
fun crossRunResumeAcceptsExactImmutableAcknowledgementFromOriginalRun() {
val generationId = "gen_${UUID.randomUUID()}"
val originalRunId = "sy_${UUID.randomUUID()}"
val retryRunId = "sy_${UUID.randomUUID()}"
val digest = "a".repeat(64)
val captured =
CapturedSyncGeneration(generationId, retryRunId, digest, java.io.File("unused"))
val acknowledgement =
JSONObject()
.put("run_id", originalRunId)
.put("generation_id", generationId)
.put("manifest_sha256", digest)
assertTrue(acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
acknowledgement.put("manifest_sha256", "b".repeat(64))
assertTrue(!acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
}
@Test
fun ledgerBackedSiblingMakesRetainedWaitingBranchesCapacityTerminal() {
val name = "generation-superseded-capacity-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-superseded-").toFile()
val repository = TrainlogRepository(context, name)
try {
val service = SyncGenerationService(repository)
val peer = "peer_11111111-1111-4111-8111-111111111111"
val branches = (0 until 4).map { service.capture(root, peer) }
val acknowledged = branches.last()
repository.inSyncGenerationTransaction { db ->
db.execSQL(
"UPDATE sync_generations SET parent_generation_id=? WHERE consumer_peer_id=?",
arrayOf("gen_22222222-2222-4222-8222-222222222222", peer),
)
db.execSQL(
"UPDATE sync_generations SET status='acknowledged',acknowledged_at=? " +
"WHERE generation_id=?",
arrayOf("2026-09-24T07:00:00+02:00", acknowledged.generationId),
)
db.execSQL(
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
arrayOf(
"ack_${UUID.randomUUID()}",
acknowledged.generationId,
acknowledged.runId,
service.peerId(),
peer,
acknowledged.manifestSha256,
"consumed",
"sqlite-commit-full",
"2026-09-24T07:00:00+02:00",
"",
"a".repeat(64),
),
)
}
// The three sibling branches remain durable, but the ledger-backed
// acknowledged sibling proves that none can be the causal tip.
assertEquals(3, branches.dropLast(1).size)
assertTrue(service.capture(root, peer).generationId.startsWith("gen_"))
} finally {
repository.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
}
@Test
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
val databaseName = "generation-actionable-${UUID.randomUUID()}.db"
@ -509,7 +878,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(32, it.getInt(0))
assertEquals(33, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())
@ -872,6 +1241,13 @@ class SyncGenerationServiceTest {
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
sleepEntry.entryId,
sleepEntry.revisionId,
"2026-09-23T07:00:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val heartCapture =
source.startHeartRateCapture(
HeartRateContextKind.SLEEP,
@ -906,6 +1282,18 @@ class SyncGenerationServiceTest {
.getJSONArray("captures")
.length(),
)
val heartRateCorrections =
JSONObject(
java.io.File(
androidToDesktop.stagingDirectory,
"heart-rate-corrections-v1.json",
).readText(),
)
assertEquals(
"trainlog-heart-rate-corrections",
heartRateCorrections.getString("format"),
)
assertEquals(0, heartRateCorrections.getJSONArray("corrections").length())
val androidPublished =
sourceService.publish(
androidToDesktop,
@ -932,6 +1320,10 @@ class SyncGenerationServiceTest {
"acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals(
"unchanged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals(
0,
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
@ -958,6 +1350,69 @@ class SyncGenerationServiceTest {
.trim(),
)
/* A second Sleep-only mutation must advance the same diary entry
* after the first correlated ACK. No new workout is created, and
* replaying either generation remains strictly idempotent. */
val revisedSleep =
source.saveSleepDiary(
SleepDiaryDraft(
entryId = sleepEntry.entryId,
expectedRevision = sleepEntry.revisionId,
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-23T08:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "second sleep-only revision",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
revisedSleep.entryId,
revisedSleep.revisionId,
"2026-09-23T08:01:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val secondSleepGeneration = sourceService.capture(root, desktopPeer)
val secondSleepPublished =
sourceService.publish(
secondSleepGeneration,
java.io.File(root, "android-desktop-objects"),
)
val secondAckFile = java.io.File(root, "android-desktop-second-ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
secondSleepPublished.absolutePath,
"--database",
desktopConsumerDb.absolutePath,
"--ack-output",
secondAckFile.absolutePath,
)
assertEquals(
"acknowledged",
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
)
assertEquals(
"unchanged",
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
)
assertEquals(
"1|${revisedSleep.revisionId}|1",
run(
"sqlite3",
desktopConsumerDb.absolutePath,
"SELECT count(*) || '|' || current_revision_id || '|' || " +
"(SELECT count(*) FROM sessions WHERE session_id='$androidSessionId') " +
"FROM sleep_diary_entries WHERE entry_id='${sleepEntry.entryId}';",
)
.trim(),
)
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,

View file

@ -13,6 +13,8 @@ import com.labfytools.trainlog.model.BodyObservationDraft
import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.ExerciseEditInput
import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
@ -56,6 +58,126 @@ class TrainlogRepositoryDraftTest {
databaseName = "draft-test-${UUID.randomUUID()}.db"
}
@Test
fun delayedFactualFinalizationTruncatesHeartRateSamplesAndRrInclusively() {
val repo = openRepository()
val exercise = createExercise(repo, "Récupération cardio", RecordingMode.SETS, TrackingMode.REPS)
val sessionId = "se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val entryId = "sxe_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
val startedAt = "2026-09-24T10:00:00+02:00"
val cutoff = "2026-09-24T11:16:00+02:00"
assertEquals(
ActiveDraftMutationResult.Saved,
repo.saveActiveSessionDraft(
ActiveSessionDraft(
sessionId = sessionId,
startedAt = startedAt,
exercises = listOf(
SessionExerciseDraft(
entryId = entryId,
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 8, weightKg = 20.0)),
),
),
),
),
)
val capture = repo.startHeartRateCapture(
HeartRateContextKind.SESSION,
sessionId,
startedAt,
"Synthetic",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertTrue(repo.startActiveSessionExercise(
entryId, "2026-09-24T10:30:00+02:00",
) is TrainlogRepository.SessionExerciseTimingResult.Started)
assertTrue(repo.finishActiveSessionExercise(
entryId, "2026-09-24T11:15:00+02:00",
) is TrainlogRepository.SessionExerciseTimingResult.Finished)
listOf(
"2026-09-24T11:15:59.999999+02:00" to 100,
cutoff to 101,
"2026-09-24T11:16:00.000001+02:00" to 40,
).forEachIndexed { index, (observedAt, bpm) ->
assertTrue(
repo.appendHeartRateSample(
capture.captureId,
observedAt,
bpm,
rrIntervals = listOf(HeartRateRrInterval(0, 900 + index)),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
}
assertEquals(
FinalizeActiveDraftResult.Saved(sessionId),
repo.finalizeActiveSessionDraft(cutoff),
)
SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(cutoff, db.rawQuery(
"SELECT ended_at FROM heart_rate_captures WHERE capture_id=?",
arrayOf(capture.captureId),
).use { it.moveToFirst(); it.getString(0) })
assertEquals(
listOf("2026-09-24T11:15:59.999999+02:00", cutoff),
db.rawQuery(
"SELECT observed_at FROM heart_rate_samples WHERE capture_id=? " +
"ORDER BY sequence",
arrayOf(capture.captureId),
).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } },
)
assertEquals(2, db.rawQuery(
"SELECT COUNT(*) FROM heart_rate_rr_intervals WHERE capture_id=?",
arrayOf(capture.captureId),
).use { it.moveToFirst(); it.getInt(0) })
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
}
}
@Test
fun heartRateCorrectionsRemainOutsideFrozenCausalDeletionV1() {
val repo = openRepository()
repo.buildCausalDeletionExportV1Json()
SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL(
"INSERT INTO sync_causal_operations(" +
"operation_id,target_kind,target_id,creator_id,predecessor_revision_id," +
"created_at,payload_sha256,publication_context) VALUES(?,?,?,?,?,?,?,NULL)",
arrayOf(
"hrx_11111111-1111-4111-8111-111111111111",
"heart_rate_tail",
"hrc_22222222-2222-4222-8222-222222222222|" +
"2026-09-24T11:16:00+02:00",
"peer_test",
"lv_test",
"2026-09-24T12:00:00+02:00",
"0".repeat(64),
),
)
}
assertEquals(
0,
JSONObject(repo.buildCausalDeletionExportV1Json())
.getJSONArray("operations")
.length(),
)
val correction = JSONObject(repo.buildHeartRateCorrectionsV1Json())
assertEquals("trainlog-heart-rate-corrections", correction.getString("format"))
assertEquals(
"hrx_11111111-1111-4111-8111-111111111111",
correction.getJSONArray("corrections").getJSONObject(0).getString("operation_id"),
)
}
@After
fun tearDown() {
repository?.close()
@ -2260,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(32, cursor.getInt(0))
assertEquals(33, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(32, cursor.getInt(0))
assertEquals(33, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -4,12 +4,12 @@
"columns": 12,
"max_y": 200,
"tiles": [
{ "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
{ "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
{ "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
{ "id": "next-session", "min_width": 4, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 12, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
{ "id": "last-session", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
{ "id": "max-records", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
]
}

View file

@ -25,6 +25,17 @@ revision. Quick publication makes a current fact eligible for generation; it
does not validate a day and does not change the existing validation/ACK
lifecycle.
Android schema v33 enforces that same immutable identity boundary inside
SQLite. Eight append-only triggers reject update or deletion of Sleep diary
revisions, events, medication intakes, and medication revisions. Repository
imports compare a known ancestor's complete canonical payload before the stale
ancestor shortcut, so a reused ID with a different parent or content cannot be
hidden below a later local tip. All user edits continue to create a fresh
UUIDv4 revision and preserve the previous payload and causal parent.
Medication-intake writes name all columns explicitly, so the additive
`quantity` column cannot reorder note or creation-time values in upgraded
schema-v33 databases.
The quick Sleep capture derives one night identity from the existing
18:00-to-18:00 date rule. A medication action can create that pending entry
before `BED_TIME`, later medication actions reuse it, and Couché appends
@ -34,6 +45,21 @@ successful intake; the per-unit dose and unit remain independent structured
facts. Pending entries persist through restart and can synchronize without
inventing bedtime.
The persistence regression begins from an empty pending revision, records two
pre-bed intakes (one with quantity two), and reopens the repository after
intake, Couché, Réveil and Levé. It then proves the same stable entry and intake
snapshots through generation, desktop import/ACK, exact replay, rejection of a
stale intake-free ancestor and a later current revision. This is the executable
contract for lifecycle and synchronization retention; it does not infer facts
that are absent from a device database.
The one-tap Réveil action records `long_awake` rather than a point
`night_get_up`: its factual start is the tap timestamp and its policy end is 30
minutes later. Another tap inside that window extends the same interval to 30
minutes after the latest tap; Levé clamps an overlapping interval to the exact
final-get-up timestamp. This explicit default requires no Rendormi interaction,
remains revisioned/undoable, and is not derived from heart-rate measurements.
Sleep-owned HR capture remains sample-driven. A pending medication alone opens
no capture. After Couché, the first fresh real measurement creates or resumes
one `context_kind=sleep` capture for that entry, with `started_at` equal to the
@ -663,7 +689,24 @@ resume action after relaunch.
Final save validates the durable draft, inserts the completed session and actual
values, and removes the draft in one SQLite transaction. Failure rolls back and
retains the draft for retry; repeated completion does not create duplicates.
The existing completed-session save-time timestamp behavior is unchanged.
Planned targets remain prescriptions: finalization accepts fewer or more valid
performed sets and actual repetitions or loads that differ from their targets.
Finishing a target-only continuous occurrence with no supplemental fields
materializes its performed duration from that occurrence's already persisted
timeline start/end interval. This also repairs a preserved draft whose interval
was closed by an older build without creating the performed row. Existing
performed values are never overwritten, and continuous occurrences that require
speed or distance still require those measured values explicitly.
Ordinary completion uses the save-time timestamp. A private recovery build may
embed one exact session identity and one explicit offset-aware factual end time;
only that matching draft uses the supplied time. The repository rejects an end
before the session start or latest exercise marker. When heart-rate samples
arrived after a failed factual session end, delayed finalization keeps samples
at or before that inclusive end and transactionally deletes the strict tail.
RR children follow their BPM sample through the declared foreign-key cascade,
and the stopped capture uses the exact factual end. Retained sample identities
and values are never retimed or renumbered.
PC catalog reconciliation preserves draft references through catalog row
ownership. If an editing selection no longer resolves, only the selection is

View file

@ -0,0 +1,37 @@
<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>
<desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.6.</desc>
<style>
.label { font: 600 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #cdd6f4; dominant-baseline: middle; text-anchor: middle; }
</style>
<g>
<rect x="0" y="4" width="172" height="44" rx="12" fill="#1e1e2e" stroke="#b4befe" stroke-width="2"/>
<circle cx="24" cy="26" r="6" fill="#b4befe"/>
<text class="label" x="100" y="27">LOCAL-FIRST</text>
</g>
<g>
<rect x="184" y="4" width="116" height="44" rx="12" fill="#1e1e2e" stroke="#89b4fa" stroke-width="2"/>
<circle cx="208" cy="26" r="6" fill="#89b4fa"/>
<text class="label" x="258" y="27">C17</text>
</g>
<g>
<rect x="312" y="4" width="160" height="44" rx="12" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="2"/>
<circle cx="336" cy="26" r="6" fill="#a6e3a1"/>
<text class="label" x="405" y="27">ANDROID</text>
</g>
<g>
<rect x="484" y="4" width="176" height="44" rx="12" fill="#1e1e2e" stroke="#74c7ec" stroke-width="2"/>
<circle cx="508" cy="26" r="6" fill="#74c7ec"/>
<text class="label" x="584" y="27">LOCAL WEB</text>
</g>
<g>
<rect x="672" y="4" width="132" height="44" rx="12" fill="#1e1e2e" stroke="#cba6f7" stroke-width="2"/>
<circle cx="696" cy="26" r="6" fill="#cba6f7"/>
<text class="label" x="753" y="27">GPLv3</text>
</g>
<g>
<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"/>
<text class="label" x="896" y="27">v0.1.6</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="480" viewBox="0 0 1440 480" role="img" aria-labelledby="title desc">
<title id="title">Trainlog</title>
<desc id="desc">A Catppuccin Mocha banner for a local-first training data system.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#11111b"/>
<stop offset="0.58" stop-color="#181825"/>
<stop offset="1" stop-color="#1e1e2e"/>
</linearGradient>
<linearGradient id="lavender" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#b4befe"/>
<stop offset="1" stop-color="#cba6f7"/>
</linearGradient>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M40 0H0V40" fill="none" stroke="#45475a" stroke-width="1" opacity="0.22"/>
</pattern>
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="7" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect width="1440" height="480" rx="28" fill="url(#bg)"/>
<rect width="1440" height="480" rx="28" fill="url(#grid)"/>
<path d="M0 25Q0 0 25 0H1415Q1440 0 1440 25" fill="none" stroke="#b4befe" stroke-width="4"/>
<g transform="translate(72 64)">
<circle cx="12" cy="12" r="7" fill="#f38ba8"/>
<circle cx="36" cy="12" r="7" fill="#f9e2af"/>
<circle cx="60" cy="12" r="7" fill="#a6e3a1"/>
<text x="90" y="19" fill="#6c7086" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21">~/trainlog</text>
<text x="0" y="164" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="92" font-weight="700" letter-spacing="10">TRAINLOG</text>
<rect x="2" y="190" width="610" height="5" rx="2.5" fill="url(#lavender)"/>
<text x="0" y="243" fill="#b4befe" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="27" letter-spacing="1">capture · synchronize · inspect · understand</text>
<text x="0" y="293" fill="#a6adc8" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21">Android / C17 + Notcurses / Local Web / SQLite</text>
</g>
<g transform="translate(890 72)">
<rect width="470" height="336" rx="22" fill="#11111b" stroke="#45475a" stroke-width="2"/>
<path d="M0 58H470" stroke="#45475a" stroke-width="2"/>
<circle cx="28" cy="29" r="6" fill="#f38ba8"/>
<circle cx="50" cy="29" r="6" fill="#f9e2af"/>
<circle cx="72" cy="29" r="6" fill="#a6e3a1"/>
<text x="96" y="36" fill="#6c7086" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="17">facts.timeline</text>
<path d="M34 222H436" stroke="#313244" stroke-width="2"/>
<path d="M34 118H436M34 170H436M34 274H436" stroke="#313244" stroke-width="1" stroke-dasharray="5 8"/>
<path d="M34 221H78L94 219L111 145L128 263L147 212H188L207 194L226 205L245 114L264 248L286 218H327L345 176L364 203L384 202L402 155L420 221H436" fill="none" stroke="#b4befe" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)"/>
<circle cx="111" cy="145" r="6" fill="#f38ba8"/>
<circle cx="245" cy="114" r="6" fill="#f38ba8"/>
<circle cx="402" cy="155" r="6" fill="#a6e3a1"/>
<rect x="34" y="292" width="112" height="24" rx="12" fill="#313244"/>
<circle cx="50" cy="304" r="5" fill="#a6e3a1"/>
<text x="63" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">local</text>
<rect x="158" y="292" width="136" height="24" rx="12" fill="#313244"/>
<circle cx="174" cy="304" r="5" fill="#89b4fa"/>
<text x="187" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">versioned</text>
<rect x="306" y="292" width="130" height="24" rx="12" fill="#313244"/>
<circle cx="322" cy="304" r="5" fill="#b4befe"/>
<text x="335" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">factual</text>
</g>
<rect x="1" y="1" width="1438" height="478" rx="27" fill="none" stroke="#313244" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -10,6 +10,16 @@ the wired recovery path. The separately configured private Drive transport
remains available to its legacy mirror/fallback workflow. No transport copies
SQLite.
Automatic Bluetooth restart recovery preserves the immutable generation
identity across conversation-run changes and recognizes the exact original-run
ACK by immutable generation ID and manifest digest before full SQLite
validation. An unacknowledged branch is excluded
from the bounded admission count only when a different sibling has a durable,
ledger-backed consumed acknowledgement; the superseded bytes and row remain
retained. The desktop service installer retires the exact obsolete private
ExecStart override that omitted required runtime paths and installs both
`trainlog-syncd` and the BlueZ RFCOMM agent.
Android synchronization paths share the same concurrency-guarded generation
coordinator as SyncScreen. That coordinator has one process-wide ownership
arbiter: explicit foreground intent has priority over automatic background
@ -55,6 +65,12 @@ occurrences: opening an existing stable entry renders its prefilled form,
keeps the independent target intact on save, and leaves sibling occurrences
unchanged on save or cancellation. A set-based target-only occurrence can add
or change equipment without fabricating a performed set.
Session completion now also turns a completed target-only continuous timeline
into its measured performed duration when no supplemental field is required.
Program targets remain independent from facts, so a valid execution may contain
fewer or more sets and different repetitions or loads. A strictly session-bound
private recovery build can supply a past factual end without retiming exercise
markers or immutable heart-rate samples.
The full-generation path is operational on the paired daily installation.
Bluetooth Classic is the primary local byte transport, MTP remains the wired
@ -65,8 +81,8 @@ is idempotent and no transport carries SQLite. The Bluetooth transport does not
yet ingest heart-rate sensor data.
The public Android release chain advances from v0.1.1 versionCode 2 through
v0.1.2 versionCode 3 and v0.1.3 versionCode 4 to v0.1.4 versionCode 5 under
certificate SHA-256
v0.1.2 versionCode 3, v0.1.3 versionCode 4, v0.1.4 versionCode 5 and v0.1.5
versionCode 6 to v0.1.6 versionCode 7 under certificate SHA-256
`5ef41117da107c9805ae3215c5ec823cff06216610e6e4f7542a754161c71bd2`.
Higher-numbered private validation builds used a separate Android Debug
certificate and are not members of the public update-signature chain.
@ -81,7 +97,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
mixed-checkout user-service execution during the controlled rollout.
Snapshot date: **2026-09-23**.
Snapshot date: **2026-09-25**.
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
@ -112,11 +128,11 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v35; v35 additively gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
| Android SQLite | schema v32; v32 additively gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
| 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 | 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 |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.6` development, synchronized across Android and desktop; latest stable release: `v0.1.5` |
| 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 |
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
@ -148,10 +164,11 @@ parallel implementation of its rules.
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
| Recovered session HR tail | `TRAINLOG_HR_TRUNCATE_AFTER_SESSION_END_V1=PASS`: delayed Android finalization truncates BPM/RR strictly after the inclusive factual end, and the separate `trainlog-heart-rate-corrections` V1 companion reuses causal state without widening frozen causal-deletions V1. The paired-device recovery for `se_fa845379-794d-4bb6-8cd3-48187ebbd134` retained 3,429 BPM/RR facts through 11:16; two successive full MTP exchanges received consumed ACKs, and replay of the original 4,919-sample Heart Rate V1 artifact did not resurrect the tail. |
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: existing one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. |
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. Réveil now creates a structured 30-minute `long_awake` window, repeated taps extend it, and Levé clamps it. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. |
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
@ -159,7 +176,11 @@ parallel implementation of its rules.
| Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. |
| 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 |
| Current operational cursor | 0.1.6 development is open after immutable stable v0.1.5; no new feature tranche is selected by the version-opening commit. |
| 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 | 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 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. |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
@ -173,12 +194,32 @@ Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
The active Sleep V2 delta keeps the Web agenda period-owned: every returned
night has one row on the shared 18:00-to-18:00 geometry, selecting a row is
presentation-only, and the selected stable Sleep entry drives the full-width
heart-rate detail. Its rise/fall overlays are descriptive relative measured
night has one row on the shared 18:00-to-18:00 geometry. Selecting a row is
presentation-only and atomically drives the detail dates, editor content, and
full-width heart-rate detail; it performs no database mutation. PDF preview
and download instead share an explicit inclusive range over night start dates,
initialized to the loaded agenda bounds and independent of detail selection. Its
rise/fall overlays are descriptive relative measured
variation only (five-minute trailing reference excluding the current 60-second
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
are not sleep events, sleep stages, causes, readiness, or medical inference.
The detail shares one normalized time projection with declared sleep and
awakening intervals, exact Couché/Réveil/Levé markers, medication markers, and
full-hour ticks between exact visible bounds. Medication presentation retains
unit dose and quantity as separate facts. A missing declared sleep interval
uses only a labeled, non-medical Web estimate from Couché plus 45 minutes to
Levé minus 10 minutes when both bounds exist; it never changes canonical Sleep
data. Agenda, selected-night graph, and ranged PDF consume the same projection of
sleep intervals, every structured long-awake interval, point events, medication
intakes, and bounds. The agenda renders an estimated interval with distinct
hatching, never substitutes it for a factual interval, and retains the exact
shared 18:00-to-18:00 geometry, reinforced by 24 equal hour columns shared by
every row, with each hour label centered inside the cell it names. Displayed
events use one factual night offset even when revision metadata is UTC, and
French agenda headings use `DD/MM/YYYY`. Displayed sleep duration is the sum of factual
sleep intervals or, in their absence, the displayed estimate minus overlapping
structured awakenings. The overlays remain available without an HR capture and
do not fabricate BPM.
Language is device-local presentation state. Android keeps it in dedicated
SharedPreferences; desktop keeps it in the XDG configuration file
@ -306,6 +347,53 @@ the result. The Web aggregate now exposes the distinct factual snapshot through
## Local Web
The 0.1.6 refinement adds a persistent French/English preference and a dedicated
Settings route without moving the global synchronization action. Dashboard
activity is grouped by calendar month/ISO week, its progression tile is now a
global factual synthesis, and Analyse progresses from primary BODY ZONE to
exercise to individual detail. Exercise work time is derived exclusively from
`session_exercise_timeline` start/end facts; missing timelines remain unknown.
Measurements first expose unit-safe relative change cards before real-unit
detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG.
The Dashboard layout keeps a 12-column canonical grid but permits every card
to reach full width and several useful heights; per-card minima, overlap
validation, exact save/cancel/reset semantics, and keyboard resizing remain
authoritative. Settings and synchronization form the rightmost Header action
cluster. Program progress uses the shared success color while retaining its
factual completed/total text. Responsive charts preserve their viewBox aspect
ratio, and Analyse explicitly distinguishes unavailable exercise timing from
partial measured coverage instead of deriving time from sets or repetitions.
The shared Sleep agenda owns one label/track/facts grid. Its 25 exact hourly
positions and every event, interval and medication marker use the same
coordinates: 18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00
at 100%. Ordinary desktop widths show the 13 two-hour labels, wide layouts show
all hours, and narrow layouts retain the five six-hour anchors without changing
the underlying geometry.
The Equipment route projects supplied and custom definitions with exercise,
history, preparation and Program reference counts. Its guarded merge accepts a
custom duplicate only, transfers relational references in one SQLite IMMEDIATE
transaction, preserves occurrence/MAX facts indirectly through occurrence
identity, and writes the existing causal deletion boundary. An immutable
execution-draft JSON reference rejects the operation rather than being edited.
Browser mutation identities use the shared UUIDv4 helper backed by
`crypto.getRandomValues()`; no Web mutation depends on `crypto.randomUUID()`,
so `http://trainlog.perf` remains supported.
An `execution_drafts` row marked active but containing no `exercises` or
legacy `entries` is an orphaned projection, not a resumable workout. Web may
delete that exact row through the existing revision-guarded causal transaction;
the durable tombstone prevents synchronization replay from resurrecting it.
An active draft with any occurrence remains protected from Web deletion.
Generation admission archives only rows backed by an exact correlated consumed
ACK and retains the newest two acknowledged lineage members. Legacy terminal
Android rows that predate the durable ACK ledger are neither fabricated nor
deleted, but no longer consume the active admission window. Captured,
published, waiting-ACK, and current acknowledged rows continue to count until
safe archival, so capacity recovery never prunes unacknowledged evidence.
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
browser shell, Dashboard, layout ownership, build/runtime separation, and
security invariants are canonical. The CLI/local HTTP infrastructure and
@ -425,8 +513,7 @@ The full Android lint comparison against an isolated clean `main` worktree is
unchanged at ten pre-existing errors: one `NewApi`, one
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
diagnostics. This UI tranche adds no lint error and changes no lint policy.
The stable Android v0.1.5 tag uses `versionName=0.1.5` and public versionCode 6.
The v0.1.6 development source starts at `versionName=0.1.6` and versionCode 7.
The stable Android v0.1.6 tag uses `versionName=0.1.6` and public versionCode 7.
Private validation builds may still supply only a strictly increasing
`versionCode` as a build-time override; that does not create a public release
or change schemas and protocols.

View file

@ -1,5 +1,39 @@
# Desktop database
## Causal heart-rate tail correction (schema v36, no migration)
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
`sync_causal_operations` and `sync_causal_state` tables for the additive local
`heart_rate_tail` state. The frozen `trainlog-causal-deletions` V1 artifact
continues to emit and accept exactly its original seven target kinds. A heart-
rate correction's composite target contains the stable capture identity and an
inclusive offset-aware cutoff. Applying it deletes only BPM samples strictly
after the cutoff; declared RR foreign keys cascade those exact children, and
the capture end becomes the cutoff in the same transaction. The predecessor
revision hashes the original ordered raw capture, including RR values, so a
correction cannot be silently applied to a different measurement history.
The immutable Heart Rate V1 generation remains audit evidence. Import applies
known causal cutoffs before stable-identity comparison or insertion, which
makes old-generation replay idempotent without rewriting its manifest or ACK.
No schema or frozen `TRAINLOG_FORMAT_V1` change is involved.
## Schema v36: append-only Sleep revision payloads
Schema v36 adds database-level `BEFORE UPDATE` and `BEFORE DELETE` guards for
Sleep diary revisions, their events and medication intakes, and medication
catalog revisions. A stable revision identity therefore cannot be detached
from its canonical parent or payload even through a direct SQLite write.
Normal correction remains append-only: it inserts a fresh UUIDv4 revision and
advances the entry or medication current pointer transactionally. The migration
is additive and does not rewrite any existing Sleep identity, payload, causal
edge, generation membership, publication state, or acknowledgement.
Importers additionally compare the immutable payload of every already-known
Sleep revision before treating an incoming ancestor as stale/idempotent. Thus
an exact historical replay remains accepted, while reuse of the same identity
with a different parent or content is an explicit collision.
## Schema v35: Sleep Diary quantity V2
Schema v35 additively adds `sleep_medication_intakes.quantity`, a required

View file

@ -320,14 +320,15 @@ name/dose/unit snapshot. Full editing remains available later.
### Réveil
There is no `Rendormi` button.
There is no `Rendormi` button. One tap records a structured `long_awake`
interval beginning at the factual tap time and ending 30 minutes later under
the explicit capture policy. Nothing else is required before the user tries to
return to sleep. A second tap within that window resets its end to 30 minutes
after the latest tap. Levé truncates an overlapping interval to the factual
final-get-up timestamp.
One tap records one punctual night wake event. Nothing else is required before
the user tries to return to sleep.
Trainlog does not infer a certain return-to-sleep time from BPM or RR data.
An approximate interval may be completed later only if the user chooses to
provide it.
The 30-minute end is user-approved product policy, not an inference from BPM or
RR data. Later correction remains available when the actual duration is known.
### Levé
@ -347,11 +348,11 @@ Undo is scoped to that exact action and must not roll back unrelated newer
state.
`Corriger la nuit` provides later editing of timestamps, medication
intakes, accidental wake points, forgotten events, and existing subjective
intakes, awakening intervals, forgotten events, and existing subjective
Sleep Diary fields.
The night-time rule is: one tap, one timestamp, then no further required
interaction.
The night-time rule is: one tap, one 30-minute awakening window, then no further
required interaction.
## Sleep + cardio factual timeline
@ -361,7 +362,7 @@ After synchronization, Analyse may superpose factual events and measurements:
- Couché;
- medication intakes;
- wake points;
- structured 30-minute awakening windows;
- Levé;
- BPM;
- RR when supplied;

View file

@ -691,6 +691,30 @@ enumerate at most 4096 operations plus one sentinel row, and a local deletion
is refused before its effect if the complete protection artifact would exceed
either bound.
The V1 `target_kind` set remains the frozen seven-kind set: `session`,
`execution_draft`, `exercise`, `body_observation`, `custom_equipment`,
`feedback`, and `body_zone_relation`. Heart-rate correction does not widen this
published contract.
## Heart-rate corrections V1
`trainlog-heart-rate-corrections` V1 is a separate optional Android→desktop
full-generation companion named `heart-rate-corrections`, stored as
`heart-rate-corrections-v1.json`. Its root contains `format`, integer `version`,
`generated_at`, and at most 256 `corrections`; the complete UTF-8 artifact is
limited to 4 MiB. Each correction contains a stable UUIDv4-based `operation_id`,
stable `capture_id`, inclusive offset-aware `cutoff`, `creator_id`, exact
`predecessor_revision_id`, `created_at`, and canonical `payload_sha256`.
The predecessor hashes the complete original capture metadata, ordered raw BPM
samples, and ordered RR children. The consumer requires the cutoff to equal the
canonical session end and not precede the session start or latest exercise end.
It deletes only samples strictly after the cutoff in one transaction, relies on
the declared RR cascade, records durable causal state, and applies that state
before any Heart Rate V1 snapshot in the same generation. Unknown fields,
duplicate keys/identities, digest mismatch, ancestry mismatch, and incompatible
capture/session ownership reject the containing generation atomically.
## Staged generation manifest and acknowledgement V1
`trainlog-sync-manifest` V1 is a strict independent envelope. It contains

View file

@ -6,6 +6,7 @@ than later development. GitHub and Forgejo publication must preserve the
existing release, tag, status, and assets while keeping their note bodies equal
to the corresponding source file.
- [Trainlog v0.1.6](v0.1.6.md)
- [Trainlog v0.1.5](v0.1.5.md)
- [Trainlog v0.1.4](v0.1.4.md)
- [Trainlog v0.1.3](v0.1.3.md)

64
docs/releases/v0.1.6.md Normal file
View file

@ -0,0 +1,64 @@
Trainlog v0.1.6 closes the Web refinement and synchronization-recovery cycle.
It improves factual Sleep and training presentation, preserves immutable
history through difficult replay and recovery cases, and gives the repository
a durable Catppuccin-based project entry point without widening frozen product
contracts.
## Local Web refinement
- Dashboard activity uses calendar months and ISO weeks, progression is a
factual global synthesis, and Analyse drills from BODY ZONES to exercises and
measured detail without inventing missing exercise time.
- A persistent French/English preference, Settings route, denser shared
surfaces, responsive charts, and a shared BODY ZONES figure refine the local
interface while presentation state remains outside business SQLite.
- Equipment administration can transactionally merge a custom duplicate into
a canonical identity while preserving history, MAX context, preparations,
Programs, and causal deletion evidence.
## Sleep facts and heart-rate timelines
- The multi-night agenda, selected-night heart-rate detail, and vector PDF use
one temporal projection of factual sleep intervals, awakenings, point events,
medication occurrences, and exact bounds.
- The shared 18:00-to-18:00 agenda keeps exact hourly geometry at desktop and
narrow widths. A clearly labelled fallback estimate is presentation-only,
non-medical, and never written to Sleep data.
- Android wake capture records revisioned long-awake intervals, medication
quantity survives persistence and synchronization, and Sleep revision
payloads are append-only on both platforms.
## Synchronization and recovery
- Bluetooth restart recovery retains immutable generation identity and accepts
the exact original generation/digest acknowledgement across conversation
runs; direct MTP remains the wired recovery transport.
- Android foreground and automatic synchronization share one ownership arbiter,
and direct-MTP publication waits for MediaProvider visibility before exposing
a mutable generation reference.
- Divergent Sleep revision identities can be recovered without choosing an
arbitrary winner. Strict payload checks reject reuse of a historical revision
identity with different parentage or content.
- A separate heart-rate correction companion removes samples strictly after a
factual session end and prevents an older immutable generation from
resurrecting the removed tail.
## Compatibility and validation
- Desktop schema v36 and Android schema v33 are additive migrations.
- `TRAINLOG_FORMAT_V1`, mobile export V3, stable identities, generation/ACK
semantics, and Bluetooth/MTP transport ownership remain unchanged.
- The default automatic exchange remains V3; full-generation synchronization
remains behind its trusted local opt-in.
- Native Meson and Clang ASan/UBSan suites each pass 114/114. Android passes
317 tests (312 passed and five documented optional skips), debug assembly,
signed release assembly, and signature verification. Web passes 32 files / 209
tests plus typecheck/build; JSON/import validation, changed-C formatting, and
diff checks also pass at the release tag.
## Assets
- `trainlog-android-v0.1.6.apk`: signed public Android APK.
- `trainlog-tui-linux-x86_64-v0.1.6`: Linux x86-64 runtime bundle containing
the TUI, local Web adapter, synchronization helpers, and required catalogs.
- `SHA256SUMS`: SHA-256 checksums for both artifacts.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View file

@ -13,7 +13,7 @@ endobj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>
endobj
5 0 obj
<< /Length 17659 >>
<< /Length 17665 >>
stream
0 G 0 g
0 g BT /F1 14 Tf 30.0 565.0 Td (AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL) Tj ET
@ -53,8 +53,8 @@ stream
0 g BT /F1 4 Tf 728.0 552.0 Td (TRAITEMENT ET) Tj ET
0 g BT /F1 4 Tf 728.0 545.0 Td (REMARQUES) Tj ET
0.7 G 30 498 782 40 re S
0 g BT /F1 5 Tf 33.0 525.0 Td (20/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 515.0 Td (au 21/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 525.0 Td (23/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 515.0 Td (au 24/09/2026) Tj ET
0.9 G 112 498 m 112 538 l S
0.9 G 132.83333333333334 498 m 132.83333333333334 538 l S
0.9 G 153.66666666666666 498 m 153.66666666666666 538 l S
@ -133,8 +133,8 @@ stream
0 g BT /F1 7 Tf 620.0 482.0 Td (B) Tj ET
0 g BT /F1 7 Tf 658.0 482.0 Td (TB) Tj ET
0 g BT /F1 7 Tf 696.0 482.0 Td (Moy) Tj ET
0 g BT /F1 4 Tf 730.0 491.0 Td (22:30 \227 venlafaxine \227 75 mg | Nuit de co) Tj ET
0 g BT /F1 4 Tf 730.0 482.0 Td (ntr\364le g\351om\351trique.) Tj ET
0 g BT /F1 4 Tf 730.0 491.0 Td (22:30 \227 venlafaxine \227 75 mg \3272 | Nuit de) Tj ET
0 g BT /F1 4 Tf 730.0 482.0 Td ( contr\364le g\351om\351trique.) Tj ET
0 g BT /F1 4 Tf 730.0 473.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 464.0 Td () Tj ET
0.7 G 30 418 782 40 re S
@ -383,5 +383,5 @@ xref
trailer
<< /Size 6 /Root 1 0 R >>
startxref
18059
18065
%%EOF

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View file

@ -0,0 +1,103 @@
# Sleep awake selection and PDF V1 evidence
`TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS` on 2026-09-24.
The Web now derives the agenda, selected-night heart-rate overlay, and vector
PDF rows from one pure temporal projection. It retains every structured
`long_awake` interval separately from point-in-time `night_get_up` events,
medication intakes, and sleep ranges. Factual `sleep` intervals determine sleep
duration when present. Otherwise the displayed Couché +45 minutes to Levé -10
minutes range supplies a presentation-only duration after subtracting the union
of overlapping structured awakenings; no estimate is persisted.
One agenda row is the effective selection. A click updates the detail dates,
editor snapshot, and HR context in memory without issuing a write. PDF preview
and download receive the same explicit inclusive range over each entry's
`night_start_date`, and all observations are recomputed from that subset. The
range initially spans the loaded agenda period; later detail/HR selection does
not alter it. Each of the 24 hour labels is centered in the equal visual cell
it names, using the same grid beneath every 18:00-to-18:00 agenda row; there is
no ambiguous duplicate `18` at the right edge.
A real-data follow-up found that using UTC revision `created_at` as the visual
offset source moved `+02:00` events two hours left. The agenda now derives one
row-wide offset from factual bedtime/event data. Regression coverage mixes UTC
metadata with `+02:00` events and keeps the 04:45 final get-up at its local
04:45 position. French night headings render `DD/MM/YYYY`.
## Automated validation
- Web targeted suites: 34/34 passed.
- Complete Web suite after the range/axis follow-up: 209/209 passed.
- `npm run typecheck`: passed.
- `npm run build`: passed.
- Embedded production bundle `meson compile -C build`: passed.
- The tests cover multiple awakening intervals in agenda/HR projection, exact
factual sleep duration, estimated duration with awakening subtraction,
selection dates/HR identity, no write on selection, identical preview/export
subsets, PDF interval geometry, medications, point markers, and no-HR
fallback without an invented BPM curve.
## Read-only real-data smoke
The rebuilt user Web service was exercised through real headless Firefox at
`http://trainlog.perf/analyse?section=sleep` without a Sleep mutation.
- The real 2026-09-23 → 2026-09-24 row contains no factual `sleep` interval;
its displayed estimated band now reports `5 h 14 min 16 s`, not `—`.
- The real 2026-09-22 → 2026-09-23 row renders its structured awakening in
both the agenda and the no-invented-HR detail overlay.
- Selecting that row updates the detail inputs to exactly 2026-09-22 and
2026-09-23.
- Firefox export produced one PDF page whose extracted text contains only
`22/09/2026`, `au 23/09/2026`, and `1 nuit`; it contains no 24/09 row.
- The selected-subset observation is `Sommeil déclaré : 6 h`,
`Longs réveils : 1`, `Siestes : 1`, and `Prises de médicaments : 7`.
- Raster inspection confirms the long-awake band remains visibly distinct
inside the sleep timeline and point/medication markers retain their positions.
The original Web change affected no database schema, synchronization artifact,
frozen format, or Android behavior; the follow-up below deliberately corrects
the Android capture semantics without changing those compatibility boundaries.
## Android capture correction and real-night repair
Follow-up visual review established that Android one-tap Réveil had produced
three punctual `night_get_up` facts for 2026-09-23 → 2026-09-24, so there was
no structured interval for the shared projection to color. The user selected
an explicit 30-minute no-further-action policy. Future taps now create a
`long_awake` interval from the factual tap through +30 minutes; another tap
inside the window extends that same interval, while Levé clamps it to the
factual final-get-up timestamp. This is product policy, not BPM/RR inference.
Before repair, separate desktop and Android backups were retained under
`~/.local/share/trainlog/backups/sleep-wake-window-20260924T0733/`:
- desktop SHA-256: `ac2246dc699a5e11e9d8b52c4cad0f096dfc08191f54b6a86c44ce72f623c39c`;
- Android SHA-256: `a1a9017875757f27264556ea62a9d27a98f2efad17ca0234fb10f4095170a98a`.
The real night was advanced causally, retaining its stable `entry_id`, to:
- `01:35:43.554104 → 02:05:43.554104`;
- `04:11:59.245980 → 04:14:28.358915` (bounded by Levé).
Android and desktop current revision is
`slr_d6cac4c6-dab7-4327-bc06-5cc95f7a2dfa`. Its repaired linear ancestry
retains desktop revision `slr_2bbf573d-4262-49b0-896e-1bcabf3b002a`, including
the existing `TB` sleep and wake qualities.
Android database round-trip hashes matched, integrity/foreign-key checks passed,
and the non-destructive app update preserved first-install identity while
advancing the private build to versionCode 49.
The first post-repair full-generation attempts correctly rejected an already
published Android archive whose embedded ancestry still named the pre-desktop
mobile parent. Both live databases are repaired and aligned, but that stale
transport archive remains rejected until the normal archive-capacity recovery
retires it; no successful synchronization receipt is claimed by this evidence.
Post-correction validation passed 312 Android JVM tests (five optional skips),
`assembleDebug`, 208 Web tests, typecheck, and build. Real Firefox then observed
two orange agenda bands, two orange HR bands, `Réveils 2 · 32 min 29 s`, and two
orange PDF intervals. The selected-only PDF reports `Sommeil déclaré : 4 h 44
min 16 s` after subtracting the repaired awakening intervals from the visual
estimate.

View file

@ -0,0 +1,73 @@
# Sleep revision collision recovery V1 evidence
Date: 2026-09-24
## Collision
```text
COLLIDING_REVISION_ID=slr_d6cac4c6-dab7-4327-bc06-5cc95f7a2dfa
ANDROID_PARENT=slr_e98ad36c-94e2-452c-8068-a7e3d2ea4dd4
DESKTOP_PARENT=slr_2bbf573d-4262-49b0-896e-1bcabf3b002a
ANDROID_PUBLISHED=YES
DESKTOP_PUBLISHED=NO
ANDROID_REFERENCED_BY_IMMUTABLE_GENERATION=YES
DESKTOP_REFERENCED_BY_IMMUTABLE_GENERATION=NO
```
The two records had the same entry identity, night envelope, creation time,
notes, day form, and four event identities (`IDENTICAL`). Their causal parents
were different (`PARENT_CONFLICT`). Desktop contained two non-null quality
values where Android retained nulls (`NON_CONFLICTING_ADDITION`). Desktop also
contained five distinct medication occurrences absent from the older Android
branch (`MISSING_ON_ANDROID` / `NON_CONFLICTING_ADDITION`), with a total
quantity of six. No same occurrence ID had divergent content. Medication names,
unit doses, and notes are deliberately omitted from this repository record.
The root cause was the manual real-night causal repair recorded in
`docs/reviews/sleep_awake_selection_pdf_v1_evidence.md`: two independently
derived tips were assigned the same preselected new revision ID. No production
UUID collision or repository clone function produced the reuse. The faulty
transition was therefore that operational repair, not an Android/Desktop save
function. The production boundaries hardened by this tranche are
`sleep_diary_exchange.apply()` and
`TrainlogRepository.applySleepDiaryV1Json()`; database schemas v36/v33 also
make revision payloads append-only.
## Backups and isolated simulation
Immediate pre-recovery evidence is outside Git at
`/home/fy59/Downloads/trainlog-sleep-collision-recovery-20260924T140000+0200/`.
The Android application archive SHA-256 is
`7d0d95c9401df4baa66503658b8e309e4f6cb7359e5404517460a97afbce668a`,
the extracted Android database SHA-256 is
`6fb99e87a24041202a867e8487dd95ef088f8e189ebb6d6e60956f999bc01ae0`,
and the Desktop database SHA-256 is
`4a44a29a68683ddfaea4c20c121cd6f32309660d6b4141ca716c3fb20d89b225`.
Both databases reported `integrity_check=ok` and an empty foreign-key check.
Before canonical mutation, isolated copies retained both branches, replayed
the immutable generation, produced `consumed` / `sqlite-commit-full`, and
replayed the exact ACK byte-identically. The imported fixture had one session
ending `2026-09-24T11:16:00+02:00`, 7 exercises, 15 sets, 5 feedbacks,
7 exercise intervals, 4,919 BPM samples, 4,919 RR intervals, and one Program
execution. Sleep retained four events, five medication occurrences, and total
quantity six; both copied databases remained integrity/FK clean.
## Canonical recovery and replay
The unpublished Desktop branch was retained exactly under
`slr_42250548-5237-4225-a12d-7e68a0b57076`. The immutable Android branch kept
the collision identity and its original parent. The reconciliation successor
`slr_670c7516-f39e-4196-a861-8f3b657dbee1` descends from that published branch
and contains the non-conflicting union. The target manifest remains SHA-256
`ea3e7ed5622a04f514db069db29cccff1b4702c5cc0e4e253a578ffe8806fc70`;
no generation artifact was rewritten.
Generation `gen_76222040-50d7-4f5d-bcfb-ad9c1e7786f8` was consumed through the
production consumer with `sqlite-commit-full`, and Android accepted its exact
consumed ACK. Two subsequent normal Android generations were also consumed and
acknowledged during the recovery validation. The canonical database and Web
API expose the single original session identity at 11:16, all 7 exercises,
15 sets, 5 feedbacks, 4,919 BPM/RR values, one Program execution, and the
reconciled Sleep tip with four events, five medication occurrences, and total
quantity six.

View file

@ -30,14 +30,14 @@ estimates.
## Current baseline
Stable v0.1.5 is the release baseline for the v0.1.6 development cycle.
Desktop schema v35, Android schema v32, Notcurses, direct `Documents/Trainlog`
Stable v0.1.6 is the release baseline for the next stabilization cycle.
Desktop schema v36, Android schema v33, Notcurses, direct `Documents/Trainlog`
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
Generator V1 is hidden pending V2. AI session-draft exchange retains its
explicit manual validation gate. APP_SHELL_V1 remains useful historical design
evidence, but its September 2026 observations must be revalidated against the
v0.1.5 baseline rather than treated as a current implementation plan.
v0.1.6 baseline rather than treated as a current implementation plan.
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
contract, frozen interactive grid, versioned layout persistence and factual
@ -48,7 +48,8 @@ Sessions → Programmes remains the technical administration/import, list,
detail, archive, and delete surface. Web Exercises V1, the factual
Dashboard/Analyse read-model foundation, Sleep Diary V1/V2, synchronized
factual heart-rate timelines, and the complete Cardio V1
capture/calibration/guidance chain are included in stable v0.1.5.
capture/calibration/guidance chain and the 0.1.6 refinement/recovery work are
included in stable v0.1.6.
The corrective prepared-item projection, bounded phase-owned MTP outbox,
Sessions presentation correction, local date preference and durable preparation
withdrawal are implemented. The withdrawal uses the separate versioned
@ -57,14 +58,25 @@ mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
## Current cursor
Stable v0.1.5 closes Cardio V1 and the Sleep Tonight readiness cycle. Android
Stable v0.1.6 closes the Web refinement, Sleep timeline, synchronization
recovery, and factual session-finalization cycle. Android
alone discovers and reads the heart-rate sensor; desktop/Web consume only
synchronized factual measurements and never connect directly to the sensor.
The heart-rate path remains independent from the frozen Bluetooth Trainlog
synchronization transport.
The stable 0.1.6 Web refinement projects factual Sleep intervals, every
structured awakening, point events, medication intakes, bounds, and duration
through the agenda, selected-night HR detail, and selected-night PDF. Its
explicitly labeled fallback estimate is presentation-only, subtracts overlapping
structured awakenings for displayed duration, and is shared by all three views;
the agenda retains an exact hourly 18:00-to-18:00 geometry. Android lifecycle/ACK regressions now explicitly guard
pre-bed medication snapshots against restart, replay and stale ancestry. The
release closes this implementation cycle without reopening its frozen
contracts.
```text
CURRENT_OPERATIONAL_CURSOR=0.1.6 development open; next feature tranche not selected.
CURRENT_OPERATIONAL_CURSOR=Stable v0.1.6 published; open 0.1.7 stabilization after the immutable release tag.
```
Sleep Diary V1 stays frozen/readable while active capable peers require

View file

@ -953,6 +953,20 @@ active. Archive directories are outside bounded MTP polling and are never
uploaded recursively. Failure or interruption leaves admission closed rather
than fabricating an ACK or discarding evidence.
An unacknowledged generation whose parent also has a distinct, ledger-backed
acknowledged child is a retained superseded branch: it stays available for
audit and is never deleted or assigned a fabricated ACK, but it no longer
consumes active admission because it cannot become the causal tip. After a
transport reset, the next run republishes the newest unsuperseded immutable
generation for that peer before attempting a new capture. The mutable
generation reference uses the new conversation run; generation ID, manifest,
artifact bytes and eventual ACK correlation remain unchanged. Because the
immutable manifest and its durable ACK retain the original run identity, a
cross-run retry recognizes that ACK by the exact generation ID and manifest
digest; the producer's SQLite acknowledgement validator still verifies the
complete peer, result, durability and original-run contract before advancing
state.
Before Android captures for a negotiated `generation-archive-v1` conversation,
the desktop consumer publishes
`desktop-archive-acknowledgements-v1.json`. Its strictly correlated envelope
@ -999,11 +1013,14 @@ idempotently. Causal V1 operation bytes remain unchanged across first emission
and retransmission because generation membership lives only in
`sync_causal_publications`.
The foreground Android coordinator correlates both durable desktop ACKs and
desktop generation references by the active run (and the Android generation
for its ACK). Objects retained from an interrupted conversation are ignored
until atomically replaced by matching objects. After both peer ACKs, Android
reports completion directly; it does not enter the legacy receipt wait state.
The foreground Android coordinator correlates desktop generation references
by the active run. It correlates a durable desktop ACK for an ordinary attempt
by the generation and manifest of that run; for a cross-run resume, those same
two immutable identities deliberately recognize the exact ACK whose embedded
run remains the original capture run. Objects retained from an interrupted
conversation are ignored until atomically replaced by matching objects. After
both peer ACKs, Android reports completion directly; it does not enter the
legacy receipt wait state.
The peer publication precedes one fresh
`trainlog-sync-full-generation-request-v1` signal on
@ -1092,6 +1109,30 @@ advance cardio acknowledgement. Acknowledged captures remain in Android
history but are omitted from normal later publication, preventing a full
night's samples from being retransmitted on every sync.
An acknowledged session capture may later receive one causal
`heart_rate_tail` deletion when recovery establishes an earlier factual
`session.ended_at`. Its target is the exact `capture_id|cutoff`; the predecessor
digest covers the original capture metadata, ordered BPM samples and ordered RR
children. The cutoff is inclusive: only measurements strictly later than it are
deleted, while `capture.ended_at` becomes the cutoff. Because the general
causal-deletions V1 contract is frozen to its seven existing target kinds, the
operation travels in the separate optional
`trainlog-heart-rate-corrections` V1 / `heart-rate-corrections-v1.json`
companion while reusing the durable causal operation/state ledger. No published
or acknowledged generation is rewritten and no ACK is fabricated. The
correction companion is consumed before Heart Rate V1; that importer also
consults durable correction state before comparing or inserting an old
snapshot, so replay cannot resurrect a corrected tail.
Producer lineage selection treats only unarchived acknowledged generations
backed by an exact `consumed` / `sqlite-commit-full` ACK as active tips. This
keeps legacy terminal rows that predate the durable ACK ledger as immutable
audit evidence without letting them block every later publication. A consumer
can retain several historical consumed tips because it cannot observe which
fork the producer archived; the next manifest is valid only when its declared
parent is one of those current tips. A null, old, or unrelated parent remains a
hard rejection, and neither side rewrites fork evidence.
The companion changes neither the frozen Bluetooth Classic RFCOMM transport,
MTP recovery, manifest/ACK framing, Sleep Diary V1 nor mobile-export semantics.
Heart-rate BLE/GATT acquisition remains Android-only and is a separate
@ -1247,3 +1288,22 @@ packaged user-session deployment. Before every invocation the bounded combined
stdout/stderr result is truncated. Resolution, launch, argparse, exception,
signal, and non-zero-exit diagnostics therefore describe only the current run
and are retained in the synchronization error/history when the run fails.
## Sleep revision identity collision recovery
A Sleep `revision_id` names one immutable parent plus canonical payload. The
generation consumer accepts the same identity only when both are equal; it
rejects a different parent or payload even if the incoming revision is an
ancestor of the local current tip. Ordinary Sleep edits always use a new
revision identity.
The bounded recovery tool is intentionally narrower than general conflict
resolution. It may re-identify a divergent local tip only after proving that
the tip has no descendant, validation/ACK state, or immutable generation
reference. It retains that branch unchanged, restores the manifest-owned peer
revision under its published identity, and creates a fresh single-parent
successor containing only non-conflicting scalar additions and exact-ID unions
of events and medication occurrences. Conflicting non-null scalars, notes, or
same occurrence IDs with different payloads require a human decision. The
generation manifest and artifacts are never rewritten; only an exact rejected
consumer/producer ledger may be rearmed for normal replay.

View file

@ -1,5 +1,86 @@
# Tests and validation
## Sleep revision collision recovery
Collision regressions distinguish exact replay from same-ID parent or payload
reuse, validate known historical ancestors before stale-snapshot handling, and
exercise safe re-identification of an unpublished branch. They preserve
quantity one and two, distinct same-time medication occurrences, event and
intake unions, rollback, immutable-generation references, exact ledger rearm,
and idempotent replay. SQLite migration tests prove that Desktop v36 and
Android v33 reject direct update/delete of all revision-owned Sleep payload
tables. Android also creates a 65-revision edit chain and verifies unique UUIDs,
exact parentage, and unchanged historical payloads.
The retained real-data simulation replays the original immutable full
generation against isolated copies and verifies one session ending at 11:16,
7 exercises, 15 sets, 5 feedbacks, 7 exercise intervals, 4,919 BPM samples,
4,919 RR intervals, Program provenance, `consumed` / `sqlite-commit-full`, a
byte-identical ACK replay, and clean integrity/foreign-key checks. A later
normal generation was consumed and acknowledged, proving continued pipeline
use. Sensitive medication names and dose values remain outside repository
evidence.
## Web UI refinement 0.1.6
Web regressions cover calendar-week activity grouping, global rather than
single-exercise Dashboard progression, exercises-per-date overview data,
measured-only group drill-down, normalized measurement selection, the shared
BODY ZONES figure, persistent language/settings navigation, multi-night Sleep
selection invariants, and Equipment response validation. Native equipment
coverage exercises a synthetic custom duplicate and proves that a transactional
merge repoints completed history to the retained canonical identity without
touching real user equipment. Strict C compilation, the full Meson inventory,
Web typecheck/build/Vitest, JSON/import validation, changed-C formatting and
diff checks remain the closeout gates.
Post-review regressions cover a validated Sleep Diary revision plus nocturnal
BPM/RR capture through the production full-generation transport, desktop
import, correlated ACK, exact ACK replay, a second Sleep-only revision and a
second generation. The 24-conversation deployed-capacity regression removes
legacy ACK-ledger rows deliberately, recovers capacity without deleting
generation evidence, and continues through archive/replay. Native Web tests
prove that an active zero-occurrence execution draft is deleted causally and
that repeating its request is byte-identical, while a populated active draft
still conflicts. The equipment client test removes `crypto.randomUUID`, uses
deterministic `getRandomValues` bytes, and completes the protected merge HTTP
request; native merge tests retain their transaction/rollback coverage.
The real Sleep Bluetooth recovery regression starts with no Android session
and an empty pending Sleep revision, records two pre-bed medication intakes
(including `quantity=2`), reopens the repository after each meaningful
lifecycle transition, then records Couché, two BPM/RR samples
separated by Réveil, and Levé. It captures and publishes the production V2
Sleep and V1 heart-rate artifacts, imports them with the desktop consumer,
accepts and replays the ACK without duplication, then admits a second
Sleep-only revision. It also applies the original intake-free ancestor after
ACK and proves that causal reconciliation cannot erase the current intakes.
Separate tests prove connection-arrival auto-request
policy with its cooldown, cross-run reuse of an unacknowledged immutable
generation and recognition of its original-run exact generation/digest ACK,
ledger-backed superseded-branch capacity, fresh-peer non-root Sleep ancestry,
service-installer upgrade behavior, and ACK-recovery progress frames over the
Bluetooth-selected orchestrator path.
UI regressions assert the Sleep agenda's 25 exact hourly positions, 13 visible
two-hour desktop labels and five narrow six-hour anchors, including the exact
0/25/50/75/100% coordinates. They also prove that both Sleep views reuse the
same presentation-only Couché +45 minutes to Levé -10 minutes estimate, that a
factual sleep interval wins, and that a missing bound produces no estimate.
Other UI regressions cover persisted
Dashboard sizing and reset behavior, English preference persistence across a
remount, centralized Dashboard English labels, non-stretched chart aspect
ratios, and distinct missing/partial exercise-timing messages.
The current closeout run passes Web **32 files / 209 tests**, TypeScript
typecheck and the production build; native Meson **113/113**; Android **315
tests** with five optional skips plus `assembleDebug`; JSON/import validators;
changed-C formatting; and diff checks.
The restarted private service serves the rebuilt executable byte-for-byte from
the development checkout and reports healthy Trainlog `0.1.6`. A non-mutating
Firefox smoke on the real 2026-09-23 night confirms the agenda and detail both
show 22:50–04:04 as estimated, with 13 ordinary-desktop labels, 25 wide labels
and five narrow anchors.
Sleep Diary coverage includes schema creation/migration, midnight and DST
offsets, revision conflict, logical deletion/reopen, companion replay and
rollback, Web periods/deep links, Android persistence, and vector PDF
@ -21,6 +102,17 @@ execution and verifies Web `1 / 24` presentation. Related regressions cover
planned continuous activity capture and fresh-peer Sleep Diary bootstrap from
non-root current revisions.
Android session-finalization coverage includes a Program-derived mixed session
whose continuous target is completed from its measured timeline, several prior
occurrences, feedback, an active heart-rate capture, and a planned `2 x 10`
occurrence performed as three valid `10 @ 23 kg` sets. It recreates the legacy
state with a closed continuous timeline but no performed row, finalizes at an
explicit factual timestamp, and proves that plan, actual sets, feedback,
timeline, BPM samples, and RR intervals remain distinct and intact. Desktop
Sleep exchange coverage also verifies that replay of an identical durable but
unpublished revision acknowledges it without `StopIteration`, while different
content under the same revision identity remains an explicit conflict.
`web_analysis` covers empty history, every 7/30/90/all period selector,
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
persisted MAX, multiple primary BODY ZONES, partial measurements, one- and
@ -269,6 +361,18 @@ cover exact 18:00-to-18:00 timestamp geometry, active-night isolation, empty
night state, return navigation and rejection of an earlier date response that
finishes late. Localized dose/unit labels, same-name medications with different
usual doses and editable intake-dose prefill remain covered.
Selection regressions additionally prove that one agenda click updates both
detail dates and HR identity without a write, and that preview and download
receive the same one-night subset with recomputed observations. Projection
unit tests cover exact factual duration, fallback duration, multiple structured
awakenings, and subtraction of only their overlap from an estimated range.
The heart-rate panel suite additionally covers a complete 22:05-to-04:14 night,
declared and fallback-estimated sleep ranges, multiple close awakenings, final
get-up, pre-bed and close medication intakes with exact dose/unit/quantity,
every intervening hour tick, and a no-HR rendering that creates no measured
curve. Geometry tests prove exact factual bounds and the common normalized
projection used by chart samples, overlays, events, intakes, and ticks; narrow
viewports preserve that geometry in a horizontally scrollable chart.
`test_web_sleep_browser.py` exercises the embedded production bundle in real
Firefox against an isolated synthetic database, observes every mutation
without reload, verifies HTTP success and browser error collectors, measures
@ -277,7 +381,9 @@ empty night and back, then reloads and compares the persisted Agenda.
The PDF suite separately asserts complete French and English vocabularies,
accent-preserving WinAnsi text, human-readable duration and plural forms,
localized dates, medication dose snapshots, pagination and the exact ordered
geometry of sleep / 45-minute long-awakening / sleep intervals. The same real
geometry of sleep / 45-minute long-awakening / sleep intervals. PDF rows reuse
the same projected sleep/awake/point/medication primitives as agenda and HR;
subset summaries cannot inherit facts from unselected nights. The same real
Firefox scenario exports a seven-night French PDF from the embedded production
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
with `pdftoppm` for retained visual inspection.
@ -1665,6 +1771,45 @@ generation tool, accepts the correlated ACK, verifies one desktop
capture/sample/RR row, and proves the acknowledged Android capture is no longer
emitted.
`test_heart_rate_tail_recovery` covers the delayed-session correction boundary:
a sample exactly at the factual end remains, the first representable sample
after it and its RR child are removed, statistics use only retained facts, an
already bounded capture is an idempotent no-op, wrong session/capture and
invalid temporal bounds are refused, and an injected post-effect failure rolls
back every row. It then replays the old Heart Rate V1 snapshot and the same
causal operation twice to prove non-resurrection and second-sync idempotence.
It also proves that `heart_rate_tail` appears only in the separately versioned
heart-rate-correction artifact and remains absent from frozen
causal-deletions V1.
`TrainlogRepositoryDraftTest.delayedFactualFinalizationTruncatesHeartRateSamplesAndRrInclusively`
exercises the production Android delayed-finalization transaction.
The 2026-09-24 paired-device closeout applies operation
`hrx_a2222757-9a85-492f-823b-ad590208ce6b` to capture
`hrc_fe0627e0-dff6-4629-82d9-03da78c49634`. Android and desktop both report
3,429 BPM samples, 3,429 RR values, no sample after 11:16, last sample
11:15:59.759323, and 89 / 119.30300379119277 / 138 BPM. Full MTP runs
`sy_d1a29dd9-7b51-41db-83e1-5712758b3700` and
`sy_4b8b3c28-17b1-4300-bfbf-9332385f69ab` both completed through
`peer_consumed`. A production import of the retained pre-correction 4,919-point
artifact left the same counts and statistics. The live Web history detail in
Firefox rendered the 10:18--11:16 curve, seven exercises, 15 sets, 3,429 RR,
and the same min/average/max values.
Generation regressions additionally cover an archived acknowledged fork, a
legacy acknowledged row without durable ACK evidence, and consumer acceptance
of a producer-selected current fork tip while rejecting stale/null ancestry.
Android generation tests exercise the matching rules. Sleep quick-capture and
generation tests cover the schema-v33 medication-intake column order with
quantity 2, preventing note/creation-time values from being bound to the
`quantity` constraint.
The final post-device closeout passes the complete native Meson inventory
**114/114**, the complete clang ASan/UBSan Meson inventory **114/114**, and
Android `testDebugUnitTest` with **317 tests: 312 passed, five skipped, zero
failures/errors**. Android `assembleDebug`, both JSON/import validators,
changed-file C formatting, and `git diff --check` also pass.
The 2026-09-23 closeout passes the complete native Meson inventory:
**102/102 passed, 0 failed**. The complete Android JVM inventory reports
**258 tests: 253 passed, five skipped, zero failures/errors**. The five skips
@ -1750,21 +1895,34 @@ also succeeds. The five skips remain optional historical/external fixture gates.
`TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`
reuses the existing Sleep Diary V1 revision model and schema. The quick-capture
API creates/advances normal immutable Sleep Diary revisions for Couché,
medication intake, point Réveil and Levé actions. Undo is accepted only when
medication intake, 30-minute structured Réveil and Levé actions. Undo is accepted only when
the action's exact applied revision is still current; older receipts conflict,
and successful undo appends a compensating revision instead of rewriting the
history.
Targeted coverage executes Couché, a configured medication with dose snapshot,
two independent wake points, live sleep-owned BPM/RR capture, Levé and final
two independent awakening intervals, live sleep-owned BPM/RR capture, Levé and final
heart-rate export against the same stable sleep `entry_id`. A separate test
proves stale undo rejection and exact restoration of the latest action.
proves stale undo rejection and exact restoration of the latest action. A
dedicated regression proves that a second tap inside 30 minutes extends one
interval and that final get-up clamps it without leaving overlapping windows.
The complete Android JVM inventory before the final localization-only cleanup
reported **274 tests: 269 passed, five skipped, zero failures/errors** and
`assembleDebug` succeeded. After the localization cleanup the targeted
SleepQuickCapture test and `assembleDebug` passed again.
The 30-minute awakening-policy follow-up passes the current complete inventory:
**312 tests: 307 passed, five skipped, zero failures/errors**, plus
`assembleDebug`. Real-data Firefox coverage additionally asserts two orange
agenda/HR bands and two orange vector-PDF intervals for the repaired
2026-09-23 night.
The subsequent agenda/report-range regression covers exact 18:00-to-18:00
hour geometry, a visible 24-column row grid, inclusive start/end filtering,
identical preview/export snapshots, range summary recomputation, and the
independence of the selected HR/editor night from the PDF range.
The private Android installation was advanced non-destructively to Trainlog
0.1.5 versionCode 41 with the same debug signing identity and no uninstall or
data clear. The device locked before the final visual Sleep-screen inspection,

View file

@ -126,7 +126,9 @@ class GenerationArchiveTest(unittest.TestCase):
stage, manifest, _ = generation.capture_desktop(self.database, self.owned, PEER)
self.assertTrue(stage.is_dir())
self.assertEqual(parent, manifest["parent_generation_id"])
# Historical status alone is terminal evidence, not a durable
# consumed ACK and therefore not an active lineage parent.
self.assertIsNone(manifest["parent_generation_id"])
with sqlite3.connect(self.database) as database:
self.assertEqual(
0,

View file

@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Causal recovery coverage for delayed session-end heart-rate tails."""
import json
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import heart_rate_exchange
import causal_delete_exchange
import heart_rate_correction_exchange
import recover_heart_rate_tail as recovery
SESSION = "se_22222222-2222-4222-8222-222222222222"
OTHER_SESSION = "se_99999999-9999-4999-8999-999999999999"
CAPTURE = "hrc_11111111-1111-4111-8111-111111111111"
OTHER_CAPTURE = "hrc_88888888-8888-4888-8888-888888888888"
CUTOFF = "2026-09-24T11:16:00+02:00"
def create_schema(db: sqlite3.Connection) -> None:
db.executescript(
"""
PRAGMA foreign_keys=ON;
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,
started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,session_row_id INTEGER);
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER);
CREATE TABLE continuous_activity(session_exercise_row_id INTEGER PRIMARY KEY);
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY);
CREATE TABLE session_exercise_timeline(session_id TEXT,entry_id TEXT,
exercise_id TEXT,started_at TEXT,ended_at TEXT,imported_at TEXT);
CREATE TABLE heart_rate_captures(capture_id TEXT PRIMARY KEY,context_kind TEXT,
context_id TEXT,started_at TEXT,ended_at TEXT,sensor_name TEXT,imported_at TEXT);
CREATE TABLE heart_rate_samples(capture_id TEXT,sequence INTEGER,observed_at TEXT,
bpm INTEGER,exercise_entry_id TEXT,sensor_contact_detected INTEGER,
energy_expended INTEGER,PRIMARY KEY(capture_id,sequence),
FOREIGN KEY(capture_id) REFERENCES heart_rate_captures(capture_id) ON DELETE CASCADE);
CREATE TABLE heart_rate_rr_intervals(capture_id TEXT,sample_sequence INTEGER,
rr_index INTEGER,value_1024 INTEGER,
PRIMARY KEY(capture_id,sample_sequence,rr_index),
FOREIGN KEY(capture_id,sample_sequence)
REFERENCES heart_rate_samples(capture_id,sequence) ON DELETE CASCADE);
CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT,
target_id TEXT,creator_id TEXT,predecessor_revision_id TEXT,created_at TEXT,
payload_sha256 TEXT,publication_context TEXT);
CREATE TABLE sync_causal_state(target_kind TEXT,target_id TEXT,
current_revision_id TEXT,deleted INTEGER,operation_id TEXT,
PRIMARY KEY(target_kind,target_id));
PRAGMA user_version=36;
"""
)
def seed(db: sqlite3.Connection, ended_at: str = CUTOFF,
include_tail: bool = True) -> dict:
db.execute(
"INSERT INTO sessions VALUES(1,?,?,?,?,?)",
(SESSION, "2026-09-24T10:00:00+02:00", ended_at, "training", "keep"),
)
db.execute("INSERT INTO session_exercises VALUES(1,1)")
db.execute("INSERT INTO performed_sets VALUES(1,1)")
db.execute(
"INSERT INTO session_exercise_timeline VALUES(?,?,?,?,?,?)",
(SESSION, "sxe_33333333-3333-4333-8333-333333333333",
"ex_44444444-4444-4444-8444-444444444444",
"2026-09-24T10:15:00+02:00", "2026-09-24T11:15:00+02:00", CUTOFF),
)
capture_end = "2026-09-24T11:16:00.000001+02:00" if include_tail else CUTOFF
db.execute(
"INSERT INTO heart_rate_captures VALUES(?,?,?,?,?,?,?)",
(CAPTURE, "session", SESSION, "2026-09-24T10:00:00+02:00", capture_end,
"Synthetic", CUTOFF),
)
samples = [
(CAPTURE, 0, "2026-09-24T11:15:59.999999+02:00", 100, None, 1, 1),
(CAPTURE, 1, CUTOFF, 101, None, 1, 2),
]
if include_tail:
samples.append((CAPTURE, 2, "2026-09-24T11:16:00.000001+02:00",
40, None, 0, 3))
db.executemany("INSERT INTO heart_rate_samples VALUES(?,?,?,?,?,?,?)", samples)
db.executemany(
"INSERT INTO heart_rate_rr_intervals VALUES(?,?,0,?)",
[(CAPTURE, row[1], 900 + row[1]) for row in samples],
)
db.execute(
"INSERT INTO heart_rate_captures VALUES(?,?,?,?,?,?,?)",
(OTHER_CAPTURE, "session", OTHER_SESSION, "2026-09-24T09:00:00+02:00",
"2026-09-24T09:01:00+02:00", "Other", CUTOFF),
)
db.execute(
"INSERT INTO heart_rate_samples VALUES(?,?,?,?,?,?,?)",
(OTHER_CAPTURE, 0, "2026-09-24T09:00:30+02:00", 77, None, 1, None),
)
db.commit()
return {
"format": "trainlog-heart-rate",
"version": 1,
"generated_at": CUTOFF,
"captures": [{
"capture_id": CAPTURE,
"context_kind": "session",
"context_id": SESSION,
"started_at": "2026-09-24T10:00:00+02:00",
"ended_at": capture_end,
"sensor_name": "Synthetic",
"samples": [{
"sequence": row[1], "observed_at": row[2], "bpm": row[3],
"exercise_entry_id": None, "sensor_contact_detected": bool(row[5]),
"energy_expended": row[6], "rr_intervals_1024": [900 + row[1]],
} for row in samples],
}],
}
class HeartRateTailRecoveryTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.path = Path(self.temp.name) / "trainlog.sqlite"
self.db = sqlite3.connect(self.path)
create_schema(self.db)
def tearDown(self):
self.db.close()
self.temp.cleanup()
def apply(self, operation=None, fail=False):
return recovery.recover(
self.db, SESSION, CAPTURE, CUTOFF, "peer_test", True,
operation=operation, fail_after_effect=fail,
)
def test_exact_cutoff_rr_stats_and_old_generation_replay(self):
old_generation = seed(self.db)
report, operation = self.apply()
self.assertEqual("applied", report["outcome"])
self.assertEqual(1, report["before"]["bpm_remove"])
self.assertEqual(1, report["before"]["rr_remove"])
self.assertEqual(0, report["after"]["bpm_remove"])
self.assertEqual(100.5, report["after"]["current_stats"]["average"])
self.assertEqual(
[(0, "2026-09-24T11:15:59.999999+02:00"), (1, CUTOFF)],
self.db.execute(
"SELECT sequence,observed_at FROM heart_rate_samples "
"WHERE capture_id=? ORDER BY sequence", (CAPTURE,),
).fetchall(),
)
self.assertEqual(2, self.db.execute(
"SELECT COUNT(*) FROM heart_rate_rr_intervals WHERE capture_id=?", (CAPTURE,),
).fetchone()[0])
correction_document = heart_rate_correction_exchange.export_document(self.db)
self.assertEqual([operation], correction_document["corrections"])
self.assertEqual([], causal_delete_exchange.export_document(self.db)["operations"])
self.assertEqual((0, 1), heart_rate_exchange.apply(
self.db, heart_rate_exchange.validate(old_generation),
))
second, _ = self.apply(operation)
self.assertEqual("unchanged", second["outcome"])
self.assertEqual(2, second["after"]["bpm_total"])
def test_dry_run_and_injected_failure_roll_back_every_table(self):
seed(self.db)
before = list(self.db.iterdump())
report, _ = recovery.recover(
self.db, SESSION, CAPTURE, CUTOFF, "peer_test", False,
)
self.assertEqual("dry-run", report["mode"])
self.assertEqual(before, list(self.db.iterdump()))
with self.assertRaisesRegex(recovery.RecoveryError, "injected failure"):
self.apply(fail=True)
self.assertEqual(before, list(self.db.iterdump()))
def test_wrong_id_and_invalid_temporal_bounds_are_rejected(self):
seed(self.db)
with self.assertRaisesRegex(recovery.RecoveryError, "session not found"):
recovery.recover(self.db, OTHER_SESSION, CAPTURE, CUTOFF, "peer", False)
with self.assertRaisesRegex(recovery.RecoveryError, "capture"):
recovery.recover(
self.db, SESSION,
"hrc_77777777-7777-4777-8777-777777777777", CUTOFF, "peer", False,
)
self.db.execute(
"UPDATE sessions SET ended_at='2026-09-24T09:59:59+02:00' WHERE session_id=?",
(SESSION,),
)
self.db.commit()
with self.assertRaisesRegex(Exception, "outside capture"):
recovery.recover(
self.db, SESSION, CAPTURE, "2026-09-24T09:59:59+02:00", "peer", False,
)
self.db.execute("UPDATE sessions SET ended_at=? WHERE session_id=?", (CUTOFF, SESSION))
self.db.execute(
"UPDATE session_exercise_timeline SET ended_at='2026-09-24T11:16:01+02:00'",
)
self.db.commit()
with self.assertRaisesRegex(Exception, "precedes the last exercise"):
self.apply()
def test_already_bounded_capture_is_noop(self):
seed(self.db, include_tail=False)
report, operation = self.apply()
self.assertEqual("unchanged", report["outcome"])
self.assertIsNone(operation)
self.assertEqual(0, self.db.execute(
"SELECT COUNT(*) FROM sync_causal_operations",
).fetchone()[0])
if __name__ == "__main__":
unittest.main()

View file

@ -58,6 +58,15 @@ class SleepExchangeTest(unittest.TestCase):
exported = exchange.build(self.db)
self.assertTrue(exported["entries"][0]["deleted"])
def test_known_revision_with_different_parent_is_an_identity_collision(self):
revision = "slr_20000000-0000-4000-8000-000000000002"
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(revision))))
conflicting = self.document(
entry(revision, "slr_30000000-0000-4000-8000-000000000003")
)
with self.assertRaisesRegex(ValueError, "identity reused"):
exchange.apply(self.db, conflicting)
def test_v1_hydrates_quantity_one_and_v2_preserves_quantity(self):
first = "slr_20000000-0000-4000-8000-000000000002"
legacy = self.document(entry(first))
@ -186,6 +195,47 @@ class SleepExchangeTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "concurrent"):
exchange.apply(self.db, sibling)
def test_current_snapshot_seeds_fresh_peer_from_non_root_revisions(self):
parent = "slr_20000000-0000-4000-8000-000000000002"
tip = "slr_40000000-0000-4000-8000-000000000004"
document = self.document(entry(tip, parent))
document["version"] = 2
document["entries"][0]["intakes"][0]["quantity"] = 2
document["entries"][0]["ancestry"] = [tip, parent]
medication = document["medications"][0]
medication_parent = medication["revision_id"]
medication_tip = "medr_a0000000-0000-4000-8000-00000000000a"
medication["revision_id"] = medication_tip
medication["parent_revision_id"] = medication_parent
medication["ancestry"] = [medication_tip, medication_parent]
medication["updated_at"] = "2026-10-25T18:05:00+01:00"
rejected = self.document(entry(
"slr_50000000-0000-4000-8000-000000000005",
"slr_60000000-0000-4000-8000-000000000006",
))
rejected["version"] = 2
rejected["entries"][0]["intakes"][0]["quantity"] = 2
with self.assertRaisesRegex(ValueError, "unknown parent"):
exchange.apply(self.db, rejected)
self.db.rollback()
self.assertEqual((1, 0), exchange.apply(self.db, document))
self.assertEqual(
(tip, parent),
self.db.execute(
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions"
).fetchone(),
)
self.assertEqual(
(medication_tip, medication_parent),
self.db.execute(
"SELECT revision_id,parent_revision_id FROM sleep_medication_revisions"
).fetchone(),
)
def test_rejects_concurrent_sibling_and_negative_absolute_interval(self):
first = "slr_20000000-0000-4000-8000-000000000002"
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first))))
@ -210,6 +260,35 @@ class SleepExchangeTest(unittest.TestCase):
(item["entry_id"], revision, item["updated_at"]))
self.assertEqual(revision, exchange.build(self.db)["entries"][0]["revision_id"])
def test_identical_remote_revision_validates_unpublished_local_draft(self):
revision = "slr_20000000-0000-4000-8000-000000000002"
document = self.document(entry(revision))
self.assertEqual((1, 0), exchange.apply(self.db, document))
self.db.execute(
"DELETE FROM sleep_diary_publication_state WHERE entry_id=?",
(document["entries"][0]["entry_id"],),
)
self.assertEqual([], exchange.build(self.db)["entries"])
self.assertEqual((0, 1), exchange.apply(self.db, document))
self.assertEqual(
revision,
self.db.execute(
"SELECT acknowledged_revision_id FROM sleep_diary_publication_state "
"WHERE entry_id=?",
(document["entries"][0]["entry_id"],),
).fetchone()[0],
)
self.db.execute(
"DELETE FROM sleep_diary_publication_state WHERE entry_id=?",
(document["entries"][0]["entry_id"],),
)
conflicting = json.loads(json.dumps(document))
conflicting["entries"][0]["treatment_and_notes"] = "Different content"
with self.assertRaisesRegex(ValueError, "identity reused"):
exchange.apply(self.db, conflicting)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""Transactional Sleep revision collision recovery regressions."""
import copy
import sqlite3
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import recover_sleep_revision_collision as recovery
import sleep_diary_exchange as exchange
ENTRY = "sl_10000000-0000-4000-8000-000000000001"
REMOTE_PARENT = "slr_20000000-0000-4000-8000-000000000002"
LOCAL_PARENT = "slr_30000000-0000-4000-8000-000000000003"
COLLISION = "slr_40000000-0000-4000-8000-000000000004"
LOCAL_REIDENTIFIED = "slr_50000000-0000-4000-8000-000000000005"
RECONCILIATION = "slr_60000000-0000-4000-8000-000000000006"
RECOVERED_AT = "2026-10-25T18:30:00+01:00"
GENERATION = "gen_a0000000-0000-4000-8000-00000000000a"
RUN = "sy_b0000000-0000-4000-8000-00000000000b"
PRODUCER = "peer_c0000000-0000-4000-8000-00000000000c"
CONSUMER = "peer_d0000000-0000-4000-8000-00000000000d"
MANIFEST_DIGEST = "a" * 64
def incoming_entry() -> dict:
return {
"entry_id": ENTRY,
"night_start_date": "2026-10-24",
"night_end_date": "2026-10-25",
"created_at": "2026-10-24T22:00:00+02:00",
"updated_at": "2026-10-25T07:00:00+01:00",
"revision_id": COLLISION,
"parent_revision_id": REMOTE_PARENT,
"ancestry": [COLLISION, REMOTE_PARENT],
"deleted": False,
"sleep_quality": None,
"wake_quality": None,
"day_form": None,
"treatment_and_notes": "",
"events": [{
"event_id": "sle_70000000-0000-4000-8000-000000000007",
"type": "sleep",
"start_at": "2026-10-24T23:00:00+02:00",
"end_at": "2026-10-25T06:00:00+01:00",
}],
"intakes": [],
}
def document() -> dict:
return {
"format": exchange.FORMAT,
"version": 2,
"generated_at": "2026-10-25T18:00:00+01:00",
"entries": [incoming_entry()],
"medications": [],
}
def manifest() -> dict:
return {
"generation_id": GENERATION,
"run_id": RUN,
"producer": {"peer_id": PRODUCER, "kind": "android"},
"consumer_peer_id": CONSUMER,
"generated_at": "2026-10-25T18:00:00+01:00",
"parent_generation_id": None,
}
class SleepRevisionCollisionRecoveryTest(unittest.TestCase):
def setUp(self) -> None:
self.db = sqlite3.connect(":memory:")
self.db.execute("PRAGMA foreign_keys=ON")
self.db.executescript("""
CREATE TABLE sleep_diary_entries(entry_id TEXT PRIMARY KEY,night_start_date TEXT,night_end_date TEXT,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER);
CREATE TABLE sleep_diary_revisions(revision_id TEXT PRIMARY KEY,entry_id TEXT REFERENCES sleep_diary_entries(entry_id),parent_revision_id TEXT,created_at TEXT,sleep_quality TEXT,wake_quality TEXT,day_form TEXT,treatment_and_notes TEXT);
CREATE TABLE sleep_diary_events(revision_id TEXT REFERENCES sleep_diary_revisions(revision_id),event_id TEXT,event_type TEXT,start_at TEXT,end_at TEXT,PRIMARY KEY(revision_id,event_id));
CREATE TABLE sleep_medication_intakes(revision_id TEXT REFERENCES sleep_diary_revisions(revision_id),intake_id TEXT,medication_id TEXT,medication_name TEXT,taken_at TEXT,dose_value REAL,dose_unit TEXT,quantity INTEGER,note TEXT,created_at TEXT,PRIMARY KEY(revision_id,intake_id));
CREATE TABLE sleep_medications(medication_id TEXT PRIMARY KEY,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER);
CREATE TABLE sleep_medication_revisions(revision_id TEXT PRIMARY KEY,medication_id TEXT,parent_revision_id TEXT,created_at TEXT,name TEXT,default_dose_value REAL,default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER);
CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT);
CREATE TABLE sleep_diary_generation_entries(generation_id TEXT,entry_id TEXT,revision_id TEXT,PRIMARY KEY(generation_id,entry_id));
CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,consumed_at TEXT,result TEXT,durability TEXT,diagnostic TEXT,ack_json TEXT);
CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT);
CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,producer_kind TEXT,generated_at TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,status TEXT,manifest_json TEXT,staging_path TEXT,acknowledged_at TEXT);
""")
item = incoming_entry()
self.db.execute(
"INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,0)",
(ENTRY, item["night_start_date"], item["night_end_date"], item["created_at"],
item["updated_at"], COLLISION),
)
# The fixture intentionally reproduces two parents and one reused id.
for revision_id, parent in ((REMOTE_PARENT, None), (LOCAL_PARENT, REMOTE_PARENT)):
self.db.execute(
"INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
(revision_id, ENTRY, parent, item["updated_at"], None, None, None, ""),
)
self.db.execute(
"INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
(COLLISION, ENTRY, LOCAL_PARENT, item["updated_at"], "TB", "B", None, ""),
)
self.db.execute(
"INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
(COLLISION, item["events"][0]["event_id"], "sleep",
item["events"][0]["start_at"], item["events"][0]["end_at"]),
)
for suffix, quantity in (("8", 1), ("9", 2)):
self.db.execute(
"INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?,?)",
(COLLISION, f"mdi_80000000-0000-4000-8000-00000000000{suffix}",
"med_90000000-0000-4000-8000-000000000009", "Fixture medication",
"2026-10-24T22:30:00+02:00", 10.0, "mg", quantity, "",
"2026-10-24T22:30:00+02:00"),
)
self.db.execute(
"INSERT INTO sleep_diary_publication_state VALUES(?,?,?,?,?)",
(ENTRY, REMOTE_PARENT, item["updated_at"], REMOTE_PARENT, item["updated_at"]),
)
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def ids(self):
values = iter((LOCAL_REIDENTIFIED, RECONCILIATION))
return lambda: next(values)
def state(self):
return {
"entry": self.db.execute("SELECT * FROM sleep_diary_entries").fetchall(),
"revisions": self.db.execute(
"SELECT * FROM sleep_diary_revisions ORDER BY revision_id"
).fetchall(),
"events": self.db.execute(
"SELECT * FROM sleep_diary_events ORDER BY revision_id,event_id"
).fetchall(),
"intakes": self.db.execute(
"SELECT * FROM sleep_medication_intakes ORDER BY revision_id,intake_id"
).fetchall(),
}
def test_unpublished_branch_is_preserved_and_replay_is_idempotent(self):
report = recovery.recover(
self.db, document(), COLLISION, RECOVERED_AT, self.ids()
)
self.assertEqual(LOCAL_REIDENTIFIED, report["local_reidentified_revision_id"])
self.assertEqual(RECONCILIATION, report["reconciliation_revision_id"])
self.assertEqual((2, 1), (report["intake_count"], report["event_count"]))
self.assertEqual(
RECONCILIATION,
self.db.execute("SELECT current_revision_id FROM sleep_diary_entries").fetchone()[0],
)
self.assertEqual(
(REMOTE_PARENT, None, None),
self.db.execute(
"SELECT parent_revision_id,sleep_quality,wake_quality FROM sleep_diary_revisions "
"WHERE revision_id=?", (COLLISION,),
).fetchone(),
)
self.assertEqual(
(COLLISION, "TB", "B"),
self.db.execute(
"SELECT parent_revision_id,sleep_quality,wake_quality FROM sleep_diary_revisions "
"WHERE revision_id=?", (RECONCILIATION,),
).fetchone(),
)
self.assertEqual(
[(1,), (2,)],
self.db.execute(
"SELECT quantity FROM sleep_medication_intakes WHERE revision_id=? "
"ORDER BY intake_id", (RECONCILIATION,),
).fetchall(),
)
self.assertEqual((0, 1), exchange.apply(self.db, document()))
self.assertEqual([], self.db.execute("PRAGMA foreign_key_check").fetchall())
def test_historical_identity_mismatch_is_rejected_after_recovery(self):
recovery.recover(self.db, document(), COLLISION, RECOVERED_AT, self.ids())
conflicting = copy.deepcopy(document())
conflicting["entries"][0]["sleep_quality"] = "M"
with self.assertRaisesRegex(ValueError, "identity reused"):
exchange.apply(self.db, conflicting)
def test_failure_rolls_back_every_reidentification_write(self):
before = self.state()
with self.assertRaisesRegex(recovery.RecoveryError, "injected"):
recovery.recover(
self.db, document(), COLLISION, RECOVERED_AT, self.ids(),
fail_after_reidentification=True,
)
self.assertEqual(before, self.state())
def test_published_local_branch_is_never_reidentified(self):
self.db.execute(
"UPDATE sleep_diary_publication_state SET validated_revision_id=? WHERE entry_id=?",
(COLLISION, ENTRY),
)
self.db.commit()
with self.assertRaisesRegex(recovery.RecoveryError, "publication state"):
recovery.recover(self.db, document(), COLLISION, RECOVERED_AT, self.ids())
def test_two_non_null_scalar_values_require_human_decision(self):
conflicting = document()
conflicting["entries"][0]["sleep_quality"] = "M"
with self.assertRaisesRegex(recovery.RecoveryError, "human decision"):
recovery.recover(self.db, conflicting, COLLISION, RECOVERED_AT, self.ids())
def test_same_intake_identity_with_different_quantity_is_rejected(self):
conflicting = document()
conflicting["entries"][0]["intakes"] = [{
"intake_id": "mdi_80000000-0000-4000-8000-000000000008",
"medication_id": "med_90000000-0000-4000-8000-000000000009",
"medication_name": "Fixture medication",
"taken_at": "2026-10-24T22:30:00+02:00",
"dose_value": 10.0,
"dose_unit": "mg",
"quantity": 2,
"note": "",
"created_at": "2026-10-24T22:30:00+02:00",
}]
with self.assertRaisesRegex(recovery.RecoveryError, "identity reused"):
recovery.recover(self.db, conflicting, COLLISION, RECOVERED_AT, self.ids())
def insert_rejection_ack(self) -> None:
ack_id = "ack_" + GENERATION.removeprefix("gen_")
self.db.execute(
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(ack_id, GENERATION, RUN, PRODUCER, CONSUMER, MANIFEST_DIGEST, "rejected",
"sqlite-commit-rejection", RECOVERED_AT, "", "b" * 64),
)
def test_desktop_rejection_is_rearmed_with_recovery_transaction(self):
self.db.execute(
"INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(GENERATION, RUN, PRODUCER, CONSUMER, None, MANIFEST_DIGEST, RECOVERED_AT,
"rejected", "sqlite-commit-rejection", "", "{}"),
)
self.insert_rejection_ack()
self.db.commit()
report = recovery.recover(
self.db, document(), COLLISION, RECOVERED_AT, self.ids(),
rejected_generation=(manifest(), MANIFEST_DIGEST),
)
self.assertTrue(report["desktop_rejection_rearmed"])
self.assertEqual(0, self.db.execute(
"SELECT COUNT(*) FROM sync_consumed_generations WHERE generation_id=?", (GENERATION,)
).fetchone()[0])
self.assertEqual(0, self.db.execute(
"SELECT COUNT(*) FROM sync_acknowledgements WHERE generation_id=?", (GENERATION,)
).fetchone()[0])
def test_post_recovery_schema_rejection_can_be_rearmed_exactly(self):
recovery.recover(self.db, document(), COLLISION, RECOVERED_AT, self.ids())
self.db.execute(
"INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(GENERATION, RUN, PRODUCER, CONSUMER, None, MANIFEST_DIGEST, RECOVERED_AT,
"rejected", "sqlite-commit-rejection", "schema desktop v8 à v35 requis", "{}"),
)
self.insert_rejection_ack()
self.db.commit()
report = recovery.rearm_desktop_consumer(
self.db, document(), manifest(), MANIFEST_DIGEST, COLLISION
)
self.assertEqual("ready_for_retry", report["status"])
self.assertFalse(report["immutable_artifacts_rewritten"])
self.assertEqual(0, self.db.execute(
"SELECT COUNT(*) FROM sync_consumed_generations WHERE generation_id=?", (GENERATION,)
).fetchone()[0])
self.assertEqual(0, self.db.execute(
"SELECT COUNT(*) FROM sync_acknowledgements WHERE generation_id=?", (GENERATION,)
).fetchone()[0])
def test_android_rearm_preserves_manifest_and_sleep_mapping(self):
self.db.execute(
"INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(GENERATION, RUN, PRODUCER, CONSUMER, "android", manifest()["generated_at"],
None, MANIFEST_DIGEST, "rejected", "{}", "/immutable", RECOVERED_AT),
)
self.db.execute(
"INSERT INTO sleep_diary_generation_entries VALUES(?,?,?)",
(GENERATION, ENTRY, COLLISION),
)
self.insert_rejection_ack()
self.db.commit()
report = recovery.rearm_android_producer(
self.db, manifest(), MANIFEST_DIGEST, COLLISION
)
self.assertFalse(report["immutable_artifacts_rewritten"])
self.assertEqual(
("waiting_acknowledgement", None, "{}", "/immutable"),
self.db.execute(
"SELECT status,acknowledged_at,manifest_json,staging_path FROM sync_generations "
"WHERE generation_id=?", (GENERATION,),
).fetchone(),
)
self.assertEqual(0, self.db.execute(
"SELECT COUNT(*) FROM sync_acknowledgements WHERE generation_id=?", (GENERATION,)
).fetchone()[0])
if __name__ == "__main__":
unittest.main()

View file

@ -13,6 +13,17 @@ import sync_generation_exchange as generation
class DeploymentToolsTest(unittest.TestCase):
def test_user_service_installer_retires_only_known_legacy_exec_override(self):
installer = (ROOT / "tools/install_syncd_user.sh").read_text()
self.assertIn(
"grep -Fxq 'ExecStart=%h/.local/bin/trainlog-syncd --interval 3'",
installer,
)
self.assertIn('rm -f -- "$LEGACY_ROLLOUT"', installer)
self.assertIn('--sync-once "$SYNC_ONCE"', installer)
self.assertIn('--bt-adapter "$BT_ADAPTER"', installer)
self.assertIn('--mtp-adapter "$MTP_ADAPTER"', installer)
def test_sqlite_backup_captures_committed_wal_and_restores_invariants(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
@ -66,7 +77,7 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.6")
self.assertEqual(inventory["desktop_schema"], 35)
self.assertEqual(inventory["desktop_schema"], 36)
self.assertEqual(inventory["android_schema"], 32)
self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"])
@ -76,6 +87,7 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertIn("cardio-calibrations-v1", inventory["protocols"])
self.assertIn("cardio-guidance-v1", inventory["protocols"])
self.assertIn("heart-rate-v1", inventory["protocols"])
self.assertIn("heart-rate-corrections-v1", inventory["protocols"])
self.assertIn("session-timeline-v1", inventory["protocols"])
self.assertIn("bluetooth-files-v1", inventory["protocols"])
self.assertIn("generation-ack-recovery-v1", inventory["protocols"])

View file

@ -71,6 +71,105 @@ class GenerationTest(unittest.TestCase):
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])
def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
with closing(sqlite3.connect(self.database)) as db:
db.execute(
"INSERT OR REPLACE INTO sync_peer_identity VALUES(1,?,'desktop')",
(PEER_A,),
)
for identity, generated in (
(active, "2026-09-17T09:00:00+02:00"),
(archived, "2026-09-17T09:01:00+02:00"),
):
db.execute(
"INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(
identity, RUN, PEER_A, PEER_B, "desktop", generated, None,
"a" * 64, "acknowledged", "{}", str(self.root / identity),
generated,
),
)
db.execute(
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(
"ack_" + identity[4:], identity, RUN, PEER_A, PEER_B,
"a" * 64, "consumed", "sqlite-commit-full", generated,
"", "b" * 64,
),
)
db.execute(
"INSERT INTO sync_generation_archives VALUES(?,?,?,?,?,?)",
(archived, str(self.root / "archive"), "a" * 64, "b" * 64,
"2026-09-17T09:02:00+02:00", "{}"),
)
db.commit()
_, manifest, _ = self.capture()
self.assertEqual(active, manifest["parent_generation_id"])
def test_acknowledged_row_without_durable_ack_is_not_an_active_tip(self):
legacy = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
current = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
with closing(sqlite3.connect(self.database)) as db:
db.execute(
"INSERT OR REPLACE INTO sync_peer_identity VALUES(1,?,'desktop')",
(PEER_A,),
)
for identity, generated in (
(legacy, "2026-09-17T09:00:00+02:00"),
(current, "2026-09-17T09:01:00+02:00"),
):
db.execute(
"INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(
identity, RUN, PEER_A, PEER_B, "desktop", generated,
None, "a" * 64, "acknowledged", "{}",
str(self.root / identity), generated,
),
)
db.execute(
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(
"ack_" + current[4:], current, RUN, PEER_A, PEER_B,
"a" * 64, "consumed", "sqlite-commit-full",
"2026-09-17T09:01:00+02:00", "", "b" * 64,
),
)
db.commit()
_, manifest, _ = self.capture()
self.assertEqual(current, manifest["parent_generation_id"])
def test_consumer_accepts_producer_selected_current_fork_tip(self):
first = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
second = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
stale = "gen_cccccccc-cccc-4ccc-8ccc-cccccccccccc"
with closing(sqlite3.connect(self.database)) as db:
for identity, parent in (
(stale, None),
(first, stale),
(second, stale),
):
db.execute(
"INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(
identity, RUN, PEER_B, PEER_A, parent, "a" * 64,
"2026-09-17T09:00:00+02:00", "consumed",
"sqlite-commit-full", "", "{}",
),
)
generation.require_current_consumed_parent(db, PEER_B, first)
generation.require_current_consumed_parent(db, PEER_B, second)
with self.assertRaisesRegex(
generation.GenerationError, "stale or unrelated"
):
generation.require_current_consumed_parent(db, PEER_B, stale)
with self.assertRaisesRegex(
generation.GenerationError, "stale or unrelated"
):
generation.require_current_consumed_parent(db, PEER_B, None)
generation.require_current_consumed_parent(db, PEER_A, None)
def test_sleep_revision_becomes_acknowledged_only_after_correlated_ack(self):
stage, manifest, checksum = self.capture()
self.publish()

View file

@ -156,7 +156,10 @@ class OrchestratorTest(unittest.TestCase):
"assert sys.argv[sys.argv.index('--mode')+1]=='bt'\n"
"assert '--bt-adapter' in sys.argv\n"
"v=" + repr(report) +
"\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\nprint(json.dumps(v))\n"
"\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\n"
"p={'format':'trainlog-sync-progress','version':1,'run_id':v['run_id'],"
"'phase':'android_archive_ack_observed','recovered_acknowledgements':1}\n"
"print(json.dumps(p))\nprint(json.dumps(v))\n"
)
bt_marker = root / "bt-probe"
bt_adapter = sandbox / "trainlog_generation_bt_adapter.py"
@ -175,7 +178,7 @@ class OrchestratorTest(unittest.TestCase):
bluetooth_device_address="AA:BB:CC:DD:EE:FF",
)
result = self.invoke(sandbox / "sync_orchestrator.py", values)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.returncode, 0, result.stderr or values[1].read_text())
self.assertTrue(bt_marker.is_file())
self.assertFalse(mtp_marker.exists())
final = json.loads(values[1].read_text())

View file

@ -368,6 +368,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
"taken_at": "2026-09-19T22:30:00+02:00",
"dose_value": 75,
"dose_unit": "mg",
"quantity": 2,
"note": "",
"created_at": "2026-09-19T22:30:00+02:00",
}
@ -381,6 +382,36 @@ class BrowserSleepDiaryTest(unittest.TestCase):
By.CSS_SELECTOR, "[data-testid='sleep-agenda-event-bed_time']"
)
)
wait.until(
lambda current: current.find_elements(
By.CSS_SELECTOR, "[data-testid='heart-rate-sleep-factual']"
)
)
self.assertEqual(
1,
len(driver.find_elements(
By.CSS_SELECTOR, "[data-testid='heart-rate-medication-marker']"
)),
)
self.assertEqual(
1,
len(driver.find_elements(
By.CSS_SELECTOR, "[data-testid='heart-rate-sleep-event-final_get_up']"
)),
)
self.assertGreaterEqual(
len(driver.find_elements(
By.CSS_SELECTOR, "[data-testid='heart-rate-hour-tick']"
)),
6,
)
self.assertIn(
"venlafaxine · 75 mg ×2",
driver.find_element(By.CSS_SELECTOR, ".heart-rate-overlay-list").text,
)
self.assertFalse(
driver.find_elements(By.CSS_SELECTOR, ".heart-rate-line")
)
geometry = driver.execute_script(
"""
const axis=document.querySelector('.sleep-hour-axis').getBoundingClientRect();
@ -395,11 +426,13 @@ class BrowserSleepDiaryTest(unittest.TestCase):
};
const sleep=box('sleep-agenda-event-sleep');
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
.filter(value => getComputedStyle(value).display !== 'none')
.map(value => {
const tick=value.getBoundingClientRect();
return {
label:value.textContent,
center:tick.left + tick.width / 2,
left:value.style.left,
};
});
return {
@ -428,23 +461,27 @@ class BrowserSleepDiaryTest(unittest.TestCase):
self.assertAlmostEqual(
expected_x, geometry[key], delta=1.0, msg=key
)
for tick_index in (4, 9, 10, 11, 21, 22):
expected_x = geometry["left"] + geometry["width"] * tick_index / 24
self.assertAlmostEqual(
expected_x,
geometry["ticks"][tick_index]["center"],
delta=1.0,
)
agenda_tick_hours = tuple(range(0, 25, 2))
self.assertEqual(
[f"{(18 + hour) % 24:02d}" for hour in agenda_tick_hours],
[tick["label"] for tick in geometry["ticks"]],
)
self.assertEqual(
[f"{tick_hour / 24 * 100:g}%" for tick_hour in agenda_tick_hours],
[tick["left"] for tick in geometry["ticks"]],
)
self.assertTrue(
driver.save_screenshot(
str(EVIDENCE / "sleep-diary-alignment-firefox.png")
)
)
set_value("sleep-night-start", "2026-09-21")
set_value("sleep-night-start", "2025-01-01")
wait.until(
lambda current: "Aucune donnée pour cette nuit."
in current.page_source
lambda current: current.find_element(
By.CSS_SELECTOR, "[data-testid='sleep-night-start']"
).get_attribute("value")
== "2025-01-01"
)
self.assertFalse(
driver.find_elements(
@ -492,6 +529,14 @@ class BrowserSleepDiaryTest(unittest.TestCase):
response = json.loads(bidi.recv())
self.assertEqual("success", response["type"])
wait.until(lambda current: current.execute_script("return innerWidth") == 390)
narrow_ticks = driver.execute_script(
"""
return [...document.querySelectorAll('.sleep-hour-axis > span')]
.filter(value => getComputedStyle(value).display !== 'none')
.map(value => value.textContent);
"""
)
self.assertEqual(["18", "00", "06", "12", "18"], narrow_ticks)
self.assertLessEqual(
driver.execute_script("return document.documentElement.scrollWidth"),
driver.execute_script("return document.documentElement.clientWidth"),
@ -599,7 +644,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']"
)
)
== 1
== 7
)
EVIDENCE.mkdir(parents=True, exist_ok=True)
download_path = EVIDENCE / "trainlog-sleep-diary.pdf"

View file

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

View file

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

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
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):
raise CausalError(f"desktop schema v20-v35 required, found v{version}")
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-v36 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
@ -243,7 +243,12 @@ def load(path: Path) -> dict:
def export_document(db: sqlite3.Connection) -> dict:
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
rows = db.execute("SELECT " + ",".join(keys) + " FROM sync_causal_operations ORDER BY operation_id LIMIT ?", (MAX_OPERATIONS + 1,)).fetchall()
rows = db.execute(
"SELECT " + ",".join(keys) + " FROM sync_causal_operations "
"WHERE target_kind IN('session','execution_draft','exercise','body_observation',"
"'custom_equipment','feedback','body_zone_relation') "
"ORDER BY operation_id LIMIT ?", (MAX_OPERATIONS + 1,),
).fetchall()
if len(rows) > MAX_OPERATIONS: raise CausalError("protection set exceeds artifact bound")
document = {"format": FORMAT, "version": VERSION,
"generated_at": dt.datetime.now().astimezone().isoformat(),

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection):
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):
raise LifecycleError(f"desktop schema v19-v35 required, found v{version}")
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-v36 required, found v{version}")
def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
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)
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 = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_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."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v35 requis")
raise ValueError("schema desktop v15-v36 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
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)
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:
raise ValueError("schema desktop v18-v35 requis")
raise ValueError("schema desktop v18-v36 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

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

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(8, 36))
supported_versions = tuple(range(8, 37))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v35 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():
raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute(

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database)
try:
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):
raise ValueError("schema desktop v12-v35 requis")
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-v36 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"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.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 36))
supported_versions = tuple(range(11, 37))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v35 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():
raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = []

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
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):
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}")
rows = con.execute("""
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
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 36):
if version not in range(8, 37):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
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):
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")
supported_versions = range(11, 36)
supported_versions = range(11, 37)
legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v35 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(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
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:
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):
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")
programs = []
for row in db.execute(

View file

@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35)
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:
raise ValueError("desktop schema v24, v25, v26 through v32 required")
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()
db=connect_database(args.database)
try:
supported_versions = range(15, 36)
supported_versions = range(15, 37)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v35 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")
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()

View file

@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Versioned causal corrections for acknowledged Heart Rate V1 captures."""
from __future__ import annotations
import datetime as dt
import hashlib
import json
import re
import sqlite3
import uuid
from pathlib import Path
from validate_json import parse_timestamp
FORMAT = "trainlog-heart-rate-corrections"
VERSION = 1
TARGET_KIND = "heart_rate_tail"
MAX_BYTES = 4 * 1024 * 1024
MAX_CORRECTIONS = 256
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
CAPTURE_ID = re.compile(rf"^hrc_{UUID4}$")
OPERATION_ID = re.compile(rf"^hrx_{UUID4}$")
HEX = re.compile(r"^[0-9a-f]{64}$")
class CorrectionError(ValueError):
"""A correction is malformed, conflicts, or targets incompatible facts."""
def canonical(value: object) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def digest(value: object) -> str:
return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
def exact_timestamp(value: str):
try:
return parse_timestamp(value, "heart_rate_timestamp")
except (TypeError, ValueError) as error:
raise CorrectionError("invalid heart-rate timestamp") from error
def target_id(capture_id: str, cutoff: str) -> str:
if not isinstance(capture_id, str) or not CAPTURE_ID.fullmatch(capture_id):
raise CorrectionError("invalid heart-rate capture identity")
exact_timestamp(cutoff)
return f"{capture_id}|{cutoff}"
def split_target(value: str) -> tuple[str, str]:
parts = value.split("|", 1)
if len(parts) != 2:
raise CorrectionError("invalid heart-rate correction target")
target_id(parts[0], parts[1])
return parts[0], parts[1]
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in range(27, 37):
raise CorrectionError(f"heart-rate correction requires schema v27-v36, found v{version}")
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
return db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,),
).fetchone() is not None
def capture_snapshot(db: sqlite3.Connection, capture_id: str):
row = db.execute(
"SELECT capture_id,context_kind,context_id,started_at,ended_at,sensor_name "
"FROM heart_rate_captures WHERE capture_id=?", (capture_id,),
).fetchone()
if row is None:
return None
samples = []
for sample in db.execute(
"SELECT sequence,observed_at,bpm,exercise_entry_id,sensor_contact_detected,"
"energy_expended FROM heart_rate_samples WHERE capture_id=? ORDER BY sequence",
(capture_id,),
):
rr = [list(value) for value in db.execute(
"SELECT rr_index,value_1024 FROM heart_rate_rr_intervals "
"WHERE capture_id=? AND sample_sequence=? ORDER BY rr_index",
(capture_id, sample[0]),
)]
samples.append([*sample, rr])
return [*row, samples]
def live_revision(db: sqlite3.Connection, capture_id: str) -> str | None:
snapshot = capture_snapshot(db, capture_id)
return None if snapshot is None else "lv_" + digest(snapshot)
def authorize(db: sqlite3.Connection, capture_id: str, cutoff: str) -> None:
row = db.execute(
"SELECT context_kind,context_id,started_at,ended_at FROM heart_rate_captures "
"WHERE capture_id=?", (capture_id,),
).fetchone()
if row is None:
raise CorrectionError("heart-rate capture not found")
if row[0] not in ("session", "cardio"):
raise CorrectionError("only session heart-rate captures can be corrected")
if row[3] is None:
raise CorrectionError("active heart-rate capture cannot be corrected")
cutoff_key = exact_timestamp(cutoff)
if cutoff_key < exact_timestamp(row[2]) or cutoff_key > exact_timestamp(row[3]):
raise CorrectionError("heart-rate cutoff is outside capture")
session = db.execute(
"SELECT ended_at FROM sessions WHERE session_id=?", (row[1],),
).fetchone()
if session is None or session[0] != cutoff:
raise CorrectionError("heart-rate cutoff must equal the factual session end")
timeline = ("session_timeline_exercises"
if _table_exists(db, "session_timeline_exercises")
else "session_exercise_timeline")
latest = db.execute(
f"SELECT MAX(ended_at) FROM {timeline} WHERE session_id=?", (row[1],),
).fetchone()[0]
if latest is not None and exact_timestamp(latest) > cutoff_key:
raise CorrectionError("heart-rate cutoff precedes the last exercise marker")
previous = None
for expected, (sequence, observed_at) in enumerate(db.execute(
"SELECT sequence,observed_at FROM heart_rate_samples "
"WHERE capture_id=? ORDER BY sequence", (capture_id,),
)):
observed = exact_timestamp(observed_at)
if sequence != expected or previous is not None and observed < previous:
raise CorrectionError("heart-rate samples are not one chronological sequence")
previous = observed
def correction_payload(correction: dict) -> dict:
return {key: correction[key] for key in (
"operation_id", "capture_id", "cutoff", "creator_id",
"predecessor_revision_id", "created_at",
)}
def validate_correction(value: object) -> dict:
keys = {"operation_id", "capture_id", "cutoff", "creator_id",
"predecessor_revision_id", "created_at", "payload_sha256"}
if not isinstance(value, dict) or set(value) != keys:
raise CorrectionError("invalid heart-rate correction shape")
if not isinstance(value["operation_id"], str) or not OPERATION_ID.fullmatch(value["operation_id"]):
raise CorrectionError("invalid heart-rate correction identity")
target_id(value["capture_id"], value["cutoff"])
for field in ("creator_id", "predecessor_revision_id"):
if not isinstance(value[field], str) or not 0 < len(value[field].encode()) <= 128:
raise CorrectionError(f"invalid {field}")
exact_timestamp(value["created_at"])
if not isinstance(value["payload_sha256"], str) or not HEX.fullmatch(value["payload_sha256"]):
raise CorrectionError("invalid heart-rate correction digest")
if digest(correction_payload(value)) != value["payload_sha256"]:
raise CorrectionError("heart-rate correction digest mismatch")
return value
def apply_effect(db: sqlite3.Connection, capture_id: str, cutoff: str) -> None:
# WHY: a stopped capture may cross an immutable generation before recovery
# establishes the factual session end.
# CONTRACT: the cutoff is inclusive; only its strict BPM/RR tail is removed.
# INVARIANT: acknowledged generation bytes and retained identities never change.
cutoff_key = exact_timestamp(cutoff)
removed = [sequence for sequence, observed_at in db.execute(
"SELECT sequence,observed_at FROM heart_rate_samples "
"WHERE capture_id=? ORDER BY sequence", (capture_id,),
) if exact_timestamp(observed_at) > cutoff_key]
db.executemany(
"DELETE FROM heart_rate_samples WHERE capture_id=? AND sequence=?",
[(capture_id, sequence) for sequence in removed],
)
if db.execute(
"UPDATE heart_rate_captures SET ended_at=? WHERE capture_id=?",
(cutoff, capture_id),
).rowcount != 1:
raise CorrectionError("heart-rate capture disappeared during correction")
def apply_correction(db: sqlite3.Connection, value: dict) -> str:
correction = validate_correction(value)
operation_id = correction["operation_id"]
target = target_id(correction["capture_id"], correction["cutoff"])
known = db.execute(
"SELECT target_kind,target_id,creator_id,predecessor_revision_id,created_at,"
"payload_sha256,publication_context FROM sync_causal_operations "
"WHERE operation_id=?", (operation_id,),
).fetchone()
expected = (TARGET_KIND, target, correction["creator_id"],
correction["predecessor_revision_id"], correction["created_at"],
correction["payload_sha256"], None)
if known is not None:
if tuple(known) != expected:
raise CorrectionError("heart-rate correction identity reused")
return "unchanged"
authorize(db, correction["capture_id"], correction["cutoff"])
state = db.execute(
"SELECT current_revision_id,deleted FROM sync_causal_state "
"WHERE target_kind=? AND target_id=?", (TARGET_KIND, target),
).fetchone()
current = state[0] if state else live_revision(db, correction["capture_id"])
if state and state[1]:
raise CorrectionError("concurrent heart-rate correction conflict")
if current is None or current != correction["predecessor_revision_id"]:
raise CorrectionError("heart-rate correction predecessor conflict")
count = db.execute(
"SELECT COUNT(*) FROM sync_causal_operations WHERE target_kind=?", (TARGET_KIND,),
).fetchone()[0]
if count >= MAX_CORRECTIONS:
raise CorrectionError("heart-rate correction bound exceeded")
db.execute(
"INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,NULL)",
(operation_id, TARGET_KIND, target, correction["creator_id"], current,
correction["created_at"], correction["payload_sha256"]),
)
apply_effect(db, correction["capture_id"], correction["cutoff"])
db.execute(
"INSERT OR REPLACE INTO sync_causal_state VALUES(?,?,?,?,?)",
(TARGET_KIND, target, operation_id, 1, operation_id),
)
if len(canonical(export_document(db)).encode("utf-8")) > MAX_BYTES:
raise CorrectionError("heart-rate correction artifact exceeds byte bound")
return "applied"
def local_correction(db: sqlite3.Connection, capture_id: str, cutoff: str,
creator_id: str) -> tuple[dict, str]:
target = target_id(capture_id, cutoff)
state = db.execute(
"SELECT current_revision_id,deleted FROM sync_causal_state "
"WHERE target_kind=? AND target_id=?", (TARGET_KIND, target),
).fetchone()
if state and state[1]:
row = db.execute(
"SELECT operation_id,creator_id,predecessor_revision_id,created_at,payload_sha256 "
"FROM sync_causal_operations WHERE operation_id=?", (state[0],),
).fetchone()
correction = {
"operation_id": row[0], "capture_id": capture_id, "cutoff": cutoff,
"creator_id": row[1], "predecessor_revision_id": row[2],
"created_at": row[3], "payload_sha256": row[4],
}
return correction, "unchanged"
authorize(db, capture_id, cutoff)
predecessor = state[0] if state else live_revision(db, capture_id)
if predecessor is None:
raise CorrectionError("heart-rate capture not found")
correction = {
"operation_id": "hrx_" + str(uuid.uuid4()),
"capture_id": capture_id,
"cutoff": cutoff,
"creator_id": creator_id,
"predecessor_revision_id": predecessor,
"created_at": dt.datetime.now().astimezone().isoformat(),
}
correction["payload_sha256"] = digest(correction_payload(correction))
return correction, apply_correction(db, correction)
def export_document(db: sqlite3.Connection) -> dict:
corrections = []
for row in db.execute(
"SELECT operation_id,target_id,creator_id,predecessor_revision_id,created_at,"
"payload_sha256 FROM sync_causal_operations WHERE target_kind=? "
"ORDER BY operation_id LIMIT ?", (TARGET_KIND, MAX_CORRECTIONS + 1),
):
capture_id, cutoff = split_target(row[1])
corrections.append({
"operation_id": row[0], "capture_id": capture_id, "cutoff": cutoff,
"creator_id": row[2], "predecessor_revision_id": row[3],
"created_at": row[4], "payload_sha256": row[5],
})
if len(corrections) > MAX_CORRECTIONS:
raise CorrectionError("heart-rate correction bound exceeded")
result = {
"format": FORMAT,
"version": VERSION,
"generated_at": dt.datetime.now().astimezone().isoformat(),
"corrections": corrections,
}
if len(canonical(result).encode("utf-8")) > MAX_BYTES:
raise CorrectionError("heart-rate correction artifact exceeds byte bound")
return result
def validate_document(value: object) -> dict:
if not isinstance(value, dict) or set(value) != {
"format", "version", "generated_at", "corrections"}:
raise CorrectionError("invalid heart-rate correction envelope")
if value["format"] != FORMAT or type(value["version"]) is not int or value["version"] != VERSION:
raise CorrectionError("unsupported heart-rate correction artifact")
exact_timestamp(value["generated_at"])
if not isinstance(value["corrections"], list) or len(value["corrections"]) > MAX_CORRECTIONS:
raise CorrectionError("heart-rate correction bound exceeded")
identities = set()
for correction in value["corrections"]:
validate_correction(correction)
if correction["operation_id"] in identities:
raise CorrectionError("duplicate heart-rate correction identity")
identities.add(correction["operation_id"])
return value
def load(path: Path) -> dict:
raw = path.read_bytes()
if len(raw) > MAX_BYTES:
raise CorrectionError("heart-rate correction artifact exceeds byte bound")
try:
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_object)
except (UnicodeError, json.JSONDecodeError) as error:
raise CorrectionError("invalid heart-rate correction JSON") from error
return validate_document(value)
def _unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise CorrectionError("duplicate heart-rate correction JSON field")
result[key] = value
return result
def apply_document(db: sqlite3.Connection, value: dict) -> tuple[int, int]:
validate_document(value)
applied = 0
unchanged = 0
for correction in value["corrections"]:
outcome = apply_correction(db, correction)
applied += outcome == "applied"
unchanged += outcome == "unchanged"
return applied, unchanged

View file

@ -9,6 +9,7 @@ from datetime import datetime
from pathlib import Path
from trainlog_sqlite import connect_database
from validate_json import parse_timestamp as exact_timestamp
FORMAT = "trainlog-heart-rate"
@ -134,6 +135,45 @@ def load(path: Path) -> dict:
fail("heart-rate artifact exceeds 64 MiB")
return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object))
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
return db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,),
).fetchone() is not None
def _corrected_capture(db: sqlite3.Connection, capture: dict) -> dict:
if not _table_exists(db, "sync_causal_state"):
return capture
prefix = capture["capture_id"] + "|"
cutoffs = []
for target_id, in db.execute(
"SELECT target_id FROM sync_causal_state "
"WHERE target_kind='heart_rate_tail' AND deleted=1 AND target_id LIKE ?",
(prefix + "%",),
):
if not target_id.startswith(prefix):
continue
raw = target_id[len(prefix):]
timestamp(raw)
value, parsed = raw, exact_timestamp(raw, "heart_rate_cutoff")
cutoffs.append((parsed, value))
if not cutoffs:
return capture
cutoff_key, cutoff = min(cutoffs)
started = exact_timestamp(capture["started_at"], "started_at")
ended = exact_timestamp(capture["ended_at"], "ended_at")
if cutoff_key < started or cutoff_key > ended:
fail("causal heart-rate cutoff is outside capture")
samples = [sample for sample in capture["samples"]
if exact_timestamp(sample["observed_at"], "observed_at") <= cutoff_key]
if any(sample["sequence"] != expected for expected, sample in enumerate(samples)):
fail("causal heart-rate truncation would break sample sequence")
corrected = dict(capture)
corrected["ended_at"] = cutoff
corrected["samples"] = samples
return corrected
def _same_capture(db: sqlite3.Connection, capture: dict) -> bool:
row = db.execute(
"SELECT context_kind,context_id,started_at,ended_at,sensor_name "
@ -183,7 +223,13 @@ def _same_capture(db: sqlite3.Connection, capture: dict) -> bool:
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
applied = 0
unchanged = 0
for capture in root["captures"]:
for raw_capture in root["captures"]:
# WHY: acknowledged Heart Rate V1 generations are immutable, while a
# later causal correction may remove a sensor tail from history.
# CONTRACT: replay compares/inserts the old snapshot only after every
# durable inclusive cutoff for this capture has been applied.
# INVARIANT: a corrected tail cannot be resurrected by old bytes.
capture = _corrected_capture(db, raw_capture)
if _same_capture(db, capture):
unchanged += 1
continue
@ -236,8 +282,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35):
fail("desktop schema v30-v35 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v30-v36 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
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;
any failure leaves no draft, child, or import-identity row behind.
"""
supported_versions = range(18, 36)
supported_versions = range(18, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v35 requis")
raise ImportFailure("schema desktop v18-v36 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
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"))
connection = connect_database(args.database)
try:
supported_versions = range(8, 36)
supported_versions = range(8, 37)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v35 requis")
fail("schema desktop v8 à v36 requis")
proof_path = args.mobile_export
if proof_path is None:
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):
"""Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 36)
supported_versions = range(8, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v35 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():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0

View file

@ -56,9 +56,9 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 36)
supported_versions = range(12, 37)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v35 requis")
fail("schema desktop v12-v36 requis")
for source, canonical in aliases:
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}")

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):
"""Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 36)
supported_versions = range(11, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v35 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")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# 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")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
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):
fail("schema desktop v17-v35 requis")
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
fail("schema desktop v17-v36 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
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
# readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 36):
if version not in range(5, 37):
raise ImportFailure(
f"base desktop schema v5 à v35 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)
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):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v35")
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 à v36")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"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):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 36)
supported_versions = range(15, 37)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v35 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")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -24,6 +24,18 @@ done
mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user"
# WHY: private pre-orchestrator rollouts installed this exact drop-in to call
# syncd without its now-required bounded runtime paths. Leaving it in place
# silently overrides the canonical unit below and creates an endless restart
# loop. CONTRACT: remove only the known obsolete Trainlog-owned override;
# preserve every unrecognized administrator customization. INVARIANT: the
# effective ExecStart always owns sync-once plus both transport adapters.
LEGACY_ROLLOUT="$HOME/.config/systemd/user/trainlog-syncd.service.d/rollout.conf"
if [[ -f "$LEGACY_ROLLOUT" ]] &&
grep -Fxq 'ExecStart=%h/.local/bin/trainlog-syncd --interval 3' "$LEGACY_ROLLOUT"; then
rm -f -- "$LEGACY_ROLLOUT"
fi
ln -sfn "$SYNC_ONCE" "$HOME/.local/bin/trainlog-sync-once"
ln -sfn "$DAEMON" "$HOME/.local/bin/trainlog-syncd"
ln -sfn "$BT_AGENT" "$HOME/.local/bin/trainlog-btd"

View file

@ -19,9 +19,10 @@ RUNTIME_TOOLS = [
"sync_generation_exchange.py",
"sleep_diary_exchange.py",
"cardio_session_exchange.py",
"cardio_calibration_exchange.py",
"cardio_guidance_exchange.py",
"heart_rate_exchange.py",
"cardio_calibration_exchange.py",
"cardio_guidance_exchange.py",
"heart_rate_exchange.py",
"heart_rate_correction_exchange.py",
"session_timeline_exchange.py",
"trainlog_sqlite.py",
"validate_json.py",
@ -119,7 +120,7 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.6",
"desktop_schema": 35,
"desktop_schema": 36,
"android_schema": 32,
"protocols": [
"mobile-export-v3",
@ -140,6 +141,7 @@ def main() -> int:
"cardio-calibrations-v1",
"cardio-guidance-v1",
"heart-rate-v1",
"heart-rate-corrections-v1",
"session-timeline-v1",
],
"entry_points": [

View file

@ -87,7 +87,7 @@ def load(path: Path) -> dict:
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):
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")
added = 0
advanced = 0

View file

@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Transactionally truncate one acknowledged session heart-rate capture."""
from __future__ import annotations
import argparse
import hashlib
import json
import sqlite3
from pathlib import Path
import heart_rate_correction_exchange as correction_exchange
from trainlog_sqlite import connect_database
class RecoveryError(ValueError):
"""The requested recovery does not match the persisted factual session."""
def _digest(value: object) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":"), default=str).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def _rows(db: sqlite3.Connection, query: str, parameters: tuple = ()) -> list[list]:
return [list(row) for row in db.execute(query, parameters)]
def protected_snapshot(db: sqlite3.Connection, session_id: str,
capture_id: str) -> dict:
session = db.execute(
"SELECT id FROM sessions WHERE session_id=?", (session_id,),
).fetchone()
if session is None:
raise RecoveryError("session not found")
session_row = session[0]
occurrence_rows = [row[0] for row in db.execute(
"SELECT id FROM session_exercises WHERE session_row_id=? ORDER BY id",
(session_row,),
)]
occurrence_marks = ",".join("?" for _ in occurrence_rows) or "NULL"
protected = {
"session": _rows(db, "SELECT * FROM sessions WHERE id=?", (session_row,)),
"occurrences": _rows(
db, "SELECT * FROM session_exercises WHERE session_row_id=? ORDER BY id",
(session_row,),
),
"sets": _rows(
db, f"SELECT * FROM performed_sets WHERE session_exercise_row_id IN "
f"({occurrence_marks}) ORDER BY id", tuple(occurrence_rows),
),
"continuous": _rows(
db, f"SELECT * FROM continuous_activity WHERE session_exercise_row_id IN "
f"({occurrence_marks}) ORDER BY session_exercise_row_id", tuple(occurrence_rows),
),
"maxima": _rows(
db, f"SELECT * FROM max_results WHERE session_exercise_row_id IN "
f"({occurrence_marks}) ORDER BY session_exercise_row_id", tuple(occurrence_rows),
),
"other_captures": _rows(
db, "SELECT capture_id,context_kind,context_id,started_at,ended_at,sensor_name "
"FROM heart_rate_captures WHERE capture_id<>? ORDER BY capture_id", (capture_id,),
),
"other_sample_counts": _rows(
db, "SELECT capture_id,COUNT(*),MIN(observed_at),MAX(observed_at) "
"FROM heart_rate_samples WHERE capture_id<>? GROUP BY capture_id "
"ORDER BY capture_id", (capture_id,),
),
"other_samples": _rows(
db, "SELECT * FROM heart_rate_samples WHERE capture_id<>? "
"ORDER BY capture_id,sequence", (capture_id,),
),
"other_rr": _rows(
db, "SELECT * FROM heart_rate_rr_intervals WHERE capture_id<>? "
"ORDER BY capture_id,sample_sequence,rr_index", (capture_id,),
),
}
if db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='exercise_feedback'",
).fetchone():
feedback_ids = [row[0] for row in db.execute(
f"SELECT feedback_id FROM exercise_feedback WHERE session_exercise_row_id IN "
f"({occurrence_marks}) ORDER BY feedback_id", tuple(occurrence_rows),
)]
feedback_marks = ",".join("?" for _ in feedback_ids) or "NULL"
protected["feedback"] = _rows(
db, f"SELECT * FROM exercise_feedback WHERE feedback_id IN ({feedback_marks}) "
"ORDER BY feedback_id", tuple(feedback_ids),
)
protected["feedback_revisions"] = _rows(
db, f"SELECT * FROM exercise_feedback_revisions WHERE feedback_id IN "
f"({feedback_marks}) ORDER BY feedback_id,revision_id", tuple(feedback_ids),
)
if db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='session_followups'",
).fetchone():
followup_ids = [row[0] for row in db.execute(
"SELECT followup_id FROM session_followups WHERE session_row_id=? "
"ORDER BY followup_id", (session_row,),
)]
followup_marks = ",".join("?" for _ in followup_ids) or "NULL"
protected["session_followups"] = _rows(
db, f"SELECT * FROM session_followups WHERE followup_id IN "
f"({followup_marks}) ORDER BY followup_id", tuple(followup_ids),
)
protected["session_followup_revisions"] = _rows(
db, f"SELECT * FROM session_followup_revisions WHERE followup_id IN "
f"({followup_marks}) ORDER BY followup_id,revision_id", tuple(followup_ids),
)
if db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' "
"AND name='session_timeline_sessions'",
).fetchone():
protected["timeline_session"] = _rows(
db, "SELECT * FROM session_timeline_sessions WHERE session_id=?", (session_id,),
)
protected["timeline_exercises"] = _rows(
db, "SELECT * FROM session_timeline_exercises WHERE session_id=? "
"ORDER BY entry_id", (session_id,),
)
else:
protected["timeline_exercises"] = _rows(
db, "SELECT * FROM session_exercise_timeline WHERE session_id=? "
"ORDER BY entry_id", (session_id,),
)
for table in ("sleep_diary_entries", "sleep_diary_revisions", "sleep_diary_events",
"sleep_medications", "sleep_medication_intakes"):
if db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,),
).fetchone():
protected[table] = _rows(db, f"SELECT * FROM {table} ORDER BY rowid")
return protected
def capture_report(db: sqlite3.Connection, capture_id: str, cutoff: str) -> dict:
cutoff_key = correction_exchange.exact_timestamp(cutoff)
capture = db.execute(
"SELECT context_kind,context_id,started_at,ended_at,sensor_name "
"FROM heart_rate_captures WHERE capture_id=?", (capture_id,),
).fetchone()
if capture is None:
raise RecoveryError("capture not found")
samples = db.execute(
"SELECT sequence,observed_at,bpm,exercise_entry_id FROM heart_rate_samples "
"WHERE capture_id=? ORDER BY sequence", (capture_id,),
).fetchall()
keep = [row for row in samples
if correction_exchange.exact_timestamp(row[1]) <= cutoff_key]
remove = [row for row in samples
if correction_exchange.exact_timestamp(row[1]) > cutoff_key]
rr = db.execute(
"SELECT s.observed_at FROM heart_rate_rr_intervals r "
"JOIN heart_rate_samples s ON s.capture_id=r.capture_id "
"AND s.sequence=r.sample_sequence WHERE r.capture_id=?",
(capture_id,),
).fetchall()
rr_keep = sum(correction_exchange.exact_timestamp(row[0]) <= cutoff_key for row in rr)
values = [row[2] for row in samples]
kept_values = [row[2] for row in keep]
def stats(items: list[int]) -> dict:
return {
"minimum": min(items) if items else None,
"average": sum(items) / len(items) if items else None,
"maximum": max(items) if items else None,
}
return {
"capture_id": capture_id,
"context_kind": capture[0],
"context_id": capture[1],
"started_at": capture[2],
"ended_at": capture[3],
"sensor_name": capture[4],
"bpm_total": len(samples),
"bpm_keep": len(keep),
"bpm_remove": len(remove),
"rr_total": len(rr),
"rr_keep": rr_keep,
"rr_remove": len(rr) - rr_keep,
"first_removed_at": remove[0][1] if remove else None,
"last_removed_at": remove[-1][1] if remove else None,
"removed_exercise_entry_ids": sorted({row[3] for row in remove},
key=lambda value: value or ""),
"current_stats": stats(values),
"kept_stats": stats(kept_values),
}
def load_operation(path: Path) -> dict:
value = json.loads(path.read_text(encoding="utf-8"))
if isinstance(value, dict) and set(value) == {"format", "version", "generated_at",
"corrections"}:
correction_exchange.load(path)
if len(value["corrections"]) != 1:
raise RecoveryError("correction artifact must contain exactly one operation")
return value["corrections"][0]
return correction_exchange.validate_correction(value)
def recover(db: sqlite3.Connection, session_id: str, capture_id: str, cutoff: str,
creator_id: str, apply: bool, operation: dict | None = None,
fail_after_effect: bool = False) -> tuple[dict, dict | None]:
"""Apply or simulate one exact correction without touching sibling facts.
CONTRACT: an emitted operation is the immutable content of the separate
heart-rate-corrections V1 companion. INVARIANT: every mutation commits
together or rolls back.
"""
db.execute("PRAGMA foreign_keys=ON")
correction_exchange.require_schema(db)
db.execute("BEGIN IMMEDIATE")
emitted = operation
try:
session = db.execute(
"SELECT started_at,ended_at FROM sessions WHERE session_id=?", (session_id,),
).fetchone()
if session is None:
raise RecoveryError("session not found")
if session[1] != cutoff:
raise RecoveryError("cutoff does not equal the factual session end")
capture = db.execute(
"SELECT context_id FROM heart_rate_captures WHERE capture_id=?", (capture_id,),
).fetchone()
if capture is None or capture[0] != session_id:
raise RecoveryError("capture does not belong to the requested session")
before = capture_report(db, capture_id, cutoff)
protected_before = protected_snapshot(db, session_id, capture_id)
if operation is not None:
if operation.get("capture_id") != capture_id or operation.get("cutoff") != cutoff:
raise RecoveryError("operation does not match requested capture and cutoff")
outcome = correction_exchange.apply_correction(db, operation)
elif before["bpm_remove"] == 0 and before["ended_at"] == cutoff:
outcome = "unchanged"
emitted = None
else:
emitted, outcome = correction_exchange.local_correction(
db, capture_id, cutoff, creator_id,
)
if fail_after_effect:
raise RecoveryError("injected failure after correction effect")
after = capture_report(db, capture_id, cutoff)
protected_after = protected_snapshot(db, session_id, capture_id)
if protected_before != protected_after:
raise RecoveryError("recovery modified protected session or unrelated data")
if after["ended_at"] != cutoff or after["bpm_remove"] != 0 or after["rr_remove"] != 0:
raise RecoveryError("post-cutoff heart-rate data remain after correction")
if db.execute("PRAGMA foreign_key_check").fetchone() is not None:
raise RecoveryError("foreign key check failed after correction")
if apply:
db.commit()
else:
db.rollback()
return {
"mode": "apply" if apply else "dry-run",
"outcome": outcome,
"session_id": session_id,
"cutoff": cutoff,
"before": before,
"after": after,
"protected_sha256": _digest(protected_before),
"protected_unchanged": True,
}, emitted
except Exception:
db.rollback()
raise
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--database", required=True, type=Path)
parser.add_argument("--session-id", required=True)
parser.add_argument("--capture-id", required=True)
parser.add_argument("--cutoff", required=True)
parser.add_argument("--creator-id", default="peer_android_recovery")
parser.add_argument("--operation-input", type=Path)
parser.add_argument("--operation-output", type=Path)
parser.add_argument("--report-output", type=Path)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
operation = load_operation(args.operation_input) if args.operation_input else None
db = connect_database(args.database)
try:
db.execute("PRAGMA foreign_keys=ON")
report, emitted = recover(
db, args.session_id, args.capture_id, args.cutoff, args.creator_id,
args.apply, operation,
)
finally:
db.close()
if args.operation_output and emitted is not None:
args.operation_output.write_text(
json.dumps(emitted, ensure_ascii=False, sort_keys=True,
separators=(",", ":")), encoding="utf-8",
)
rendered = json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2)
if args.report_output:
args.report_output.write_text(rendered + "\n", encoding="utf-8")
print(rendered)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"HEART_RATE_RECOVERY=FAIL {error}")
raise SystemExit(1)

View file

@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""Recover one unpublished local Sleep branch from an immutable peer collision."""
import argparse
import hashlib
import json
import re
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from typing import Callable
import sleep_diary_exchange as exchange
REVISION_ID = re.compile(r"slr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
class RecoveryError(ValueError):
"""A collision cannot be recovered without changing established facts."""
def _new_revision_id() -> str:
return f"slr_{uuid.uuid4()}"
def _canonical_digest(value: object) -> str:
payload = json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":")).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _incoming_entry(document: dict, collision_id: str) -> dict:
matches = [item for item in document["entries"] if item["revision_id"] == collision_id]
if len(matches) != 1:
raise RecoveryError("collision revision is not unique in incoming artifact")
return matches[0]
def _merge_scalar(local: object, remote: object, label: str) -> object:
if local == remote:
return local
if local is None:
return remote
if remote is None:
return local
raise RecoveryError(f"human decision required for {label}")
def _merge_identity_list(local: list[dict], remote: list[dict], identity: str,
label: str) -> list[dict]:
merged = {value[identity]: value for value in local}
for value in remote:
existing = merged.get(value[identity])
if existing is not None and existing != value:
raise RecoveryError(f"{label} identity reused with different content")
merged[value[identity]] = value
return [merged[key] for key in sorted(merged)]
def _copy_local_revision(db: sqlite3.Connection, entry_id: str, source_id: str,
target_id: str) -> None:
db.execute(
"INSERT INTO sleep_diary_revisions "
"SELECT ?,entry_id,parent_revision_id,created_at,sleep_quality,wake_quality,day_form,"
"treatment_and_notes FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?",
(target_id, source_id, entry_id),
)
db.execute(
"INSERT INTO sleep_diary_events "
"SELECT ?,event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=?",
(target_id, source_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=?",
(target_id, source_id),
)
def _insert_revision(db: sqlite3.Connection, item: dict, revision_id: str,
parent_revision_id: str | None, created_at: str) -> None:
db.execute(
"INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
(revision_id, item["entry_id"], parent_revision_id, created_at,
item["sleep_quality"], item["wake_quality"], item["day_form"],
item["treatment_and_notes"]),
)
db.executemany(
"INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
[(revision_id, value["event_id"], value["type"], value["start_at"], value["end_at"])
for value 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(?,?,?,?,?,?,?,?,?,?)",
[(revision_id, value["intake_id"], value["medication_id"],
value["medication_name"], value["taken_at"], value["dose_value"],
value["dose_unit"], value["quantity"], value["note"], value["created_at"])
for value in item["intakes"]],
)
def recover(db: sqlite3.Connection, document: dict, collision_id: str, recovered_at: str,
id_factory: Callable[[], str] = _new_revision_id,
fail_after_reidentification: bool = False,
rejected_generation: tuple[dict, str] | None = None) -> dict:
"""Preserve both branches and advance through one non-conflicting successor.
CONTRACT: only a local current revision that has never crossed an immutable
publication boundary may be re-identified. The incoming revision retains its
published identity and exact payload. A fresh successor descends from that
published revision, while the re-identified local side branch remains durable.
INVARIANT: every mutation commits in one IMMEDIATE transaction or none does.
"""
exchange.validate(document)
if document["version"] != 2 or not REVISION_ID.fullmatch(collision_id):
raise RecoveryError("Sleep Diary V2 collision identity required")
incoming = _incoming_entry(document, collision_id)
incoming_payload = exchange.incoming_revision_payload(incoming, 2)
owner = db.execute(
"SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?", (collision_id,)
).fetchone()
if owner is None or owner[0] != incoming["entry_id"]:
raise RecoveryError("local collision revision is unavailable")
entry_id = owner[0]
current = db.execute(
"SELECT night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted "
"FROM sleep_diary_entries WHERE entry_id=?", (entry_id,)
).fetchone()
if current is None or current[4] != collision_id:
raise RecoveryError("local collision revision is not the current tip")
local_payload = exchange.persisted_revision_payload(db, entry_id, collision_id)
if local_payload == incoming_payload:
raise RecoveryError("revision is already an identical replay")
if tuple(current[:3]) != (incoming["night_start_date"], incoming["night_end_date"],
incoming["created_at"]) or bool(current[5]) != incoming["deleted"]:
raise RecoveryError("entry envelope requires a human decision")
if db.execute(
"SELECT 1 FROM sleep_diary_revisions WHERE parent_revision_id=? LIMIT 1", (collision_id,)
).fetchone() is not None:
raise RecoveryError("local collision revision already has descendants")
publication = db.execute(
"SELECT validated_revision_id,acknowledged_revision_id FROM sleep_diary_publication_state "
"WHERE entry_id=?", (entry_id,)
).fetchone()
if publication is not None and collision_id in publication:
raise RecoveryError("local collision revision already crossed publication state")
try:
immutable_reference = db.execute(
"SELECT 1 FROM sleep_diary_generation_entries WHERE entry_id=? AND revision_id=? LIMIT 1",
(entry_id, collision_id),
).fetchone()
except sqlite3.OperationalError:
immutable_reference = None
if immutable_reference is not None:
raise RecoveryError("local collision revision is referenced by an immutable generation")
if incoming["parent_revision_id"] is not None and db.execute(
"SELECT 1 FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?",
(incoming["parent_revision_id"], entry_id),
).fetchone() is None:
raise RecoveryError("incoming causal parent is unavailable locally")
merged = dict(incoming)
merged["sleep_quality"] = _merge_scalar(
local_payload["sleep_quality"], incoming["sleep_quality"], "sleep_quality"
)
merged["wake_quality"] = _merge_scalar(
local_payload["wake_quality"], incoming["wake_quality"], "wake_quality"
)
merged["day_form"] = _merge_scalar(
local_payload["day_form"], incoming["day_form"], "day_form"
)
if local_payload["treatment_and_notes"] != incoming["treatment_and_notes"]:
raise RecoveryError("human decision required for treatment_and_notes")
merged["events"] = _merge_identity_list(
local_payload["events"], incoming["events"], "event_id", "sleep event"
)
merged["intakes"] = _merge_identity_list(
local_payload["intakes"], incoming["intakes"], "intake_id", "medication intake"
)
local_revision_id = id_factory()
reconciliation_revision_id = id_factory()
if (not REVISION_ID.fullmatch(local_revision_id) or
not REVISION_ID.fullmatch(reconciliation_revision_id) or
len({collision_id, local_revision_id, reconciliation_revision_id}) != 3):
raise RecoveryError("revision identity generator returned an invalid or duplicate id")
db.execute("BEGIN IMMEDIATE")
try:
_copy_local_revision(db, entry_id, collision_id, local_revision_id)
changed = db.execute(
"UPDATE sleep_diary_entries SET current_revision_id=? "
"WHERE entry_id=? AND current_revision_id=?",
(local_revision_id, entry_id, collision_id),
).rowcount
if changed != 1:
raise RecoveryError("guarded local branch re-identification failed")
if fail_after_reidentification:
raise RecoveryError("injected recovery failure")
db.execute("DELETE FROM sleep_diary_events WHERE revision_id=?", (collision_id,))
db.execute("DELETE FROM sleep_medication_intakes WHERE revision_id=?", (collision_id,))
db.execute("DELETE FROM sleep_diary_revisions WHERE revision_id=?", (collision_id,))
_insert_revision(db, incoming, collision_id, incoming["parent_revision_id"],
incoming["updated_at"])
merged["updated_at"] = recovered_at
_insert_revision(db, merged, reconciliation_revision_id, collision_id, recovered_at)
changed = db.execute(
"UPDATE sleep_diary_entries SET updated_at=?,current_revision_id=? "
"WHERE entry_id=? AND current_revision_id=?",
(recovered_at, reconciliation_revision_id, entry_id, local_revision_id),
).rowcount
if changed != 1:
raise RecoveryError("guarded reconciliation advance failed")
rejection_rearmed = False
if rejected_generation is not None:
rejection_rearmed = _rearm_desktop_rejection(
db, rejected_generation[0], rejected_generation[1]
)
if db.execute("PRAGMA foreign_key_check").fetchone() is not None:
raise RecoveryError("foreign key check failed during recovery")
db.commit()
except Exception:
db.rollback()
raise
merged_payload = dict(merged)
merged_payload["revision_id"] = reconciliation_revision_id
merged_payload["parent_revision_id"] = collision_id
return {
"entry_id": entry_id,
"collision_revision_id": collision_id,
"local_reidentified_revision_id": local_revision_id,
"reconciliation_revision_id": reconciliation_revision_id,
"reconciliation_parent_revision_id": collision_id,
"local_payload_sha256": _canonical_digest(local_payload),
"incoming_payload_sha256": _canonical_digest(incoming_payload),
"merged_payload_sha256": _canonical_digest(
exchange.incoming_revision_payload(merged_payload, 2)
),
"event_count": len(merged["events"]),
"intake_count": len(merged["intakes"]),
"desktop_rejection_rearmed": rejection_rearmed,
}
def _rearm_desktop_rejection(db: sqlite3.Connection, manifest: dict,
manifest_digest: str) -> bool:
generation_id = manifest["generation_id"]
row = db.execute(
"SELECT run_id,producer_peer_id,consumer_peer_id,parent_generation_id,manifest_sha256,"
"result,durability,diagnostic FROM sync_consumed_generations WHERE generation_id=?",
(generation_id,),
).fetchone()
if row is None:
return False
expected = (
manifest["run_id"], manifest["producer"]["peer_id"], manifest["consumer_peer_id"],
manifest["parent_generation_id"], manifest_digest, "rejected", "sqlite-commit-rejection",
)
if tuple(row[:7]) != expected:
raise RecoveryError("generation rejection ledger does not match immutable manifest")
# The deployed consumer could lose ValueError text while recording this
# exact collision (StopIteration has an empty message). The collision has
# already been proven directly against the manifest-owned Sleep artifact.
if row[7] not in (
"",
"sleep diary revision identity reused with different content",
"schema desktop v8 à v35 requis",
):
raise RecoveryError("generation rejection has an unrelated diagnostic")
if db.execute(
"SELECT 1 FROM sync_consumed_generations WHERE parent_generation_id=? LIMIT 1",
(generation_id,),
).fetchone() is not None:
raise RecoveryError("rejected generation already has a durable descendant")
ack = db.execute(
"SELECT ack_id,result,durability,manifest_sha256 FROM sync_acknowledgements "
"WHERE generation_id=?", (generation_id,),
).fetchall()
expected_ack_id = "ack_" + generation_id.removeprefix("gen_")
if ack != [(expected_ack_id, "rejected", "sqlite-commit-rejection", manifest_digest)]:
raise RecoveryError("generation rejection ACK does not match immutable manifest")
db.execute("DELETE FROM sync_acknowledgements WHERE generation_id=?", (generation_id,))
db.execute("DELETE FROM sync_consumed_generations WHERE generation_id=?", (generation_id,))
return True
def rearm_desktop_consumer(db: sqlite3.Connection, document: dict, manifest: dict,
manifest_digest: str, collision_id: str) -> dict:
"""Rearm an exact retry after recovery or a recovery-adjacent schema rejection.
CONTRACT: the published collision identity must already contain the exact
manifest-owned payload. Only the matching rejected consumption and ACK are
removed; immutable generation bytes and recovered Sleep history are unchanged.
"""
exchange.validate(document)
incoming = _incoming_entry(document, collision_id)
persisted = exchange.persisted_revision_payload(db, incoming["entry_id"], collision_id)
if persisted != exchange.incoming_revision_payload(incoming, 2):
raise RecoveryError("recovered collision payload no longer matches immutable artifact")
db.execute("BEGIN IMMEDIATE")
try:
rearmed = _rearm_desktop_rejection(db, manifest, manifest_digest)
if not rearmed:
raise RecoveryError("desktop has no matching rejected generation to rearm")
db.commit()
except Exception:
db.rollback()
raise
return {
"generation_id": manifest["generation_id"],
"manifest_sha256": manifest_digest,
"status": "ready_for_retry",
"immutable_artifacts_rewritten": False,
}
def rearm_android_producer(db: sqlite3.Connection, manifest: dict, manifest_digest: str,
collision_id: str) -> dict:
"""Rearm one rejected immutable Android generation after causal recovery.
CONTRACT: manifest bytes and generation-to-Sleep mapping remain unchanged.
Only the exact rejection ACK is removed and its producer state returns to
waiting_acknowledgement so the normal retry can record a consumed ACK.
"""
generation_id = manifest["generation_id"]
db.execute("BEGIN IMMEDIATE")
try:
row = db.execute(
"SELECT run_id,producer_peer_id,consumer_peer_id,generated_at,parent_generation_id,"
"manifest_sha256,status FROM sync_generations WHERE generation_id=?",
(generation_id,),
).fetchone()
expected = (
manifest["run_id"], manifest["producer"]["peer_id"], manifest["consumer_peer_id"],
manifest["generated_at"], manifest["parent_generation_id"], manifest_digest, "rejected",
)
if row is None or tuple(row) != expected:
raise RecoveryError("Android rejected generation does not match immutable manifest")
mapping = db.execute(
"SELECT revision_id FROM sleep_diary_generation_entries WHERE generation_id=? "
"AND entry_id=(SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?)",
(generation_id, collision_id),
).fetchone()
if mapping != (collision_id,):
raise RecoveryError("Android generation does not own the collided Sleep revision")
ack = db.execute(
"SELECT ack_id,result,durability,manifest_sha256 FROM sync_acknowledgements "
"WHERE generation_id=?", (generation_id,),
).fetchall()
expected_ack_id = "ack_" + generation_id.removeprefix("gen_")
if ack != [(expected_ack_id, "rejected", "sqlite-commit-rejection", manifest_digest)]:
raise RecoveryError("Android rejection ACK does not match immutable manifest")
db.execute("DELETE FROM sync_acknowledgements WHERE generation_id=?", (generation_id,))
changed = db.execute(
"UPDATE sync_generations SET status='waiting_acknowledgement',acknowledged_at=NULL "
"WHERE generation_id=? AND status='rejected'", (generation_id,),
).rowcount
if changed != 1:
raise RecoveryError("Android generation rearm guard failed")
if db.execute("PRAGMA foreign_key_check").fetchone() is not None:
raise RecoveryError("foreign key check failed during Android rearm")
db.commit()
except Exception:
db.rollback()
raise
return {
"generation_id": generation_id,
"manifest_sha256": manifest_digest,
"status": "waiting_acknowledgement",
"immutable_artifacts_rewritten": False,
}
def main() -> None:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
desktop = subparsers.add_parser("recover-desktop")
desktop.add_argument("--database", required=True, type=Path)
desktop.add_argument("--generation-directory", required=True, type=Path)
desktop.add_argument("--collision-id", required=True)
desktop.add_argument("--recovered-at", default=None)
desktop.add_argument("--report", type=Path)
desktop_retry = subparsers.add_parser("rearm-desktop")
desktop_retry.add_argument("--database", required=True, type=Path)
desktop_retry.add_argument("--generation-directory", required=True, type=Path)
desktop_retry.add_argument("--collision-id", required=True)
desktop_retry.add_argument("--report", type=Path)
android = subparsers.add_parser("rearm-android")
android.add_argument("--database", required=True, type=Path)
android.add_argument("--generation-directory", required=True, type=Path)
android.add_argument("--collision-id", required=True)
android.add_argument("--report", type=Path)
args = parser.parse_args()
import sync_generation_exchange as generation
manifest_path = args.generation_directory / "manifest.json"
preliminary = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest, manifest_digest = generation.validate_published(
args.generation_directory, preliminary["consumer_peer_id"]
)
sleep_descriptor = next(
(value for value in manifest["artifacts"] if value["logical_name"] == "sleep-diary"),
None,
)
if sleep_descriptor is None:
raise RecoveryError("generation has no Sleep Diary artifact")
artifact = args.generation_directory / Path(sleep_descriptor["filename"]).name
document = exchange.load(artifact)
with exchange.connect_database(args.database) as db:
version = db.execute("PRAGMA user_version").fetchone()[0]
if args.command == "recover-desktop":
if version != 35:
raise RecoveryError("desktop schema v35 pre-immutability recovery required")
recovered_at = args.recovered_at or datetime.now().astimezone().isoformat()
report = recover(
db, document, args.collision_id, recovered_at,
rejected_generation=(manifest, manifest_digest),
)
elif args.command == "rearm-desktop":
if version != 36:
raise RecoveryError("desktop schema v36 post-recovery retry required")
report = rearm_desktop_consumer(
db, document, manifest, manifest_digest, args.collision_id
)
else:
if version not in (32, 33):
raise RecoveryError("Android schema v32 or v33 required")
report = rearm_android_producer(
db, manifest, manifest_digest, args.collision_id
)
encoded = json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
if args.report is not None:
args.report.write_text(encoded, encoding="utf-8")
print(encoded, end="")
if __name__ == "__main__":
main()

View file

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

View file

@ -217,8 +217,119 @@ def revision_ancestry(db: sqlite3.Connection, parent_sql: str, owner_id: str,
return result
def _persisted_entry(db: sqlite3.Connection, row: tuple) -> dict:
"""Build one durable entry independently of its publication state."""
entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted = row
revision = db.execute(
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes "
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id)
).fetchone()
if revision is None:
fail("sleep diary current revision is missing")
ancestry = revision_ancestry(
db,
"SELECT parent_revision_id FROM sleep_diary_revisions "
"WHERE entry_id=? AND revision_id=?",
entry_id,
revision_id,
REVISION_ID,
)
events = [
{"event_id": value[0], "type": value[1], "start_at": value[2], "end_at": value[3]}
for value in db.execute(
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
)
]
intakes = [
{"intake_id": value[0], "medication_id": value[1], "medication_name": value[2],
"taken_at": value[3], "dose_value": value[4], "dose_unit": value[5],
"quantity": value[6], "note": value[7], "created_at": value[8]}
for value in db.execute(
"SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,"
"quantity,note,created_at FROM sleep_medication_intakes "
"WHERE revision_id=? ORDER BY intake_id", (revision_id,)
)
]
return {
"entry_id": entry_id, "night_start_date": night_start, "night_end_date": night_end,
"created_at": created_at, "updated_at": updated_at, "revision_id": revision_id,
"parent_revision_id": revision[0], "ancestry": ancestry, "deleted": bool(deleted),
"sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
"treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes,
}
def persisted_revision_payload(db: sqlite3.Connection, entry_id: str,
revision_id: str) -> dict | None:
"""Return the immutable content owned by one revision identity.
WHY: a replay can name an older revision while the mutable entry tip has
advanced. Comparing only the current tip would let a reused historical
identity hide behind its descendant. CONTRACT: parent, scalar observations,
events, and medication occurrences are immutable for a revision_id.
"""
revision = db.execute(
"SELECT parent_revision_id,created_at,sleep_quality,wake_quality,day_form,"
"COALESCE(treatment_and_notes,'') FROM sleep_diary_revisions "
"WHERE revision_id=? AND entry_id=?",
(revision_id, entry_id),
).fetchone()
if revision is None:
return None
events = [
{"event_id": value[0], "type": value[1], "start_at": value[2], "end_at": value[3]}
for value in db.execute(
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
)
]
intakes = [
{"intake_id": value[0], "medication_id": value[1], "medication_name": value[2],
"taken_at": value[3], "dose_value": value[4], "dose_unit": value[5],
"quantity": value[6], "note": value[7], "created_at": value[8]}
for value in db.execute(
"SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,"
"quantity,COALESCE(note,''),created_at FROM sleep_medication_intakes "
"WHERE revision_id=? ORDER BY intake_id", (revision_id,)
)
]
return {
"entry_id": entry_id,
"revision_id": revision_id,
"parent_revision_id": revision[0],
"created_at": revision[1],
"sleep_quality": revision[2],
"wake_quality": revision[3],
"day_form": revision[4],
"treatment_and_notes": revision[5],
"events": events,
"intakes": intakes,
}
def incoming_revision_payload(item: dict, wire_version: int) -> dict:
"""Normalize V1 quantity before immutable-identity comparison."""
intakes = []
for intake in item["intakes"]:
normalized = dict(intake)
normalized.setdefault("quantity", 1 if wire_version == 1 else None)
intakes.append(normalized)
return {
"entry_id": item["entry_id"],
"revision_id": item["revision_id"],
"parent_revision_id": item["parent_revision_id"],
"created_at": item["updated_at"],
"sleep_quality": item["sleep_quality"],
"wake_quality": item["wake_quality"],
"day_form": item["day_form"],
"treatment_and_notes": item["treatment_and_notes"],
"events": sorted(item["events"], key=lambda value: value["event_id"]),
"intakes": sorted(intakes, key=lambda value: value["intake_id"]),
}
def build(db: sqlite3.Connection) -> dict:
entries = []
# CONTRACT: local durability does not publish a draft. A generation may
# capture only the current tip explicitly validated by the user.
rows = db.execute(
@ -229,37 +340,7 @@ def build(db: sqlite3.Connection) -> dict:
).fetchall()
if len(rows) > MAX_ENTRIES:
fail("sleep diary entry bound exceeded")
for entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted in rows:
revision = db.execute(
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes "
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id)
).fetchone()
if revision is None:
fail("sleep diary current revision is missing")
ancestry = revision_ancestry(
db,
"SELECT parent_revision_id FROM sleep_diary_revisions "
"WHERE entry_id=? AND revision_id=?",
entry_id,
revision_id,
REVISION_ID,
)
events = [
{"event_id": row[0], "type": row[1], "start_at": row[2], "end_at": row[3]}
for row in db.execute(
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
)
]
intakes = [{"intake_id": row[0], "medication_id": row[1], "medication_name": row[2],
"taken_at": row[3], "dose_value": row[4], "dose_unit": row[5],
"quantity": row[6], "note": row[7], "created_at": row[8]}
for row in db.execute("SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY intake_id", (revision_id,))]
entries.append({"entry_id": entry_id, "night_start_date": night_start,
"night_end_date": night_end, "created_at": created_at, "updated_at": updated_at,
"revision_id": revision_id, "parent_revision_id": revision[0], "ancestry": ancestry,
"deleted": bool(deleted), "sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
"treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes})
entries = [_persisted_entry(db, row) for row in rows]
medications = []
for row in db.execute("SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted,r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active FROM sleep_medications m JOIN sleep_medication_revisions r ON r.revision_id=m.current_revision_id ORDER BY m.medication_id"):
ancestry = revision_ancestry(
@ -328,7 +409,13 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
)
if local is not None and not remote_descends_from_local and medication["parent_revision_id"] != local[0]:
fail("concurrent medication revision")
if local is None and medication["parent_revision_id"] is not None: fail("unknown medication parent")
# CONTRACT: this artifact is a current-state companion, not a revision
# log. A fresh peer may seed a non-root tip only when the validated
# complete ancestry accompanies it; legacy payloads without ancestry
# retain the strict unknown-parent rejection.
if (local is None and medication["parent_revision_id"] is not None and
"ancestry" not in medication):
fail("unknown medication parent")
if local is not None and local[1] and not medication["deleted"]: fail("medication resurrection")
if local is None:
db.execute("INSERT INTO sleep_medications VALUES(?,?,?,?,?)", (medication["medication_id"], medication["created_at"], medication["updated_at"], medication["revision_id"], int(medication["deleted"])))
@ -340,9 +427,29 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
(item["entry_id"],),
).fetchone()
known_owner = db.execute(
"SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?",
(item["revision_id"],),
).fetchone()
if known_owner is not None:
persisted_revision = persisted_revision_payload(
db, known_owner[0], item["revision_id"]
)
incoming_revision = incoming_revision_payload(item, wire_version)
if known_owner[0] != item["entry_id"] or persisted_revision != incoming_revision:
fail("sleep diary revision identity reused with different content")
if local is not None and local[0] == item["revision_id"]:
persisted = next(
value for value in build(db)["entries"] if value["entry_id"] == item["entry_id"]
# INVARIANT: an identical durable revision may still be a local
# draft because publication state is intentionally separate. The
# incoming validated snapshot must compare the durable content
# directly before it acknowledges that revision.
persisted = _persisted_entry(
db,
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(),
)
persisted_payload = dict(persisted)
incoming_payload = dict(item)
@ -379,7 +486,13 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:]
if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]:
fail("concurrent sleep diary revision")
if local is None and item["parent_revision_id"] is not None:
# WHY: one-tap Sleep creates several local revisions before its first
# synchronization. The current snapshot carries their bounded lineage
# by identity, so a new desktop must be able to seed the tip without
# inventing historical payloads. INVARIANT: missing ancestry still
# rejects a non-root first snapshot.
if (local is None and item["parent_revision_id"] is not None and
"ancestry" not in item):
fail("sleep diary revision has an unknown parent")
if local is not None and local[1] and not item["deleted"]:
fail("sleep diary deletion cannot be resurrected")
@ -429,8 +542,8 @@ def main() -> None:
import_command.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35):
fail("desktop schema v30-v35 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v30-v36 required")
if args.command == "export":
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
separators=(",", ":")), encoding="utf-8")

View file

@ -25,6 +25,7 @@ import cardio_calibration_exchange
import cardio_guidance_exchange
import execution_draft_exchange
import heart_rate_exchange
import heart_rate_correction_exchange
import session_timeline_exchange
import import_equipment_associations
import import_equipment_definitions
@ -90,6 +91,7 @@ SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
("trainlog-cardio-calibrations", 1),
("trainlog-cardio-guidance", 1),
("trainlog-heart-rate", 1),
("trainlog-heart-rate-corrections", 1),
("trainlog-session-timeline", 1),
}
@ -219,9 +221,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35)
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:
raise GenerationError("desktop schema v24 through v35 required")
raise GenerationError("desktop schema v24 through v36 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -417,7 +419,29 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
"size": size, "sha256": checksum, "required": required})
parent = None
with closing(connect_database(database)) as db:
parent_rows = db.execute("SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2", (producer, consumer)).fetchall()
# WHY: archived generations retain immutable audit rows, including
# forks, but have left the bounded active publication window.
# CONTRACT: only unarchived acknowledged rows with an exact durable
# consumed ACK participate in active parent selection; multiple
# active tips remain a hard failure.
# INVARIANT: archival evidence is never rewritten or deleted.
parent_rows = db.execute(
"SELECT g.generation_id FROM sync_generations g WHERE "
"g.producer_peer_id=? AND g.consumer_peer_id=? AND "
"g.status='acknowledged' AND EXISTS(SELECT 1 FROM "
"sync_acknowledgements k WHERE k.generation_id=g.generation_id "
"AND k.result='consumed' AND k.durability='sqlite-commit-full') "
"AND NOT EXISTS(SELECT 1 FROM "
"sync_generation_archives a WHERE a.generation_id=g.generation_id) "
"AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE "
"c.parent_generation_id=g.generation_id AND c.status='acknowledged' "
"AND EXISTS(SELECT 1 FROM sync_acknowledgements ck WHERE "
"ck.generation_id=c.generation_id AND ck.result='consumed' AND "
"ck.durability='sqlite-commit-full') "
"AND NOT EXISTS(SELECT 1 FROM sync_generation_archives ca WHERE "
"ca.generation_id=c.generation_id)) LIMIT 2",
(producer, consumer),
).fetchall()
if len(parent_rows) > 1: raise GenerationError("outgoing generation lineage has multiple tips")
parent = parent_rows[0][0] if parent_rows else None
manifest = {"format": MANIFEST, "version": 1, "generation_id": generation,
@ -630,6 +654,36 @@ def record_consumed(db: sqlite3.Connection, manifest: dict, manifest_digest: str
return ack
def require_current_consumed_parent(
db: sqlite3.Connection, producer_peer_id: str, declared_parent: str | None
) -> None:
"""Require the producer to extend one locally observed consumed tip."""
# WHY: a producer can archive one branch after a transient fork, while the
# consumer retains both immutable consumed rows and cannot observe that
# producer-local archive decision.
# CONTRACT: a non-empty lineage must extend one of its current consumed
# tips; an empty lineage requires a null parent. An old or unrelated parent
# remains a hard failure.
# INVARIANT: this check never rewrites or discards either fork's audit row.
tip_predicate = (
"g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 "
"FROM sync_consumed_generations c WHERE c.parent_generation_id="
"g.generation_id AND c.result='consumed')"
)
has_tip = db.execute(
"SELECT 1 FROM sync_consumed_generations g WHERE " + tip_predicate + " LIMIT 1",
(producer_peer_id,),
).fetchone() is not None
parent_is_tip = declared_parent is not None and db.execute(
"SELECT 1 FROM sync_consumed_generations g WHERE g.generation_id=? AND "
+ tip_predicate
+ " LIMIT 1",
(declared_parent, producer_peer_id),
).fetchone() is not None
if (not has_tip and declared_parent is not None) or (has_tip and not parent_is_tip):
raise GenerationError("generation lineage is stale or unrelated")
def record_rejected(database: Path, manifest: dict, manifest_digest: str, diagnostic: str) -> dict:
# Android's platform JSON quoter escapes solidus characters while the
# desktop canonical encoder does not. Rejection diagnostics are bounded
@ -685,6 +739,10 @@ def consume_desktop(database: Path, directory: Path) -> dict:
if "sleep-diary" in listed else None)
heart_rate = (heart_rate_exchange.load(listed["heart-rate"])
if "heart-rate" in listed else None)
heart_rate_corrections = (
heart_rate_correction_exchange.load(listed["heart-rate-corrections"])
if "heart-rate-corrections" in listed else None
)
cardio_calibrations = (
cardio_calibration_exchange.load(listed["cardio-calibrations"])
if "cardio-calibrations" in listed else None
@ -712,15 +770,17 @@ def consume_desktop(database: Path, directory: Path) -> dict:
raise GenerationError("generation identity reused with different manifest")
db.rollback()
return json.loads(known[1])
predecessors = db.execute("SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2", (manifest["producer"]["peer_id"],)).fetchall()
if len(predecessors) > 1: raise GenerationError("consumed generation lineage has multiple tips")
expected_parent = predecessors[0][0] if predecessors else None
if manifest["parent_generation_id"] != expected_parent:
raise GenerationError("generation lineage is stale or unrelated")
require_current_consumed_parent(
db,
manifest["producer"]["peer_id"],
manifest["parent_generation_id"],
)
# Causal state is applied first. Complete-envelope helpers retain
# item-level conflict rules while avoiding the legacy global guard.
for operation in causal["operations"]:
causal_delete_exchange.apply_operation(db, operation)
if heart_rate_corrections is not None:
heart_rate_correction_exchange.apply_document(db, heart_rate_corrections)
import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True)
import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=True)
import_mobile_export.apply_payload(db, history, complete_causal_envelope=True)

View file

@ -30,6 +30,7 @@ MAX_DIAGNOSTIC = 1024
PHASES = {
"requested",
"waiting_android_publication",
"android_archive_ack_observed",
"running",
"local_import_committed",
"published",
@ -297,6 +298,7 @@ def read_worker(
deadline = time.monotonic() + timeout
allowed = [
"waiting_android_publication",
"android_archive_ack_observed",
"running",
"local_import_committed",
"published",
@ -338,6 +340,7 @@ def read_worker(
"inbound_generation_id",
"outbound_generation_id",
"manifest_sha256",
"recovered_acknowledgements",
}
or value.get("version") != 1
or value.get("run_id") != run_id

View file

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

View file

@ -0,0 +1,27 @@
/* Trainlog loopback Web equipment projection and transactional merge. */
#ifndef TRAINLOG_WEB_EQUIPMENT_H
#define TRAINLOG_WEB_EQUIPMENT_H
#include <stddef.h>
#include "trainlog/database.h"
#include "trainlog/status.h"
/* CONTRACT: returned JSON is heap-owned by the caller and must be freed. */
TrainlogStatus
trainlog_web_equipment_list_json(TrainlogDatabase *database, char **output, size_t *output_size);
/* WHY: duplicate physical-machine identities split historical context and MAX
* lookup. CONTRACT: only a custom duplicate may be absorbed; every relational
* reference is repointed in one IMMEDIATE transaction before the definition is
* deleted. INVARIANT: performed facts, timestamps, provenance and values are
* never rewritten, a matching causal marker makes exact replay idempotent, and
* embedded draft JSON causes a conflict rather than a partial or textual
* rewrite. */
TrainlogStatus trainlog_web_equipment_merge_json(TrainlogDatabase *database,
const char *input,
size_t input_size,
char **output,
size_t *output_size);
#endif

View file

@ -48,6 +48,8 @@ if web_assets_enabled
'../web/src/api/health.ts',
'../web/src/api/dashboard.ts',
'../web/src/api/analysis.ts',
'../web/src/api/heartRate.ts',
'../web/src/api/heartRate.test.ts',
'../web/src/api/sleepDiary.ts',
'../web/src/api/analysis.test.ts',
'../web/src/analysis/messages.ts',
@ -64,6 +66,10 @@ if web_assets_enabled
'../web/src/components/DeleteConfirmationDialog.tsx',
'../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx',
'../web/src/components/HeartRateTimelinePanel.tsx',
'../web/src/components/HeartRateTimelinePanel.test.tsx',
'../web/src/components/heartRateVariation.ts',
'../web/src/components/heartRateVariation.test.ts',
'../web/src/components/SyncControl.tsx',
'../web/src/components/SyncControl.test.tsx',
'../web/src/components/Tile.tsx',
@ -75,10 +81,15 @@ if web_assets_enabled
'../web/src/dashboard/dashboardFormat.ts',
'../web/src/dashboard/dashboardLayout.ts',
'../web/src/presentation/DatePreferences.tsx',
'../web/src/presentation/LanguagePreferences.tsx',
'../web/src/presentation/dateFormat.ts',
'../web/src/routes/DashboardPage.tsx',
'../web/src/routes/AnalysisPage.tsx',
'../web/src/routes/SleepDiaryWorkspace.tsx',
'../web/src/routes/sleepTimelineGeometry.ts',
'../web/src/routes/sleepTimelineGeometry.test.ts',
'../web/src/routes/sleepVisualProjection.ts',
'../web/src/routes/sleepVisualProjection.test.ts',
'../web/src/report/sleepDiaryPdf.ts',
'../web/src/report/sleepDiaryPdf.test.ts',
'../web/src/routes/AnalysisPage.test.tsx',
@ -86,11 +97,15 @@ if web_assets_enabled
'../web/src/routes/SessionsPage.tsx',
'../web/src/api/sessions.ts',
'../web/src/api/exercises.ts',
'../web/src/api/equipment.ts',
'../web/src/api/equipment.test.ts',
'../web/src/api/exercises.test.ts',
'../web/src/api/programs.ts',
'../web/src/routes/ProgramsTab.tsx',
'../web/src/routes/ProgramsCalendarPage.tsx',
'../web/src/routes/ExercisesPage.tsx',
'../web/src/routes/EquipmentPage.tsx',
'../web/src/routes/SettingsPage.tsx',
'../web/src/routes/ExercisesPage.test.tsx',
'../web/src/theme/app.css',
'../web/src/theme/tokens.css',
@ -226,6 +241,7 @@ trainlog_core_sources = files(
'src/web_programs.c',
'src/web_sleep.c',
'src/web_exercises.c',
'src/web_equipment.c',
'src/duration.c',
'src/id.c',
'src/timeutil.c',
@ -873,6 +889,14 @@ test_schema_v34_migration = executable(
)
test('schema_v34_migration', test_schema_v34_migration)
test_schema_v35_migration = executable(
'test_schema_v35_migration',
'tests/test_schema_v35_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v35_migration', test_schema_v35_migration)
test_schema_v7_migration = executable(
'test_schema_v7_migration',
'tests/test_schema_v7_migration.c',
@ -952,11 +976,21 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'],
)
test(
'sleep_revision_collision_recovery',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sleep_revision_collision_recovery.py'],
)
test(
'heart_rate_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_heart_rate_exchange.py'],
)
test(
'heart_rate_tail_recovery',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_heart_rate_tail_recovery.py'],
)
test(
'session_timeline_exchange',
find_program('python3'),

View file

@ -21,13 +21,16 @@ typedef struct TileContract {
} TileContract;
static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"next-session", 4U, 8U, 3U, 7U},
{"activity", 5U, 12U, 3U, 7U},
{"progression", 6U, 12U, 4U, 9U},
{"last-session", 3U, 7U, 3U, 6U},
{"max-records", 3U, 8U, 3U, 7U},
{"muscle-distribution", 5U, 12U, 4U, 9U},
{"cardio-recovery", 5U, 12U, 4U, 8U}};
/* CONTRACT: every tile can reach full width and multiple useful vertical
* sizes. Per-tile minima preserve legibility; the shared 12-column grid
* and overlap validation remain the bounded layout authority. */
{"next-session", 4U, 12U, 3U, 10U},
{"activity", 5U, 12U, 3U, 12U},
{"progression", 6U, 12U, 4U, 12U},
{"last-session", 3U, 12U, 3U, 10U},
{"max-records", 3U, 12U, 3U, 10U},
{"muscle-distribution", 5U, 12U, 4U, 12U},
{"cardio-recovery", 5U, 12U, 4U, 12U}};
static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"next-session", 0U, 0U, 5U, 4U},

View file

@ -1549,6 +1549,35 @@ static TrainlogStatus ensure_v35_medication_quantity(TrainlogDatabase *database)
return TRAINLOG_STATUS_OK;
}
/* WHY: revision identity is the causal content address used by synchronized
* peers; changing a persisted row in place can make two peers assign different
* facts to the same UUID. CONTRACT: v36 makes every Sleep revision payload
* append-only. Tips and publication markers remain mutable through their owning
* tables. INVARIANT: correction always appends a fresh revision identity. */
static const char *const MIGRATE_V35_TO_V36_SQL =
"BEGIN IMMEDIATE;"
"CREATE TRIGGER IF NOT EXISTS sleep_diary_revisions_immutable_update BEFORE UPDATE ON "
"sleep_diary_revisions BEGIN SELECT RAISE(ABORT,'sleep diary revision is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_diary_revisions_immutable_delete BEFORE DELETE ON "
"sleep_diary_revisions BEGIN SELECT RAISE(ABORT,'sleep diary revision is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_diary_events_immutable_update BEFORE UPDATE ON "
"sleep_diary_events "
"BEGIN SELECT RAISE(ABORT,'sleep diary event is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_diary_events_immutable_delete BEFORE DELETE ON "
"sleep_diary_events "
"BEGIN SELECT RAISE(ABORT,'sleep diary event is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_medication_intakes_immutable_update BEFORE UPDATE ON "
"sleep_medication_intakes BEGIN SELECT RAISE(ABORT,'sleep medication intake is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_medication_intakes_immutable_delete BEFORE DELETE ON "
"sleep_medication_intakes BEGIN SELECT RAISE(ABORT,'sleep medication intake is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_medication_revisions_immutable_update BEFORE UPDATE ON "
"sleep_medication_revisions BEGIN SELECT RAISE(ABORT,'sleep medication revision is "
"immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_medication_revisions_immutable_delete BEFORE DELETE ON "
"sleep_medication_revisions BEGIN SELECT RAISE(ABORT,'sleep medication revision is "
"immutable');END;"
"PRAGMA user_version=36;COMMIT;";
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
* before medication capture joined the same unreleased migration. CONTRACT:
* this additive repair is identical to the tail of v28 -> v29 and runs only
@ -2383,7 +2412,8 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
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) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2552,6 +2582,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 35) {
status = ensure_v35_medication_quantity(database);
}
if (status == TRAINLOG_STATUS_OK && version < 36) {
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2571,7 +2604,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v35" : "migrate database to schema v35",
version == 0 ? "create schema v36" : "migrate database to schema v36",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -91,7 +91,12 @@ static bool add_overview(TrainlogDatabase *database,
"se.id=ps.session_exercise_row_id JOIN eligible e ON e.id=se.session_row_id),"
"CASE WHEN COUNT(*)>0 AND COUNT(*)=SUM(CASE WHEN ended_at IS NOT NULL AND "
"unixepoch(ended_at)>=unixepoch(started_at) THEN 1 ELSE 0 END) THEN "
"SUM(unixepoch(ended_at)-unixepoch(started_at)) ELSE NULL END FROM eligible";
"SUM(unixepoch(ended_at)-unixepoch(started_at)) ELSE NULL END,"
"(SELECT COUNT(DISTINCT se.exercise_row_id) FROM session_exercises se JOIN eligible e "
"ON e.id=se.session_row_id WHERE EXISTS(SELECT 1 FROM performed_sets p WHERE "
"p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity c WHERE "
"c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results m WHERE "
"m.session_exercise_row_id=se.id)) FROM eligible";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *overview = yyjson_mut_obj(document);
bool ok;
@ -109,6 +114,8 @@ static bool add_overview(TrainlogDatabase *database,
? yyjson_mut_obj_add_null(document, overview, "duration_seconds")
: yyjson_mut_obj_add_sint(
document, overview, "duration_seconds", sqlite3_column_int64(statement, 2))) &&
yyjson_mut_obj_add_sint(
document, overview, "distinct_exercises", sqlite3_column_int64(statement, 3)) &&
yyjson_mut_obj_add_val(document, root, "overview", overview) &&
sqlite3_step(statement) == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK;
return ok;
@ -120,7 +127,8 @@ static bool add_activity(TrainlogDatabase *database,
yyjson_mut_val *root,
bool *partial) {
static const char SQL[] =
"SELECT substr(s.started_at,1,10),COUNT(DISTINCT s.id),COUNT(DISTINCT ps.id) FROM "
"SELECT substr(s.started_at,1,10),COUNT(DISTINCT s.id),COUNT(DISTINCT ps.id),"
"COUNT(DISTINCT se.id) FROM "
"sessions s LEFT JOIN session_exercises se ON se.session_row_id=s.id LEFT JOIN "
"performed_sets ps ON ps.session_exercise_row_id=se.id WHERE (?1 IS NULL OR "
"unixepoch(s.started_at)>=?1) AND unixepoch(s.started_at)<=?2 AND (ps.id IS NOT NULL OR "
@ -154,6 +162,8 @@ static bool add_activity(TrainlogDatabase *database,
!yyjson_mut_obj_add_sint(
document, day, "sessions", sqlite3_column_int64(statement, 1)) ||
!yyjson_mut_obj_add_sint(document, day, "sets", sqlite3_column_int64(statement, 2)) ||
!yyjson_mut_obj_add_sint(
document, day, "exercises", sqlite3_column_int64(statement, 3)) ||
!yyjson_mut_arr_add_val(activity, day)) {
(void)sqlite3_finalize(statement);
return false;
@ -164,6 +174,100 @@ static bool add_activity(TrainlogDatabase *database,
yyjson_mut_obj_add_val(document, root, "activity", activity);
}
/* WHY: the analysis entry point must progress from primary BODY ZONE to
* exercise before showing an individual curve. CONTRACT: measured_seconds is
* derived only from imported occurrence start/end timestamps. INVARIANT: no
* duration is inferred from sets, repetitions, rest targets or session time. */
static bool add_exercise_groups(TrainlogDatabase *database,
const TrainlogWebAnalysisQuery *query,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
static const char SQL[] =
"SELECT z.zone_id,e.exercise_id,e.name,"
"COUNT(DISTINCT CASE WHEN s.id IS NOT NULL THEN se.id END),"
"COUNT(DISTINCT s.id),COUNT(DISTINCT ps.id),"
"COALESCE((SELECT SUM(unixepoch(tt.ended_at)-unixepoch(tt.started_at)) FROM "
"session_exercise_timeline tt JOIN sessions ts ON ts.session_id=tt.session_id WHERE "
"tt.exercise_id=e.exercise_id AND tt.ended_at>=tt.started_at AND (?1 IS NULL OR "
"unixepoch(ts.started_at)>=?1) AND unixepoch(ts.started_at)<=?2),0),"
"(SELECT COUNT(*) FROM session_exercise_timeline tt JOIN sessions ts ON "
"ts.session_id=tt.session_id WHERE tt.exercise_id=e.exercise_id AND "
"tt.ended_at>=tt.started_at AND (?1 IS NULL OR unixepoch(ts.started_at)>=?1) AND "
"unixepoch(ts.started_at)<=?2) "
"FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id AND "
"z.role='primary' LEFT JOIN session_exercises se ON se.exercise_row_id=e.id "
"LEFT JOIN sessions s ON s.id=se.session_row_id AND (?1 IS NULL OR "
"unixepoch(s.started_at)>=?1) AND unixepoch(s.started_at)<=?2 "
"LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id AND s.id IS NOT NULL "
"GROUP BY z.zone_id,e.exercise_id,e.name "
"HAVING COUNT(DISTINCT s.id)>0 ORDER BY z.zone_id,e.name COLLATE NOCASE,e.exercise_id";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *groups = yyjson_mut_arr(document);
yyjson_mut_val *group = NULL;
yyjson_mut_val *exercises = NULL;
char current_zone[64] = "";
int step;
if (groups == NULL || !prepare(database, SQL, &statement) || !bind_interval(statement, query)) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
const char *zone_id = (const char *)sqlite3_column_text(statement, 0);
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(zone_id);
yyjson_mut_val *exercise;
if (zone == NULL) {
(void)sqlite3_finalize(statement);
return false;
}
if (strcmp(current_zone, zone_id) != 0) {
if (group != NULL && !yyjson_mut_obj_add_val(document, group, "exercises", exercises)) {
(void)sqlite3_finalize(statement);
return false;
}
group = yyjson_mut_obj(document);
exercises = yyjson_mut_arr(document);
if (group == NULL || exercises == NULL ||
!yyjson_mut_obj_add_strcpy(document, group, "zone_id", zone_id) ||
!yyjson_mut_obj_add_strcpy(document, group, "label", zone->display_name) ||
!yyjson_mut_arr_add_val(groups, group)) {
(void)sqlite3_finalize(statement);
return false;
}
(void)snprintf(current_zone, sizeof(current_zone), "%s", zone_id);
}
exercise = yyjson_mut_obj(document);
if (exercise == NULL ||
!yyjson_mut_obj_add_strcpy(document,
exercise,
"exercise_id",
(const char *)sqlite3_column_text(statement, 1)) ||
!yyjson_mut_obj_add_strcpy(
document, exercise, "name", (const char *)sqlite3_column_text(statement, 2)) ||
!yyjson_mut_obj_add_sint(
document, exercise, "occurrences", sqlite3_column_int64(statement, 3)) ||
!yyjson_mut_obj_add_sint(
document, exercise, "sessions", sqlite3_column_int64(statement, 4)) ||
!yyjson_mut_obj_add_sint(
document, exercise, "sets", sqlite3_column_int64(statement, 5)) ||
!yyjson_mut_obj_add_sint(
document, exercise, "measured_seconds", sqlite3_column_int64(statement, 6)) ||
!yyjson_mut_obj_add_sint(
document, exercise, "measured_occurrences", sqlite3_column_int64(statement, 7)) ||
!yyjson_mut_arr_add_val(exercises, exercise)) {
(void)sqlite3_finalize(statement);
return false;
}
}
if (group != NULL && !yyjson_mut_obj_add_val(document, group, "exercises", exercises)) {
(void)sqlite3_finalize(statement);
return false;
}
return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK &&
yyjson_mut_obj_add_val(document, root, "exercise_groups", groups);
}
static bool add_exercises(TrainlogDatabase *database,
const TrainlogWebAnalysisQuery *query,
yyjson_mut_doc *document,
@ -553,7 +657,8 @@ add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val
"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 "
"(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) "
@ -658,6 +763,7 @@ TrainlogStatus trainlog_web_analysis_json(TrainlogDatabase *database,
: "90d") ||
!add_overview(database, query, document, root) ||
!add_activity(database, query, document, root, &partial) ||
!add_exercise_groups(database, query, document, root) ||
!add_exercises(database, query, document, root, selected_id, &partial) ||
!add_exercise_detail(database, query, selected_id, document, root, &partial) ||
!add_zones(database, query, document, root) ||

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