Compare commits
23 commits
main
...
trainlog-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c50d483f2 | ||
|
|
5f16a3e0ff | ||
|
|
f4ddcfdb05 | ||
|
|
f9bb96ae08 | ||
|
|
8995843e46 | ||
|
|
e64b9ddcd3 | ||
|
|
c2935b81ee | ||
|
|
469a256715 | ||
|
|
3758875dbb | ||
|
|
e7439c5177 | ||
|
|
e2c07f1e80 | ||
|
|
3a4ec34eb9 | ||
|
|
df41300353 | ||
|
|
c4df2c3b3f | ||
|
|
7c27e7f8ec | ||
|
|
6c0e7af2f0 | ||
|
|
c17394ece2 | ||
|
|
39a9df7b07 | ||
|
|
9568bb60d3 | ||
|
|
3d5dfa224a | ||
|
|
8b3a91cf50 | ||
|
|
42cee73632 | ||
|
|
acdf5ae06d |
123 changed files with 5243 additions and 502 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -8,12 +8,13 @@ Trainlog consists of:
|
|||
- a Unix/Linux C17 terminal TUI for durable history, correction, analysis,
|
||||
visualization, and manual synchronization, using Notcurses as its completed
|
||||
active rendering/input backend;
|
||||
- a loopback-only local Web adapter whose Dashboard is implemented on the
|
||||
0.1.2 development branch; its Analyse, Programmes, Sessions, and Exercises
|
||||
routes remain placeholders pending separate contracts;
|
||||
- a loopback-only local Web adapter with implemented Dashboard, Analyse,
|
||||
Programmes, Sessions, and Exercises routes on the 0.1.7 development line;
|
||||
- a small user-session PC agent, `trainlog-syncd`, for Android-triggered
|
||||
synchronization;
|
||||
- versioned JSON synchronization artifacts exchanged over direct MTP.
|
||||
- versioned JSON synchronization artifacts exchanged through configured
|
||||
Bluetooth Classic RFCOMM, direct MTP recovery, or a separate private Drive
|
||||
workflow.
|
||||
|
||||
The desktop SQLite database is the canonical long-term history.
|
||||
|
||||
|
|
@ -170,7 +171,7 @@ It is not the canonical analytics store.
|
|||
The Android UI is driven by exercise metadata, never by exercise-name
|
||||
heuristics.
|
||||
|
||||
Android local SQLite schema v24 retains the exactly-one durable active-session
|
||||
Android local SQLite schema v34 retains the exactly-one durable active-session
|
||||
draft introduced in v4.
|
||||
Every meaningful draft/form mutation is persisted by the repository. Back,
|
||||
backgrounding and process death never delete the draft. Home offers explicit
|
||||
|
|
@ -184,7 +185,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
|
|||
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
|
||||
durable flattened exercise-alias mapping used to resolve retired creator IDs.
|
||||
Desktop schema v12 owns the same alias compatibility boundary. Later additive
|
||||
desktop migrations reach v26 and Android migrations reach v24 without changing
|
||||
desktop migrations reach v37 and Android migrations reach v34 without changing
|
||||
that identity contract. The active
|
||||
completed-session exchange is the separate strict
|
||||
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
|
||||
|
|
@ -241,14 +242,16 @@ Desktop SQLite schema is versioned with:
|
|||
PRAGMA user_version;
|
||||
```
|
||||
|
||||
The current desktop schema is v26. Schema v9 added an occurrence-owned explicit
|
||||
The current desktop schema is v37. Schema v9 added an occurrence-owned explicit
|
||||
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
|
||||
flattened exercise aliases; v13-v18 add machine metadata, feedback and
|
||||
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
|
||||
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
|
||||
generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
|
||||
and durable preparation withdrawal; v25 adds Programs; and v26 additively adds
|
||||
logical Program deletion. None changes `TRAINLOG_FORMAT_V1`.
|
||||
logical Program deletion. Later additive migrations through v37 add Sleep,
|
||||
heart-rate, Cardio and Program planning state. None changes
|
||||
`TRAINLOG_FORMAT_V1`.
|
||||
|
||||
Every incompatible schema evolution requires an explicit migration and
|
||||
regression coverage.
|
||||
|
|
|
|||
101
CHANGELOG.md
101
CHANGELOG.md
|
|
@ -1,5 +1,106 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased — 0.1.7
|
||||
|
||||
- Reconciled the canonical documentation against the `f4ddcfd` development
|
||||
lineage: current source schemas v37/v34, implemented Web routes and Program
|
||||
rescheduling, observed package provenance, Sleep row paging, capability
|
||||
selected exchange, Web packaging requirements and validation limits. The
|
||||
[documentation review](docs/reviews/documentation_deep_review_v1.md) records
|
||||
the evidence matrix. This entry changes no runtime behavior or format.
|
||||
|
||||
- Show the Web Sleep agenda newest first with one 7, 14, 21, 28, or All
|
||||
recorded-night selector (7 by default). Sleep reads its full available history
|
||||
without the Analysis period control; other Analysis sections retain their
|
||||
period choice. The browser-local 5/10/15 preference migrates to 7/14/21,
|
||||
and a capped full-history read fails explicitly. Page changes leave the
|
||||
selected night, editor, statistics, and inclusive PDF range untouched.
|
||||
Removed only the redundant textual sport list below the Web agenda; factual
|
||||
blue overlays, the sport legend, and the complete PDF sport appendix remain.
|
||||
|
||||
- Corrected the Dashboard/Analysis active-Program next-session projection to
|
||||
select title, identity, and current date from one pending Program session.
|
||||
Recorded rescheduling dates replace imported dates, ceded slots are excluded,
|
||||
and an overdue uncompleted session remains eligible. The read-only projection
|
||||
does not change Program history, preparation priority, or synchronization.
|
||||
|
||||
- Aligned the Web front/back BODY ZONES figures with Android's normalized
|
||||
Compose Canvas silhouettes. The Web now uses Android's head, neck and 11
|
||||
region paths per face, while retaining the existing Catppuccin intensity
|
||||
classes, 30-day session counts, legend, labels and keyboard focus behavior.
|
||||
No Android, domain, database or synchronization behavior changed.
|
||||
|
||||
- Widened the Sleep diary PDF treatment/remarks column from 84 to 180 pt
|
||||
and raised its text from 4 to 7.2 pt. Width-aware wrapping keeps paragraphs,
|
||||
separate medication intakes, accents, doses, units, and quantities visible.
|
||||
Row heights and page count now follow content; exceptional entries continue
|
||||
on clearly dated pages. The former truncated observations duplicate is gone.
|
||||
The 18:00 timeline and factual blue sport overlay use one shared PDF geometry;
|
||||
Sleep, sport, database, and synchronization data are unchanged.
|
||||
The private PC/Web rollout from `3758875` was verified with a four-page
|
||||
browser-downloaded PDF; synchronization services retained their Program paths.
|
||||
|
||||
- Added a read-only completed-session overlay to the Web Sleep agenda and its
|
||||
local vector PDF preview/export. Factual session start and end times share
|
||||
the Sleep row's 18:00 axis in both renderers; blue outlined bands and a
|
||||
printable paginated listing expose recorded names and times. Missing ends
|
||||
remain point markers. The report flags sport without a recorded Sleep row,
|
||||
and export waits for the complete paginated history read. Sleep entries,
|
||||
session history, database schema, Android, and synchronization contracts
|
||||
remain unchanged.
|
||||
The private PC/Web rollout from `e2c07f1` was verified against its served
|
||||
agenda and an actual downloaded PDF; the phone installation was untouched.
|
||||
|
||||
The isolated `trainlog-program-recalage-v1` candidate adds an explicit
|
||||
preview-and-confirm cascade for unfinished Program sessions. It retains source
|
||||
and actual dates, stores absolute current dates and ceded-slot state, and
|
||||
selects a separate Programs V2 Android projection by capability. The source
|
||||
candidate remains isolated from the stable checkout while its private desktop
|
||||
and compatible Android build are installed for the coordinated rollout.
|
||||
Rollout packaging rejects a native build without the embedded Web frontend.
|
||||
The private rollout preserves parsed V1 Program number types during Android V2
|
||||
upgrade and raises the bounded local MTP operation budget to 90 seconds and
|
||||
retained outgoing generation capacity to 16 after interrupted exchanges.
|
||||
|
||||
Development opened after the stable v0.1.6 release. This line is restricted to
|
||||
observed bug fixes, regression fixes, directly related small UI corrections,
|
||||
documentation corrections, robustness, and associated tests. It does not admit
|
||||
new features, modules, protocols, or speculative refactors.
|
||||
|
||||
- Corrected the Web Program calendar's completed-session placement using the
|
||||
linked session's actual start date in the browser's history timezone. A
|
||||
changed day displays both actual and imported dates, and concurrent cards
|
||||
remain distinct. The Program detail Web API gains a nullable read-only
|
||||
timestamp field; this changes no history, schema, Android behavior, or sync
|
||||
artifact.
|
||||
Durable ordered rescheduling remains outside the stabilized Programs V1
|
||||
exchange: its strict snapshot has no current date or ceded-slot state.
|
||||
- Fixed automatic Bluetooth synchronization recovery when RFCOMM disconnects
|
||||
after `trainlog-syncd` admits Android's arrival request but before the
|
||||
generation conversation can start. Requests whose durable terminal result
|
||||
is explicitly transient (`device_unavailable`, `transport_timeout`,
|
||||
`sync_in_progress`, or interruption) remain eligible after reconnect;
|
||||
completed requests and business failures remain consumed. Generation, ACK,
|
||||
causal, schema, MTP, Drive, and manual-sync contracts are unchanged.
|
||||
- Fixed Android generation coordination after a durable desktop rejection.
|
||||
The Android producer now retains and reports the correlated `rejected` ACK
|
||||
but immediately releases its conversation lease instead of waiting for a
|
||||
reciprocal desktop generation that the rejecting worker cannot publish.
|
||||
This prevents a rejected stale revision from starving a subsequent Web or
|
||||
automatic Bluetooth request; rejection, generation, ACK, causal, schema,
|
||||
and transport contracts are unchanged.
|
||||
- Preserved the desktop consumer's known Sleep revision rejection diagnostic
|
||||
through the full-generation worker and classified it as `data_conflict` in
|
||||
Web run state. A synthetic divergent-final-wake regression keeps the true
|
||||
conflict rejected; this reporting fix does not reconcile real Sleep data or
|
||||
turn a rejected generation into a successful exchange.
|
||||
- Resolved safely comparable concurrent Sleep V2 edits by the last actual
|
||||
user-edit instant, regardless of device or the event times being recorded.
|
||||
Complete winning snapshots and both original immutable revisions are kept;
|
||||
a local winner gains a fresh causal successor for peer convergence. Equal
|
||||
timestamps, changed entry envelopes, and deletion transitions still reject
|
||||
instead of guessing. Existing rejected generation ACKs are not rewritten.
|
||||
|
||||
## 0.1.6 — 2026-09-25
|
||||
|
||||
- Corrected delayed session finalization so a heart-rate capture cannot extend
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -145,13 +145,20 @@ cd ..
|
|||
|
||||
meson setup build-web -Dweb=enabled
|
||||
meson compile -C build-web
|
||||
meson test -C build-web --print-errorlogs
|
||||
./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.
|
||||
again. The separate `npm test` run checks Web source; the enabled Meson build
|
||||
registers embedded frontend tests and places its assets in the executable.
|
||||
The default `web=auto` configuration does not by itself prove embedded assets.
|
||||
For a compatible service bundle, use the versioned candidate packaging
|
||||
procedure in [rollout readiness](docs/sync_rollout_readiness.md); copying only
|
||||
`trainlog` omits its matching synchronization helpers. Restarting a service
|
||||
does not change the package selected by its launcher.
|
||||
|
||||
### Android
|
||||
|
||||
|
|
@ -231,7 +238,7 @@ chronological detail in the [`CHANGELOG`](CHANGELOG.md).
|
|||
|
||||
| Boundary | Current state |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Product | `0.1.6` stable |
|
||||
| Product | `0.1.7` bugfix-only development; latest stable release `v0.1.6` |
|
||||
| Desktop persistence | SQLite schema v36 |
|
||||
| Android persistence | SQLite schema v33 |
|
||||
| Mobile exchange | V3 active; V1/V2 readable; V4 staged only |
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ ANDROID_BODY_SHELL=IMPLEMENTED
|
|||
LOCAL_PERSISTENCE=IMPLEMENTED
|
||||
DIRECT_STORAGE=IMPLEMENTED
|
||||
MTP_SYNC=IMPLEMENTED
|
||||
ANDROID_SCHEMA=33
|
||||
ANDROID_SCHEMA=34
|
||||
```
|
||||
|
||||
## Visual contract
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ val trainlogVersionCode =
|
|||
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
||||
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
||||
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||
} ?: 7
|
||||
} ?: 8
|
||||
|
||||
/*
|
||||
* WHY: a user may need to finish one preserved draft after a completion bug
|
||||
|
|
@ -87,7 +87,7 @@ android {
|
|||
targetSdk = 36
|
||||
|
||||
versionCode = trainlogVersionCode
|
||||
versionName = "0.1.6"
|
||||
versionName = "0.1.7"
|
||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
|
||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
|
||||
testInstrumentationRunner =
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..33
|
||||
manifest.getInt("schema") in 17..34
|
||||
) {
|
||||
"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..33) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..34) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -370,6 +370,7 @@ internal class SyncGenerationCoordinator(
|
|||
ACK_RECOVERY_CAPABILITY,
|
||||
"session-preparations-v2",
|
||||
"programs-v1",
|
||||
"programs-v2",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
@ -606,6 +607,18 @@ internal class SyncGenerationCoordinator(
|
|||
}, pollTransport, lease)
|
||||
service.acceptAcknowledgement(desktopAck)
|
||||
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
|
||||
/* WHY: a durable desktop rejection closes this Android-produced
|
||||
* generation, so the desktop worker deliberately cannot publish a
|
||||
* reciprocal generation for this conversation. CONTRACT: retain
|
||||
* and validate the rejection ACK, then end this run immediately.
|
||||
* INVARIANT: a rejected generation never holds the process-wide
|
||||
* conversation lease while waiting for an impossible reference. */
|
||||
val desktopAcknowledgement = JSONObject(desktopAck.toString(Charsets.UTF_8))
|
||||
if (desktopAcknowledgement.getString("result") == "rejected") {
|
||||
val diagnostic = desktopAcknowledgement.getString("diagnostic")
|
||||
phase(runId, "desktop_rejected", "desktop-consumption-ack-v1.json", captured.generationId)
|
||||
return ForegroundGenerationResult.Failed(diagnostic, captured.runId)
|
||||
}
|
||||
val referenceRaw =
|
||||
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
|
||||
it.optString("run_id") == captured.runId
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
(ANDROID_KINDS.values +
|
||||
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
|
||||
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) +
|
||||
Kind("trainlog-programs", 1, "programs-v1.json", false))
|
||||
Kind("trainlog-programs", 1, "programs-v1.json", false) +
|
||||
Kind("trainlog-programs", 2, "programs-v2.json", false))
|
||||
.map { it.format to it.version }
|
||||
.toSet()
|
||||
|
||||
|
|
@ -773,6 +774,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
throw SyncGenerationException("artifact digest mismatch")
|
||||
artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8)
|
||||
}
|
||||
if ("programs-v1" in artifacts && "programs-v2" in artifacts)
|
||||
throw SyncGenerationException("multiple Program revisions in one generation")
|
||||
return ValidatedGeneration(manifest, sha256(raw), artifacts)
|
||||
}
|
||||
|
||||
|
|
@ -947,6 +950,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
apply("programs-v1") {
|
||||
repository.applyProgramsV1Json(checkNotNull(a["programs-v1"]))
|
||||
}
|
||||
if ("programs-v2" in a)
|
||||
apply("programs-v2") {
|
||||
repository.applyProgramsV2Json(checkNotNull(a["programs-v2"]))
|
||||
}
|
||||
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
|
||||
apply("exercise-profile-state") {
|
||||
repository.applyExerciseProfileStateJson(
|
||||
|
|
|
|||
|
|
@ -676,8 +676,9 @@ class TrainlogRepository(
|
|||
}
|
||||
} catch (_: RuntimeException) { false }
|
||||
|
||||
/** CONTRACT: one transaction appends an immutable revision and advances
|
||||
* the guarded tip. Wall time never resolves a concurrent edit. */
|
||||
/** CONTRACT: one local edit transaction appends an immutable revision and
|
||||
* advances the guarded tip. Cross-device conflict selection belongs only
|
||||
* to the versioned import path after both edit instants are validated. */
|
||||
fun saveSleepDiary(draft: SleepDiaryDraft): SaveSleepDiaryResult =
|
||||
saveSleepDiary(draft, quickPublicationAt = null)
|
||||
|
||||
|
|
@ -1555,8 +1556,21 @@ class TrainlogRepository(
|
|||
}
|
||||
val remoteDescendsFromLocal =
|
||||
local != null && ancestry?.drop(1)?.contains(local.first) == true
|
||||
if (local != null && !remoteDescendsFromLocal && parent != local.first)
|
||||
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
|
||||
var winningLocalRevision: String? = null
|
||||
if (local != null && !remoteDescendsFromLocal && parent != local.first) {
|
||||
val localWins = sleepConcurrentLocalWins(
|
||||
db, item, entryId, local.first, wireVersion,
|
||||
)
|
||||
if (localWins) {
|
||||
if (knownOwner != null) {
|
||||
// A retained losing branch must not generate another
|
||||
// reconciliation revision when its artifact replays.
|
||||
unchanged++
|
||||
continue
|
||||
}
|
||||
winningLocalRevision = local.first
|
||||
}
|
||||
}
|
||||
/* The snapshot may start at a non-root current revision on a
|
||||
* fresh peer. Preserve its causal parent identity even though
|
||||
* the historical parent payload is intentionally absent. */
|
||||
|
|
@ -1606,21 +1620,77 @@ class TrainlogRepository(
|
|||
arrayOf(entryId,draft.nightStartDate,draft.nightEndDate,draft.createdAt,draft.updatedAt,revisionId,if (deleted) 1 else 0))
|
||||
else db.execSQL("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
|
||||
arrayOf(draft.nightStartDate,draft.nightEndDate,draft.updatedAt,revisionId,if (deleted) 1 else 0,entryId,local.first))
|
||||
db.execSQL("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
|
||||
arrayOf(revisionId,entryId,parent,draft.updatedAt,draft.sleepQuality?.wireValue,draft.wakeQuality?.wireValue,draft.dayForm?.wireValue,draft.treatmentAndNotes))
|
||||
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(" +
|
||||
"revision_id,intake_id,medication_id,medication_name,taken_at," +
|
||||
"dose_value,dose_unit,quantity,note,created_at" +
|
||||
") VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
arrayOf<Any?>(revisionId, intake.intakeId, intake.medicationId,
|
||||
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
|
||||
intake.quantity,
|
||||
intake.note, intake.createdAt),
|
||||
) }
|
||||
if (knownOwner == null) {
|
||||
db.execSQL(
|
||||
"INSERT INTO sleep_diary_revisions(revision_id,entry_id," +
|
||||
"parent_revision_id,created_at,sleep_quality,wake_quality,day_form," +
|
||||
"treatment_and_notes) VALUES(?,?,?,?,?,?,?,?)",
|
||||
arrayOf(
|
||||
revisionId, entryId, parent, draft.updatedAt,
|
||||
draft.sleepQuality?.wireValue, draft.wakeQuality?.wireValue,
|
||||
draft.dayForm?.wireValue, draft.treatmentAndNotes,
|
||||
),
|
||||
)
|
||||
draft.events.forEach { event ->
|
||||
db.execSQL(
|
||||
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type," +
|
||||
"start_at,end_at) VALUES(?,?,?,?,?)",
|
||||
arrayOf(revisionId, event.eventId, event.type.wireValue,
|
||||
event.startAt, event.endAt),
|
||||
)
|
||||
}
|
||||
draft.intakes.forEach { intake -> db.execSQL(
|
||||
"INSERT INTO sleep_medication_intakes(" +
|
||||
"revision_id,intake_id,medication_id,medication_name,taken_at," +
|
||||
"dose_value,dose_unit,quantity,note,created_at" +
|
||||
") VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
arrayOf<Any?>(revisionId, intake.intakeId, intake.medicationId,
|
||||
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
|
||||
intake.quantity, intake.note, intake.createdAt),
|
||||
) }
|
||||
}
|
||||
markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at"))
|
||||
if (winningLocalRevision != null) {
|
||||
val successor = sleepId("slr")
|
||||
// INVARIANT: this technical successor inherits the latest
|
||||
// user's edit timestamp and exact snapshot, not the import
|
||||
// time. Its remote parent makes the peer converge, while
|
||||
// both original sibling revisions stay immutable.
|
||||
db.execSQL(
|
||||
"INSERT INTO sleep_diary_revisions(revision_id,entry_id," +
|
||||
"parent_revision_id,created_at,sleep_quality,wake_quality,day_form," +
|
||||
"treatment_and_notes) " +
|
||||
"SELECT ?,entry_id,?,created_at,sleep_quality,wake_quality,day_form," +
|
||||
"treatment_and_notes FROM sleep_diary_revisions WHERE revision_id=?",
|
||||
arrayOf(successor, revisionId, winningLocalRevision),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type," +
|
||||
"start_at,end_at) " +
|
||||
"SELECT ?,event_id,event_type,start_at,end_at FROM sleep_diary_events " +
|
||||
"WHERE revision_id=?",
|
||||
arrayOf(successor, winningLocalRevision),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id," +
|
||||
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) " +
|
||||
"SELECT ?,intake_id,medication_id,medication_name,taken_at,dose_value," +
|
||||
"dose_unit,quantity,note,created_at FROM sleep_medication_intakes " +
|
||||
"WHERE revision_id=?",
|
||||
arrayOf(successor, winningLocalRevision),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE sleep_diary_entries SET updated_at=(SELECT created_at FROM " +
|
||||
"sleep_diary_revisions WHERE revision_id=?),current_revision_id=? " +
|
||||
"WHERE entry_id=? AND current_revision_id=?",
|
||||
arrayOf(winningLocalRevision, successor, entryId, revisionId),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE sleep_diary_publication_state SET validated_revision_id=?," +
|
||||
"validated_at=? WHERE entry_id=?",
|
||||
arrayOf(successor, root.getString("generated_at"), entryId),
|
||||
)
|
||||
}
|
||||
advanced++
|
||||
}
|
||||
SleepDiaryImportResult.Applied(advanced, unchanged)
|
||||
|
|
@ -1629,6 +1699,40 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
private fun sleepConcurrentLocalWins(
|
||||
db: SQLiteDatabase,
|
||||
item: JSONObject,
|
||||
entryId: String,
|
||||
localRevision: String,
|
||||
wireVersion: Int,
|
||||
): Boolean {
|
||||
// WHY: generation/ACK timestamps are transport events. Only revision
|
||||
// creation times reflect editing actions on the two clients.
|
||||
// CONTRACT: V2 sibling tips with an unchanged entry envelope and
|
||||
// deletion state may use last-user-edit-wins. An equal edit instant
|
||||
// or inconsistent provenance remains an explicit conflict.
|
||||
if (wireVersion != 2) throw IllegalArgumentException("concurrent sleep diary revision")
|
||||
val local = db.rawQuery(
|
||||
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," +
|
||||
"r.created_at FROM sleep_diary_entries e JOIN sleep_diary_revisions r " +
|
||||
"ON r.revision_id=e.current_revision_id WHERE e.entry_id=? AND r.revision_id=?",
|
||||
arrayOf(entryId, localRevision),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else List(6) { index -> cursor.getString(index) }
|
||||
} ?: throw IllegalArgumentException("concurrent sleep diary revision")
|
||||
if (local[0] != item.getString("night_start_date") ||
|
||||
local[1] != item.getString("night_end_date") ||
|
||||
local[2] != item.getString("created_at") ||
|
||||
(local[4] == "1") != item.getBoolean("deleted") || local[3] != local[5]
|
||||
) throw IllegalArgumentException("concurrent sleep diary revision")
|
||||
val localAt = OffsetDateTime.parse(local[5]).toInstant()
|
||||
val remoteAt = OffsetDateTime.parse(item.getString("updated_at")).toInstant()
|
||||
if (localAt == remoteAt) throw IllegalArgumentException(
|
||||
"concurrent sleep diary revision has indeterminate edit order",
|
||||
)
|
||||
return localAt > remoteAt
|
||||
}
|
||||
|
||||
private fun sleepDiaryRevisionMatches(
|
||||
db: SQLiteDatabase,
|
||||
item: JSONObject,
|
||||
|
|
@ -11721,7 +11825,39 @@ class TrainlogRepository(
|
|||
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
|
||||
* wins over a later stale snapshot and never touches drafts, preparations, history or catalog.
|
||||
*/
|
||||
fun applyProgramsV1Json(json: String): ProgramsImportResult {
|
||||
fun applyProgramsV1Json(json: String): ProgramsImportResult = applyProgramsJson(json, 1)
|
||||
|
||||
fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2)
|
||||
|
||||
private fun legacyProgramDigest(program: JSONObject): String {
|
||||
// WHY: serializing and reparsing JSONObject changes integral doubles (for
|
||||
// example 52.0 to 52), which changes a V1 revision's causal digest.
|
||||
// CONTRACT: discard only V2 fields while retaining the parsed V1 values.
|
||||
val legacy = JSONObject()
|
||||
program.keys().forEach { key ->
|
||||
when (key) {
|
||||
"revision_sequence" -> Unit
|
||||
"sessions" -> {
|
||||
val rows = JSONArray()
|
||||
val sessions = program.getJSONArray("sessions")
|
||||
for (index in 0 until sessions.length()) {
|
||||
val source = sessions.getJSONObject(index)
|
||||
val row = JSONObject()
|
||||
source.keys().forEach { sessionKey ->
|
||||
if (sessionKey !in setOf("position", "current_for", "planning_state"))
|
||||
row.put(sessionKey, source.get(sessionKey))
|
||||
}
|
||||
rows.put(row)
|
||||
}
|
||||
legacy.put(key, rows)
|
||||
}
|
||||
else -> legacy.put(key, program.get(key))
|
||||
}
|
||||
}
|
||||
return causalDigest(legacy)
|
||||
}
|
||||
|
||||
private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult {
|
||||
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
|
||||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
|
||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||
|
|
@ -11749,7 +11885,7 @@ class TrainlogRepository(
|
|||
try {
|
||||
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
|
||||
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
|
||||
root.getInt("version") != 1 || !timestamp(root.opt("generated_at")) ||
|
||||
root.getInt("version") != documentVersion || !timestamp(root.opt("generated_at")) ||
|
||||
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
|
||||
throw ProgramsImportInvalid("Unsupported Programs document.")
|
||||
val programs = root.getJSONArray("programs")
|
||||
|
|
@ -11776,11 +11912,16 @@ class TrainlogRepository(
|
|||
for (programIndex in 0 until programs.length()) {
|
||||
val program = programs.optJSONObject(programIndex)
|
||||
?: throw ProgramsImportInvalid("Invalid program.")
|
||||
if (!program.exact(
|
||||
val programKeysValid = if (documentVersion == 2) program.exact(
|
||||
"program_id", "revision_id", "revision_sequence", "title", "note", "state", "start_date",
|
||||
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
||||
"source_payload_sha256", "sessions",
|
||||
) else program.exact(
|
||||
"program_id", "revision_id", "title", "note", "state", "start_date",
|
||||
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
||||
"source_payload_sha256", "sessions",
|
||||
) ||
|
||||
)
|
||||
if (!programKeysValid ||
|
||||
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
|
||||
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
|
||||
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
|
||||
|
|
@ -11790,17 +11931,33 @@ class TrainlogRepository(
|
|||
(program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
|
||||
program.opt("sessions") !is JSONArray)
|
||||
throw ProgramsImportInvalid("Invalid program metadata.")
|
||||
if (documentVersion == 2 && !integer(program.opt("revision_sequence"), 0, Int.MAX_VALUE))
|
||||
throw ProgramsImportInvalid("Invalid Program revision sequence.")
|
||||
val sessions = program.getJSONArray("sessions")
|
||||
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
|
||||
for (sessionIndex in 0 until sessions.length()) {
|
||||
val session = sessions.optJSONObject(sessionIndex)
|
||||
?: throw ProgramsImportInvalid("Invalid program session.")
|
||||
if (!session.exact("program_session_id", "title", "session_type", "planned_for", "note", "occurrences") ||
|
||||
val validKeys = if (documentVersion == 2) session.exact(
|
||||
"program_session_id", "position", "title", "session_type", "planned_for",
|
||||
"current_for", "planning_state", "note", "occurrences",
|
||||
) else session.exact(
|
||||
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences",
|
||||
)
|
||||
if (!validKeys ||
|
||||
!bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) ||
|
||||
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
|
||||
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
|
||||
session.opt("occurrences") !is JSONArray)
|
||||
throw ProgramsImportInvalid("Invalid program session metadata.")
|
||||
if (documentVersion == 2 &&
|
||||
(!integer(session.opt("position"), 0, 255) || session.getInt("position") != sessionIndex ||
|
||||
!date(session.opt("current_for")) ||
|
||||
session.optString("planning_state") !in setOf("active", "ceded") ||
|
||||
(session.optString("planning_state") == "ceded" && !session.isNull("current_for")) ||
|
||||
(session.optString("planning_state") == "active" &&
|
||||
session.isNull("current_for") && !session.isNull("planned_for"))))
|
||||
throw ProgramsImportInvalid("Invalid Program planning state.")
|
||||
val occurrences = session.getJSONArray("occurrences")
|
||||
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
|
||||
val entryIds = mutableSetOf<String>()
|
||||
|
|
@ -11826,6 +11983,12 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
data class ExistingProjection(
|
||||
val revisionId: String,
|
||||
val digest: String,
|
||||
val version: Int,
|
||||
val sequence: Long,
|
||||
)
|
||||
val db = database.writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
|
|
@ -11858,28 +12021,76 @@ class TrainlogRepository(
|
|||
.use { it.moveToFirst() }
|
||||
if (tombstoned) { skipped++; continue }
|
||||
val digest = causalDigest(program)
|
||||
val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id))
|
||||
.use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) }
|
||||
if (existing?.first == program.getString("revision_id")) {
|
||||
if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.")
|
||||
skipped++
|
||||
continue
|
||||
val existing = db.rawQuery(
|
||||
"SELECT revision_id,snapshot_sha256,projection_version,revision_sequence " +
|
||||
"FROM synced_programs WHERE program_id=?",
|
||||
arrayOf(id),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else ExistingProjection(
|
||||
cursor.getString(0), cursor.getString(1), cursor.getInt(2), cursor.getLong(3),
|
||||
)
|
||||
}
|
||||
val sequence = if (documentVersion == 2) program.getLong("revision_sequence") else 0L
|
||||
// WHY: a later V1 artifact cannot erase current dates or a
|
||||
// ceded slot after this phone has accepted Programs V2.
|
||||
if (existing != null && documentVersion < existing.version)
|
||||
throw ProgramsImportInvalid("Programs V2 capability required.")
|
||||
if (existing != null && documentVersion == 2 &&
|
||||
(sequence < existing.sequence ||
|
||||
(sequence == existing.sequence && existing.version == 2 &&
|
||||
existing.revisionId != program.getString("revision_id"))))
|
||||
throw ProgramsImportInvalid("Stale Program revision sequence.")
|
||||
if (existing?.revisionId == program.getString("revision_id")) {
|
||||
if (existing.digest == digest) { skipped++; continue }
|
||||
if (documentVersion != 2 || existing.version != 1 ||
|
||||
existing.digest != legacyProgramDigest(program))
|
||||
throw ProgramsImportInvalid("Program revision identity conflict.")
|
||||
}
|
||||
if (documentVersion == 2) {
|
||||
val activeProgramSession = db.rawQuery(
|
||||
"SELECT source_program_session_id FROM active_session_draft " +
|
||||
"WHERE id=1 AND source_program_id=?",
|
||||
arrayOf(id),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
if (activeProgramSession != null) {
|
||||
val newSessions = program.getJSONArray("sessions")
|
||||
for (sessionIndex in 0 until newSessions.length()) {
|
||||
val candidate = newSessions.getJSONObject(sessionIndex)
|
||||
if (candidate.getString("program_session_id") == activeProgramSession) {
|
||||
val existingPlan = db.rawQuery(
|
||||
"SELECT current_for,planning_state FROM synced_program_sessions " +
|
||||
"WHERE program_session_id=?",
|
||||
arrayOf(activeProgramSession),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else
|
||||
(if (cursor.isNull(0)) null else cursor.getString(0)) to cursor.getString(1)
|
||||
}
|
||||
if (existingPlan != null &&
|
||||
(existingPlan.first != candidate.optStringOrNull("current_for") ||
|
||||
existingPlan.second != candidate.getString("planning_state")))
|
||||
throw ProgramsImportInvalid("Active Program draft blocks reschedule.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
db.delete("synced_programs", "program_id=?", arrayOf(id))
|
||||
db.execSQL(
|
||||
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"),
|
||||
program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
|
||||
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
|
||||
program.getInt("source_version"), program.getString("source_payload_sha256"), digest),
|
||||
program.getInt("source_version"), program.getString("source_payload_sha256"), digest,
|
||||
documentVersion, sequence),
|
||||
)
|
||||
val sessions = program.getJSONArray("sessions")
|
||||
for (sessionIndex in 0 until sessions.length()) {
|
||||
val session = sessions.getJSONObject(sessionIndex)
|
||||
val sessionId = session.getString("program_session_id")
|
||||
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?)", arrayOf<Any?>(
|
||||
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?,?,?)", arrayOf<Any?>(
|
||||
sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"),
|
||||
session.optStringOrNull("planned_for"), session.optStringOrNull("note")))
|
||||
session.optStringOrNull("planned_for"), session.optStringOrNull("note"),
|
||||
if (documentVersion == 2) session.optStringOrNull("current_for") else session.optStringOrNull("planned_for"),
|
||||
if (documentVersion == 2) session.getString("planning_state") else "active"))
|
||||
val entries = session.getJSONArray("occurrences")
|
||||
for (entryIndex in 0 until entries.length()) {
|
||||
val entry = entries.getJSONObject(entryIndex)
|
||||
|
|
@ -11948,7 +12159,7 @@ class TrainlogRepository(
|
|||
} ?: return null
|
||||
val sessions = mutableListOf<SyncedProgramSession>()
|
||||
db.rawQuery(
|
||||
"SELECT program_session_id,title,session_type,planned_for,note " +
|
||||
"SELECT program_session_id,title,session_type,planned_for,note,current_for,planning_state " +
|
||||
"FROM synced_program_sessions WHERE program_id=? " +
|
||||
"ORDER BY position,program_session_id",
|
||||
arrayOf(programId),
|
||||
|
|
@ -11991,6 +12202,8 @@ class TrainlogRepository(
|
|||
sessionCursor.getString(1),
|
||||
sessionCursor.getString(2),
|
||||
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
|
||||
if (sessionCursor.isNull(5)) null else sessionCursor.getString(5),
|
||||
sessionCursor.getString(6),
|
||||
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
|
||||
execution.first,
|
||||
execution.second,
|
||||
|
|
@ -12056,7 +12269,8 @@ class TrainlogRepository(
|
|||
val available = db.rawQuery(
|
||||
"SELECT s.session_type FROM synced_program_sessions s " +
|
||||
"JOIN synced_programs p ON p.program_id=s.program_id " +
|
||||
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active'",
|
||||
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active' " +
|
||||
"AND s.planning_state='active'",
|
||||
arrayOf(programId, programSessionId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
?: return StartProgramSessionResult.NotAvailable
|
||||
|
|
@ -12350,7 +12564,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
33,
|
||||
34,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -12704,6 +12918,34 @@ private class TrainlogDatabaseHelper(
|
|||
createSleepImmutabilityTriggers(db)
|
||||
version = 33
|
||||
}
|
||||
if (version < 34 && newVersion >= 34) {
|
||||
/* WHY: V1 retains the source date; V2 needs an independent current
|
||||
* target and a durable ceded-slot state. INVARIANT: no local draft
|
||||
* or completed workout is rewritten by the projection migration. */
|
||||
if (!tableHasColumn(db, "synced_programs", "projection_version")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE synced_programs ADD COLUMN projection_version INTEGER NOT NULL " +
|
||||
"DEFAULT 1 CHECK(projection_version IN(1,2))",
|
||||
)
|
||||
}
|
||||
if (!tableHasColumn(db, "synced_programs", "revision_sequence")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE synced_programs ADD COLUMN revision_sequence INTEGER NOT NULL " +
|
||||
"DEFAULT 0 CHECK(revision_sequence>=0)",
|
||||
)
|
||||
}
|
||||
if (!tableHasColumn(db, "synced_program_sessions", "current_for")) {
|
||||
db.execSQL("ALTER TABLE synced_program_sessions ADD COLUMN current_for TEXT")
|
||||
db.execSQL("UPDATE synced_program_sessions SET current_for=planned_for")
|
||||
}
|
||||
if (!tableHasColumn(db, "synced_program_sessions", "planning_state")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE synced_program_sessions ADD COLUMN planning_state TEXT NOT NULL " +
|
||||
"DEFAULT 'active' CHECK(planning_state IN('active','ceded'))",
|
||||
)
|
||||
}
|
||||
version = 34
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
|
|
@ -12720,7 +12962,9 @@ private class TrainlogDatabaseHelper(
|
|||
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
|
||||
start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
source_format TEXT NOT NULL, source_version INTEGER NOT NULL,
|
||||
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL
|
||||
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL,
|
||||
projection_version INTEGER NOT NULL DEFAULT 1 CHECK(projection_version IN(1,2)),
|
||||
revision_sequence INTEGER NOT NULL DEFAULT 0 CHECK(revision_sequence>=0)
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
|
|
@ -12729,7 +12973,9 @@ private class TrainlogDatabaseHelper(
|
|||
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
|
||||
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
|
||||
planned_for TEXT, note TEXT, UNIQUE(program_id,position)
|
||||
planned_for TEXT, note TEXT, current_for TEXT,
|
||||
planning_state TEXT NOT NULL DEFAULT 'active' CHECK(planning_state IN('active','ceded')),
|
||||
UNIQUE(program_id,position)
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ data class SyncedProgramSession(
|
|||
val title: String,
|
||||
val sessionType: String,
|
||||
val plannedFor: String?,
|
||||
val currentFor: String?,
|
||||
val planningState: String,
|
||||
val note: String?,
|
||||
val executionState: ProgramSessionExecutionState,
|
||||
val executionSessionId: String?,
|
||||
|
|
|
|||
|
|
@ -118,7 +118,12 @@ fun ProgramDetailScreen(
|
|||
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
|
||||
program.note?.let { TrainlogInfo(it) }
|
||||
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
|
||||
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
|
||||
val ceded = program.sessions.count { it.planningState == "ceded" }
|
||||
TrainlogInfo(
|
||||
"$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}" +
|
||||
if (ceded > 0) " · $ceded créneau(x) cédé(s)" else "",
|
||||
if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted,
|
||||
)
|
||||
}
|
||||
message?.let { TrainlogInfo(it) }
|
||||
program.sessions.forEachIndexed { index, session ->
|
||||
|
|
@ -145,12 +150,16 @@ fun ProgramDetailScreen(
|
|||
color = LocalTrainlogColors.current.text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
ProgramStateBadge(strings.getString(stateLabel), completed)
|
||||
ProgramStateBadge(
|
||||
if (session.planningState == "ceded" && !completed) "Créneau cédé"
|
||||
else strings.getString(stateLabel),
|
||||
completed,
|
||||
)
|
||||
}
|
||||
TrainlogInfo(
|
||||
listOfNotNull(
|
||||
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
|
||||
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||
session.currentFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
|
||||
).joinToString(" · "),
|
||||
)
|
||||
|
|
@ -164,7 +173,7 @@ fun ProgramDetailScreen(
|
|||
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
|
||||
)
|
||||
when (session.executionState) {
|
||||
ProgramSessionExecutionState.TODO -> TrainlogButton(
|
||||
ProgramSessionExecutionState.TODO -> if (session.planningState == "active") TrainlogButton(
|
||||
strings.getString(R.string.program_session_start),
|
||||
{ start(session.programSessionId) },
|
||||
Modifier.testTag("program-session-start-${session.programSessionId}"),
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(34, 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"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(34, 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") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
|||
reopened.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(34, 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 ->
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(33, it.version) }
|
||||
).use { assertEquals(34, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(33, it.version) }
|
||||
).use { assertEquals(34, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(33, db.version)
|
||||
assertEquals(34, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(33, db.version)
|
||||
assertEquals(34, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT session_type,session_kind FROM sessions",
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(33, db.version)
|
||||
assertEquals(34, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM exercises WHERE " +
|
||||
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(33, db.version)
|
||||
assertEquals(34, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
||||
"'cardio_guidance_runs','cardio_guidance_phases'," +
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest {
|
|||
migrated.listSleepDiary()
|
||||
migrated.close()
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
1,
|
||||
scalar(
|
||||
|
|
|
|||
|
|
@ -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(33, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(34, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -70,6 +70,91 @@ class ProgramsProjectionRepositoryTest {
|
|||
.put("deletions", deletions)
|
||||
.toString()
|
||||
|
||||
private fun artifactV2(
|
||||
program: JSONObject,
|
||||
sequence: Int = 0,
|
||||
currentFor: String = "2026-10-01",
|
||||
): String {
|
||||
program.put("revision_sequence", sequence)
|
||||
val session = program.getJSONArray("sessions").getJSONObject(0)
|
||||
session.put("position", 0)
|
||||
session.put("current_for", currentFor)
|
||||
session.put("planning_state", "active")
|
||||
return JSONObject()
|
||||
.put("format", "trainlog-programs")
|
||||
.put("version", 2)
|
||||
.put("generated_at", "2026-09-18T12:00:00Z")
|
||||
.put("programs", JSONArray().put(program))
|
||||
.put("deletions", JSONArray())
|
||||
.toString()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v2UpgradePreservesCurrentDateAndRejectsLegacyDowngrade() {
|
||||
val name = "program-v2-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
|
||||
)
|
||||
val upgraded = program()
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV2Json(artifactV2(upgraded)),
|
||||
)
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
val stale = artifactV2(program())
|
||||
val newer = program().put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV2Json(artifactV2(newer, 1, "2026-10-02")),
|
||||
)
|
||||
assertTrue(repository.applyProgramsV2Json(stale) is ProgramsImportResult.Invalid)
|
||||
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
assertTrue(repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
|
||||
is ProgramsImportResult.Invalid)
|
||||
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertEquals("2026-10-02", repository.getSyncedProgram(program().getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v2UpgradeKeepsV1DigestForIntegralDecimalWeights() {
|
||||
val name = "program-decimal-upgrade-${UUID.randomUUID()}.db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
// The desktop JSON exporter writes SQLite REAL values as 40.0.
|
||||
// JSONObject's own serialization writes the same value as 40.
|
||||
val v1 = artifact(JSONArray().put(program()))
|
||||
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
|
||||
val v2 = artifactV2(program())
|
||||
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
|
||||
assertTrue(v1.contains("\"target_weight_kg\":40.0"))
|
||||
assertTrue(v2.contains("\"target_weight_kg\":40.0"))
|
||||
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV1Json(v1))
|
||||
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV2Json(v2))
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(program().getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV2Json(v2))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun preparationArtifact(
|
||||
includeProgramProvenance: Boolean = true,
|
||||
deliveryState: String = "pending",
|
||||
|
|
@ -211,6 +296,12 @@ class ProgramsProjectionRepositoryTest {
|
|||
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(programId, active.sourceProgramId)
|
||||
assertEquals(programSessionId, active.sourceProgramSessionId)
|
||||
val changedPlan = program().apply {
|
||||
put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
|
||||
}
|
||||
assertTrue(repository.applyProgramsV2Json(artifactV2(changedPlan))
|
||||
is ProgramsImportResult.Invalid)
|
||||
assertEquals(null, repository.getSyncedProgram(programId)!!.sessions.single().currentFor)
|
||||
val performed = active.copy(
|
||||
exercises = active.exercises.map { occurrence ->
|
||||
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
|
||||
|
|
@ -218,6 +309,12 @@ class ProgramsProjectionRepositoryTest {
|
|||
)
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV2Json(artifactV2(changedPlan)),
|
||||
)
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(programId)!!
|
||||
.sessions.single().currentFor)
|
||||
assertEquals(
|
||||
ProgramSessionExecutionState.COMPLETED,
|
||||
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
|
||||
|
|
@ -238,6 +335,8 @@ class ProgramsProjectionRepositoryTest {
|
|||
try {
|
||||
val detail = repository.listSyncedPrograms().single()
|
||||
assertEquals(1, detail.sessionCount)
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(detail.programId)!!
|
||||
.sessions.single().currentFor)
|
||||
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
||||
assertEquals(1, repository.listSessions().size)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
|||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(1, repository.listPreparedSessions().size)
|
||||
repository.close()
|
||||
assertEquals("33", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals("34", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
"0",
|
||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||
|
|
|
|||
|
|
@ -13,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.assertNotEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
|
@ -25,6 +26,147 @@ import org.robolectric.annotation.Config
|
|||
class SleepDiaryRepositoryTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun concurrentV2SleepEditsChooseLatestUserEditAndConverge() {
|
||||
for (localNewer in listOf(true, false)) {
|
||||
val sourceName = "sleep-lww-source-${UUID.randomUUID()}.db"
|
||||
val destinationName = "sleep-lww-destination-${UUID.randomUUID()}.db"
|
||||
val source = TrainlogRepository(context, sourceName)
|
||||
val destination = TrainlogRepository(context, destinationName)
|
||||
try {
|
||||
val initial = source.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
nightStartDate = "2026-10-24",
|
||||
nightEndDate = "2026-10-25",
|
||||
createdAt = "2026-10-24T22:00:00+02:00",
|
||||
updatedAt = "2026-10-24T22:00:00+02:00",
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "",
|
||||
events = listOf(
|
||||
SleepDiaryEvent(
|
||||
"", SleepEventType.SLEEP,
|
||||
"2026-10-24T23:00:00+02:00",
|
||||
"2026-10-25T07:00:00+01:00",
|
||||
),
|
||||
),
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
assertTrue(source.validateSleepDiary(
|
||||
initial.entryId,
|
||||
initial.revisionId,
|
||||
"2026-10-25T08:00:00+01:00",
|
||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved)
|
||||
assertEquals(
|
||||
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
|
||||
destination.applySleepDiaryV1Json(source.buildSleepDiaryV1Json()),
|
||||
)
|
||||
|
||||
fun edit(repository: TrainlogRepository, at: String, wakeAt: String): String {
|
||||
val current = repository.listSleepDiary().single()
|
||||
val saved = repository.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
entryId = current.entryId,
|
||||
expectedRevision = current.revisionId,
|
||||
nightStartDate = current.nightStartDate,
|
||||
nightEndDate = current.nightEndDate,
|
||||
createdAt = current.createdAt,
|
||||
updatedAt = at,
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "",
|
||||
events = current.events.map { it.copy(endAt = wakeAt) },
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
assertTrue(repository.validateSleepDiary(
|
||||
current.entryId, saved.revisionId, at,
|
||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved)
|
||||
return saved.revisionId
|
||||
}
|
||||
|
||||
val localAt = if (localNewer) "2026-10-25T10:00:00+01:00"
|
||||
else "2026-10-25T09:00:00+01:00"
|
||||
val remoteAt = if (localNewer) "2026-10-25T09:00:00+01:00"
|
||||
else "2026-10-25T10:00:00+01:00"
|
||||
val localRevision = edit(destination, localAt, "2026-10-25T06:00:00+01:00")
|
||||
val remoteRevision = edit(source, remoteAt, "2026-10-25T07:30:00+01:00")
|
||||
val remoteSnapshot = source.buildSleepDiaryV1Json()
|
||||
val tiedSnapshot = org.json.JSONObject(remoteSnapshot)
|
||||
val tiedEntry = tiedSnapshot.getJSONArray("entries").getJSONObject(0)
|
||||
val tiedRevision = "slr_${UUID.randomUUID()}"
|
||||
tiedEntry.put("revision_id", tiedRevision)
|
||||
tiedEntry.put("updated_at", localAt)
|
||||
tiedEntry.put(
|
||||
"ancestry",
|
||||
org.json.JSONArray().put(tiedRevision).put(initial.revisionId),
|
||||
)
|
||||
assertEquals(
|
||||
TrainlogRepository.SleepDiaryImportResult.Rejected(
|
||||
"concurrent sleep diary revision has indeterminate edit order",
|
||||
),
|
||||
destination.applySleepDiaryV1Json(tiedSnapshot.toString()),
|
||||
)
|
||||
assertEquals(localRevision, destination.listSleepDiary().single().revisionId)
|
||||
assertEquals(
|
||||
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
|
||||
destination.applySleepDiaryV1Json(remoteSnapshot),
|
||||
)
|
||||
val winner = destination.listSleepDiary().single()
|
||||
assertEquals(
|
||||
if (localNewer) "2026-10-25T06:00:00+01:00"
|
||||
else "2026-10-25T07:30:00+01:00",
|
||||
winner.events.single().endAt,
|
||||
)
|
||||
assertEquals(
|
||||
TrainlogRepository.SleepDiaryImportResult.Applied(0, 1),
|
||||
destination.applySleepDiaryV1Json(remoteSnapshot),
|
||||
)
|
||||
val reconciled = destination.buildSleepDiaryV1Json()
|
||||
if (localNewer) {
|
||||
assertNotEquals(localRevision, winner.revisionId)
|
||||
assertEquals(
|
||||
remoteRevision,
|
||||
org.json.JSONObject(reconciled).getJSONArray("entries")
|
||||
.getJSONObject(0).getString("parent_revision_id"),
|
||||
)
|
||||
} else {
|
||||
assertEquals(remoteRevision, winner.revisionId)
|
||||
}
|
||||
assertEquals(
|
||||
TrainlogRepository.SleepDiaryImportResult.Applied(
|
||||
if (localNewer) 1 else 0, if (localNewer) 0 else 1,
|
||||
),
|
||||
source.applySleepDiaryV1Json(reconciled),
|
||||
)
|
||||
assertEquals(winner.revisionId, source.listSleepDiary().single().revisionId)
|
||||
val retained = SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(destinationName).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
try {
|
||||
assertEquals(2, retained.rawQuery(
|
||||
"SELECT COUNT(*) FROM sleep_diary_revisions " +
|
||||
"WHERE revision_id IN (?,?)",
|
||||
arrayOf(localRevision, remoteRevision),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
cursor.getInt(0)
|
||||
})
|
||||
} finally {
|
||||
retained.close()
|
||||
}
|
||||
} finally {
|
||||
source.close()
|
||||
destination.close()
|
||||
context.deleteDatabase(sourceName)
|
||||
context.deleteDatabase(destinationName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun createEditConflictDeleteAndReopen() {
|
||||
val name = "sleep-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, name)
|
||||
|
|
@ -103,7 +245,7 @@ class SleepDiaryRepositoryTest {
|
|||
SQLiteDatabase.OPEN_READWRITE,
|
||||
)
|
||||
try {
|
||||
assertEquals(33, raw.version)
|
||||
assertEquals(34, raw.version)
|
||||
assertThrows(SQLiteException::class.java) {
|
||||
raw.execSQL(
|
||||
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.security.MessageDigest
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
|
@ -54,6 +55,109 @@ class SyncConversationOwnershipTest {
|
|||
)
|
||||
}
|
||||
|
||||
private fun canonical(value: Any?): String =
|
||||
when (value) {
|
||||
null,
|
||||
JSONObject.NULL -> "null"
|
||||
is JSONObject ->
|
||||
value.keys().asSequence().toList().sorted().joinToString(",", "{", "}") {
|
||||
JSONObject.quote(it) + ":" + canonical(value.get(it))
|
||||
}
|
||||
is JSONArray ->
|
||||
(0 until value.length()).joinToString(",", "[", "]") { canonical(value.get(it)) }
|
||||
is String -> JSONObject.quote(value)
|
||||
is Boolean,
|
||||
is Int,
|
||||
is Long -> value.toString()
|
||||
else -> error("unsupported test ACK value")
|
||||
}
|
||||
|
||||
private fun rejectedDesktopAcknowledgement(
|
||||
runId: String,
|
||||
generationId: String,
|
||||
producerPeerId: String,
|
||||
consumerPeerId: String,
|
||||
manifestSha256: String,
|
||||
): String {
|
||||
/* This fixture signs the exact wire ACK because the production
|
||||
* validator must persist it before the coordinator may release its
|
||||
* lease; an unsigned rejection would only test malformed-input logic. */
|
||||
val acknowledgement =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-ack")
|
||||
.put("version", 1)
|
||||
.put("ack_id", "ack_${generationId.removePrefix("gen_")}")
|
||||
.put("run_id", runId)
|
||||
.put("generation_id", generationId)
|
||||
.put("producer_peer_id", producerPeerId)
|
||||
.put("consumer_peer_id", consumerPeerId)
|
||||
.put("manifest_sha256", manifestSha256)
|
||||
.put("result", "rejected")
|
||||
.put("durability", "sqlite-commit-rejection")
|
||||
.put("consumed_at", "2026-09-28T12:00:00+02:00")
|
||||
.put("diagnostic", "concurrent sleep diary revision")
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(canonical(acknowledgement).toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
return acknowledgement.put("payload_sha256", digest).toString()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedDesktopAcknowledgementEndsConversationAndReleasesBackgroundLease() {
|
||||
val databaseName = "generation-rejection-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-rejection-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
val arbiter = SyncConversationArbiter()
|
||||
val referencePublished = CountDownLatch(1)
|
||||
val desktopPeer = "peer_${UUID.randomUUID()}"
|
||||
val runId = "sy_${UUID.randomUUID()}"
|
||||
try {
|
||||
val service = SyncGenerationService(repository)
|
||||
request(root, runId, service.peerId(), desktopPeer)
|
||||
archiveAcknowledgements(root, runId, service.peerId(), desktopPeer)
|
||||
val coordinator =
|
||||
SyncGenerationCoordinator(
|
||||
repository,
|
||||
arbiter = arbiter,
|
||||
trace = SyncGenerationTrace { _, phase, _, _, _ ->
|
||||
if (phase == "generation_reference_published") referencePublished.countDown()
|
||||
},
|
||||
)
|
||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
try {
|
||||
val active = executor.submit<ForegroundGenerationResult> {
|
||||
coordinator.run(root, Duration.ofSeconds(5))
|
||||
}
|
||||
assertTrue(referencePublished.await(3, TimeUnit.SECONDS))
|
||||
val reference = JSONObject(File(root, "android-generation-v1.json").readText())
|
||||
File(root, "desktop-consumption-ack-v1.json").writeText(
|
||||
rejectedDesktopAcknowledgement(
|
||||
runId,
|
||||
reference.getString("generation_id"),
|
||||
service.peerId(),
|
||||
desktopPeer,
|
||||
reference.getString("manifest_sha256"),
|
||||
)
|
||||
)
|
||||
val result = active.get(2, TimeUnit.SECONDS)
|
||||
assertEquals(
|
||||
ForegroundGenerationResult.Failed("concurrent sleep diary revision", runId),
|
||||
result,
|
||||
)
|
||||
assertTrue(
|
||||
"The rejected run must release the lease for its next request.",
|
||||
coordinator.run(root, Duration.ofMillis(100)) !is ForegroundGenerationResult.Busy,
|
||||
)
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() {
|
||||
val databaseName = "generation-ownership-${UUID.randomUUID()}.db"
|
||||
|
|
|
|||
|
|
@ -878,7 +878,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(33, it.getInt(0))
|
||||
assertEquals(34, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -2382,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(33, cursor.getInt(0))
|
||||
assertEquals(34, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2509,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(33, cursor.getInt(0))
|
||||
assertEquals(34, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ evidence and chronology.
|
|||
existing-artifact synchronization boundary.
|
||||
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
|
||||
history and Android-delivery workflow.
|
||||
- [Sleep Diary guide](sleep_diary.md) explains capture, agenda row paging,
|
||||
factual sport and heart-rate displays, and independent PDF range selection.
|
||||
- [Program format V1](program_format_v1.md) owns the strict desktop planning
|
||||
import contract and program lifecycle used by the Sessions Programs subtab.
|
||||
|
||||
|
|
@ -80,6 +82,9 @@ evidence and chronology.
|
|||
|
||||
- [Review records](reviews/) retain historical audits, incidents, checkpoints,
|
||||
and validation evidence. Their status and paths are historical.
|
||||
- [Documentation deep review V1](reviews/documentation_deep_review_v1.md)
|
||||
records the audited 0.1.7 source baseline, corrected claims, evidence limits,
|
||||
and coverage matrix.
|
||||
- [Programs presentation / Android delete evidence](reviews/programs_presentation_android_delete_v1_evidence.md)
|
||||
records the implemented, not-deployed validation checkpoint and real-browser
|
||||
screenshot evidence.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
# Android application
|
||||
|
||||
## Schema v34: read-only Programs V2 projection
|
||||
|
||||
The audited Program rescheduling development line additively stores a projection
|
||||
version and revision sequence on synchronized Programs, plus `current_for` and
|
||||
`planning_state` on synchronized Program sessions. The original `planned_for`
|
||||
and all local completed sessions and drafts remain separate. Existing V1 rows
|
||||
hydrate as active with their original date. Android still has no Program editor.
|
||||
|
||||
Programs V2 replaces one Program snapshot transactionally. A lower revision
|
||||
sequence or a later Programs V1 snapshot cannot overwrite accepted V2 planning.
|
||||
If the singleton active draft refers to a session whose current date or ceded
|
||||
state changes, V2 import rejects the generation; it does not alter the draft.
|
||||
The Program screen uses current dates for unfinished sessions and does not offer
|
||||
Start on a ceded slot. The private Program rollout records a compatible v34
|
||||
Android installation; this source snapshot alone does not prove another device.
|
||||
|
||||
## Sleep Diary V1 and active V2 companion
|
||||
|
||||
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@
|
|||
## Synchronization transport boundary
|
||||
|
||||
Generation capture, validation, causal import, publication and ACK acceptance
|
||||
are transport-neutral. `sync_peer_worker.py` owns that conversation; direct MTP
|
||||
and `sync_drive_transport.py` only move its bounded bytes. Automatic selection
|
||||
probes the expected MTP peer before preferring USB. A successful USB run is
|
||||
committed independently, then mirrored to Drive best-effort; Drive failure can
|
||||
never reopen its SQLite transactions. If MTP is unavailable, the same worker
|
||||
runs over the Drive byte adapter.
|
||||
are transport-neutral. `sync_peer_worker.py` owns that conversation. The paired
|
||||
private automatic path selects authenticated Bluetooth first and direct MTP as
|
||||
wired recovery. The separately configured legacy USB/Drive workflow probes the
|
||||
expected MTP peer before selecting USB. A successful USB run is committed
|
||||
independently, then mirrored to Drive best-effort; Drive failure cannot reopen
|
||||
its SQLite transactions. If MTP is unavailable in that workflow, the same
|
||||
worker runs over the Drive byte adapter. No transport carries a SQLite file.
|
||||
|
||||
Android mirrors that ownership: one `SyncGenerationCoordinator` is called by
|
||||
SyncScreen, the visible USB foreground service, and the private-folder Drive
|
||||
|
|
@ -42,7 +43,7 @@ Android field client
|
|||
Android shared storage
|
||||
Documents/Trainlog
|
||||
|
|
||||
| direct MTP
|
||||
| Bluetooth RFCOMM primary; direct MTP recovery
|
||||
v
|
||||
shared desktop sync engine
|
||||
/ \
|
||||
|
|
@ -358,11 +359,16 @@ SVG was selected over Canvas because the bounded series has very few elements
|
|||
and vector sharpness/responsive scaling matter more than high-volume drawing.
|
||||
All colours are read from the live Catppuccin CSS variables.
|
||||
|
||||
The muscle figure is an original code-native SVG. Canonical localized mappings
|
||||
are: `chest` to bilateral front chest; `back` to rear upper back and bilateral
|
||||
lats; `shoulders` to bilateral front/rear shoulders; `arms` to bilateral
|
||||
front/rear arms; `core` to the front trunk; `glutes` to bilateral rear glutes;
|
||||
`thighs` and `calves` to bilateral front/rear legs. Aggregate zones
|
||||
The muscle figure is a code-native SVG whose geometry is ported from the
|
||||
normalized Compose Canvas in Android `HomeScreen.kt`: `drawOval()` for the
|
||||
head, `neckPath()` for the neck, and `bodyRegions()` for 11 paths on each
|
||||
face. The SVG viewBox uses Android fractions multiplied by 100 and scales to
|
||||
a portrait ratio based on Android's 236dp tall two-panel Home layout.
|
||||
Canonical localized mappings are: `chest` to
|
||||
bilateral front chest; `back` to the contiguous rear trunk; `shoulders` to
|
||||
bilateral front/rear shoulders; `arms` to bilateral front/rear arms; `core` to
|
||||
the front trunk; `glutes` to bilateral rear glutes; `thighs` and `calves` to
|
||||
bilateral front/rear legs. Aggregate zones
|
||||
`full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and
|
||||
remain in the complete text list. Visual levels are a deterministic ratio to
|
||||
the maximum `session_count` in the current snapshot: zero, up to one third, up
|
||||
|
|
@ -519,12 +525,14 @@ silently rendered as unclassified.
|
|||
|
||||
### Desktop
|
||||
|
||||
Desktop SQLite schema v26 is canonical long-term history. Its additive v25 ->
|
||||
v26 migration adds terminal Program logical deletion and its durable replay,
|
||||
generation, and acknowledgement ledger without deleting imported definitions,
|
||||
derived preparations, or workout facts. Android schema v24 maintains the
|
||||
separate read-only synchronized Programs projection; the optional staged
|
||||
`trainlog-programs` V1 companion does not replace mobile V3 or change
|
||||
Desktop SQLite schema v37 is canonical long-term history in the audited
|
||||
development source. The additive v37 migration stores absolute Program current
|
||||
dates, ceded slots and revision ordering. The earlier v25 -> v26 migration
|
||||
added terminal Program logical deletion and its durable replay, generation and
|
||||
acknowledgement ledger without deleting imported definitions, derived
|
||||
preparations or workout facts. Android schema v34 extends the read-only
|
||||
Programs projection with capability-selected V2 planning state; v24 introduced
|
||||
its original V1 projection. Neither Programs companion replaces mobile V3 or changes
|
||||
`TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable
|
||||
manual-preparation withdrawal identity without deleting revisions, deliveries
|
||||
or workout facts. Its additive v18 ->
|
||||
|
|
@ -584,7 +592,7 @@ exercise_body_zone_sync
|
|||
|
||||
### Android
|
||||
|
||||
Android has an independent local SQLite schema, currently v14. Completed and
|
||||
Android has an independent local SQLite schema, v34 in the audited source. Completed and
|
||||
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
|
||||
resuming a completed Test max records its stable source session in the one
|
||||
durable draft.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="52" viewBox="0 0 960 52" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Trainlog technology badges</title>
|
||||
<desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.6.</desc>
|
||||
<desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.7.</desc>
|
||||
<style>
|
||||
.label { font: 600 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #cdd6f4; dominant-baseline: middle; text-anchor: middle; }
|
||||
</style>
|
||||
|
|
@ -32,6 +32,6 @@
|
|||
<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>
|
||||
<text class="label" x="896" y="27">0.1.7 DEV</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
|
@ -1,5 +1,42 @@
|
|||
# Current implementation state
|
||||
|
||||
## Audited development line and private installation
|
||||
|
||||
This snapshot audits `trainlog-sleep-single-selector-weeks-v1` at `f4ddcfd`
|
||||
on 2026-10-02. It includes the Program projection fix `8995843`, the body
|
||||
figure Web change `c2935b8`, and the Sleep PDF, sport and paging changes by
|
||||
ancestry. A later documentation-only commit does not imply a new runtime
|
||||
package. The uncommitted Dashboard layout work in the separate
|
||||
`trainlog-dashboard-polish-v1` worktree is outside this snapshot.
|
||||
|
||||
The private daily installation runs the ordered Program rescheduling rollout
|
||||
from `trainlog-program-recalage-v1` at `3a4ec34`, with desktop schema v37,
|
||||
Android schema v34, and the capability-selected `trainlog-programs` V2
|
||||
projection. The observed PC/Web launcher points to the
|
||||
`sleep-single-selector-weeks-v1-f4ddcfd` package; synchronization agents
|
||||
retain the compatible Program
|
||||
rollout bundle. The previous PC/Web bundle from
|
||||
`trainlog-sleep-pdf-treatments-space-v1` at `3758875` remains available for
|
||||
rollback. The served Web uses Android Compose Canvas head, neck, and 22 body
|
||||
paths on Dashboard muscle distribution, Analysis overview, and Analysis body
|
||||
distribution. Firefox captures of all three served views show complete figures
|
||||
at the intended aspect ratio, with the existing color scale, legend, and focus
|
||||
interaction. The candidate geometry was compared with reconstructed Android
|
||||
source paths; no Android runtime screenshot was available because no device or
|
||||
emulator was connected. No Android, schema, domain, or synchronization change
|
||||
accompanied this Web rollout.
|
||||
|
||||
Firefox downloaded a four-page PDF from the served Web. The earlier PDF layout
|
||||
validation rendered and inspected every page: its 65 individual medication
|
||||
lines match its summary, its remarks remain visible, and no extracted word
|
||||
exceeds the page margins. After the body figure rollout, a fresh PDF export
|
||||
again contained the treatments and remarks sections; actual sport markers
|
||||
remained blue in Web, and a recorded start time matched the PDF. The earlier
|
||||
PDF inspection also confirmed black-outlined blue sport bands. A separate
|
||||
synthetic set of 7, 14, 21, and 30 nights in French and English, plus long
|
||||
continuations, was rendered page by page and checked in grayscale. Rollout
|
||||
packaging requires a Meson `-Dweb=enabled` build with embedded frontend tests.
|
||||
|
||||
Full-generation synchronization uses one generation/ACK business engine across
|
||||
versioned transports. On the paired 0.1.4 deployment, automatic local selection
|
||||
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
|
||||
|
|
@ -20,12 +57,25 @@ 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.
|
||||
|
||||
The daemon's durable arrival ledger distinguishes admission from terminal
|
||||
consumption. If RFCOMM disappears after an Android request is admitted but
|
||||
before the generation conversation can complete, an explicitly transient
|
||||
terminal result leaves that same stable request eligible on reconnect. Success
|
||||
and non-transient business failures remain consumed, while every retry receives
|
||||
a fresh run identity and revalidates the existing idempotent generation/ACK
|
||||
evidence.
|
||||
|
||||
Android synchronization paths share the same concurrency-guarded generation
|
||||
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
||||
arbiter: explicit foreground intent has priority over automatic background
|
||||
resumption, while a foreground service may remain alive without owning the
|
||||
conversation. An automatic run yields cooperatively at safe boundaries; its
|
||||
durable generation is retained for a correlated handoff rather than deleted.
|
||||
A correlated desktop `rejected` ACK is also terminal for the Android producer:
|
||||
it is retained as durable rejection evidence, then releases the conversation
|
||||
lease immediately because no reciprocal desktop generation can follow that
|
||||
rejection. This keeps an explicit Web request or a later automatic Bluetooth
|
||||
request from being starved by an impossible post-rejection wait.
|
||||
|
||||
Direct-MTP publication now has an explicit Android visibility boundary.
|
||||
Durable immutable generation files are submitted to MediaProvider and all scan
|
||||
|
|
@ -33,7 +83,7 @@ callbacks complete before the mutable generation reference is published; the
|
|||
reference and Android ACK use the same confirmation. Bounded diagnostic logs
|
||||
contain only run/generation identities, artifact names, phases and results.
|
||||
The desktop worker performs lightweight local correlation checks continuously
|
||||
but opens a full MTP pull no more than once every three seconds. A 30-second
|
||||
but opens a full MTP pull no more than once every three seconds. A 90-second
|
||||
individual adapter budget is distinct from the overall conversation deadline,
|
||||
so an absent correlated generation reports a protocol wait timeout while an
|
||||
actually blocked adapter reports `transport_timeout`.
|
||||
|
|
@ -128,11 +178,11 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| 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 |
|
||||
| Desktop SQLite | source schema v37; v37 adds Program current dates, ceded slots and revision ordering; v36 makes Sleep revision payload append-only; v35 adds intake quantity |
|
||||
| Android SQLite | source schema v34; v34 adds the read-only Programs V2 projection; v33 makes Sleep revision payload append-only; v32 adds intake quantity and quick publication |
|
||||
| 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` stable, synchronized across Android and desktop; latest stable release: `v0.1.6` |
|
||||
| Trainlog product version | `0.1.7` bugfix-only development, 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` |
|
||||
|
|
@ -150,7 +200,7 @@ parallel implementation of its rules.
|
|||
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||
| Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. |
|
||||
| Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. Distinct concurrent V2 edits with comparable user-edit instants retain the complete latest snapshot and both immutable histories; ambiguous ordering and unsafe deletion/envelope changes still reject. |
|
||||
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
||||
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
|
||||
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
||||
|
|
@ -177,7 +227,7 @@ parallel implementation of its rules.
|
|||
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
|
||||
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
||||
| 0.1.6 validation | `TRAINLOG_0_1_6_VALIDATION_V1=PASS`: native Meson 114/114 and Clang ASan/UBSan 114/114; Android 317 tests (312 passed, five documented optional skips), debug APK and signed release APK; Web 32 files / 209 tests plus typecheck/build; JSON/import, changed-C formatting and diff checks pass. |
|
||||
| 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. |
|
||||
| Current operational cursor | 0.1.7 stabilization is open after immutable stable v0.1.6. Only observed bug fixes, regression fixes, directly related small UI corrections, documentation corrections, robustness, and associated tests are in scope. |
|
||||
| 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. |
|
||||
|
|
@ -193,13 +243,38 @@ parallel implementation of its rules.
|
|||
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
|
||||
The active Sleep V2 view reads the complete available Sleep history independently
|
||||
of the Analysis period used by other sections. Every returned night remains
|
||||
available on the shared 18:00-to-18:00 geometry. Sleep has one agenda display
|
||||
selector: the newest nights first, seven recorded nights per page by default,
|
||||
with 7, 14, 21, 28, and All choices. These are row counts, not calendar windows.
|
||||
The browser-local choice survives reloads; old 5/10/15 choices migrate to
|
||||
7/14/21. At the server's 3660-night read cap, the Web reports that completeness
|
||||
cannot be established instead of presenting a truncated agenda as All. Paging
|
||||
changes only the visible rows; PDF selection, statistics, and the current editor
|
||||
remain based on the complete loaded history. Selecting a row is
|
||||
presentation-only and atomically drives the detail dates, editor content, and
|
||||
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
|
||||
initialized to the loaded history bounds and independent of detail selection.
|
||||
The completed-sport overlay reads factual `sessions.started_at` and `ended_at`
|
||||
from desktop history through a read-only, paginated Web endpoint.
|
||||
Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for
|
||||
Sleep, medication, and sport in both Web and vector PDF. Valid sport intervals
|
||||
intersect `[start,end)` and are clipped only for display; missing or invalid
|
||||
ends are point markers without invented duration. The Web keeps a separate
|
||||
blue lane with accessible labels and tooltips; the redundant textual Web sport
|
||||
list is removed. Preview and download wait for the
|
||||
complete sport read and use the same selected sessions. The PDF uses a 180 pt treatment/remarks column with 7.2 pt text,
|
||||
width-aware wrapping, content-driven row heights, and dated continuation pages
|
||||
for exceptional notes. Each selected intake and remark is printed once without
|
||||
a fixed character limit. The shorter 430 pt timeline shares one coordinate
|
||||
mapping across hour ticks, grid, sleep markers, medication markers, and blue
|
||||
sport bands. The PDF prints blue
|
||||
outlined bands and paginated names, factual times, and durations, marking
|
||||
sessions without a corresponding Sleep row as `sleep not recorded`. Sport
|
||||
creates no Sleep event and changes no sleep total, session, schema, or sync
|
||||
contract. The HR detail's rise/fall overlays are descriptive relative measured
|
||||
variation only (five-minute trailing reference excluding the current 60-second
|
||||
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
||||
are not sleep events, sleep stages, causes, readiness, or medical inference.
|
||||
|
|
@ -436,7 +511,7 @@ catalogue state. Built-in identities cannot be retired, equipment associations
|
|||
are presented read-only, and profile edits affect future use only: occurrence
|
||||
snapshots, performed sets, feedback and MAX history are never rewritten. A
|
||||
successful mutation changes only the canonical desktop database; the existing
|
||||
USB-priority, Drive mirror/fallback synchronization later carries the existing
|
||||
configured generation synchronization later carries the existing
|
||||
catalogue/profile/BODY ZONES/causal artifacts. No Web mutation invokes sync.
|
||||
|
||||
The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
||||
|
|
@ -444,9 +519,21 @@ The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
|||
or execution state. With no active Program it shows an empty state and
|
||||
navigation to Sessions administration; with one it selects it automatically;
|
||||
with several it offers a non-persisted selector. Its continuous Monday–Sunday
|
||||
weeks derive from Program bounds and real `planned_for` dates, do not invent
|
||||
rest-day sessions, and retain undated sessions in a separate section. It shows
|
||||
all five Core execution states; only `todo` offers Prepare. Each Prepare result
|
||||
weeks derive from Program bounds, each unfinished session's current date
|
||||
(`current_for` when present, otherwise imported `planned_for`), and the actual
|
||||
`sessions.started_at` instant for linked completed executions, presented on the
|
||||
same browser-local civil day as Web session history. The Program detail Web API
|
||||
adds the nullable read-only `execution_started_at` projection field; the
|
||||
Programs V2 projection carries the current dates and ceded-slot state to
|
||||
capable Android peers without changing the strict Programs V1 artifact. A completed
|
||||
session moved from its imported date shows both dates without changing history
|
||||
or Program identity. Several cards may share a day; rest days contain no
|
||||
invented session. A completed execution with no valid history timestamp stays
|
||||
undated rather than appearing to have occurred on its planned day. Unfinished
|
||||
sessions show their current dates; ceded unfinished slots remain visible but
|
||||
are excluded from next-session selection and preparation, and are not counted
|
||||
as performed. It shows all five Core execution states; only eligible `todo`
|
||||
offers Prepare. Each Prepare result
|
||||
is followed by an authoritative Core reread and may expose the returned
|
||||
preparation link. This calendar adds no Android delivery, start, or execution
|
||||
behavior.
|
||||
|
|
@ -514,6 +601,7 @@ 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.6 tag uses `versionName=0.1.6` and public versionCode 7.
|
||||
The v0.1.7 stabilization source starts at `versionName=0.1.7` and versionCode 8.
|
||||
Private validation builds may still supply only a strictly increasing
|
||||
`versionCode` as a build-time override; that does not create a public release
|
||||
or change schemas and protocols.
|
||||
|
|
@ -684,9 +772,12 @@ partial, invalid and transport-error states never synthesize values.
|
|||
`WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` uses a lazy, modular Apache
|
||||
ECharts 6.1.0 SVG line chart for medium/large Progression tiles. Every real
|
||||
point remains visible, `smooth=false`, legacy zeroes are preserved, and only
|
||||
Core-owned `improved` flags alter markers. An original React SVG supplies
|
||||
front/back BODY ZONES regions whose discrete colour level depends solely on
|
||||
`session_count`; the factual text list remains authoritative and accessible.
|
||||
Core-owned `improved` flags alter markers. A React SVG ports the exact
|
||||
normalized head, neck, and 11 front/11 back BODY ZONES paths from Android
|
||||
`HomeScreen.kt` `bodyRegions()` and `neckPath()`. Its discrete Catppuccin colour
|
||||
level still depends solely on `session_count`; the factual text list remains
|
||||
authoritative and accessible. The figure geometry changes no zone mapping or
|
||||
30-day calculation.
|
||||
|
||||
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
|
||||
formatting, layout-invariance and source-derived text-boundary tests, plus
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
# Desktop database
|
||||
|
||||
## Schema v37: Program planning
|
||||
|
||||
Schema v37 is additive to the Program rescheduling development line. It
|
||||
creates `program_session_planning` for absolute current dates and ceded-slot
|
||||
state, `program_reschedule_operations` for exact request replay, and
|
||||
`program_revision_sequences` for monotonically ordered Program snapshots.
|
||||
A trigger increments the per-Program sequence whenever its revision changes,
|
||||
including archive, reschedule and logical deletion. No date uniqueness or
|
||||
historical session mutation is introduced. Missing planning rows mean the
|
||||
original `planned_for` date and active planning state. The source format and
|
||||
published Programs V1 artifact remain strict and unchanged.
|
||||
|
||||
This schema is present in the audited development commit. Its presence in
|
||||
source does not establish the schema of another installation; a deployment
|
||||
requires a compatible package, backup and migration verification.
|
||||
|
||||
## Causal heart-rate tail correction (schema v36, no migration)
|
||||
|
||||
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
|
||||
|
|
@ -240,8 +256,8 @@ is a later, separately versioned migration.
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=28
|
||||
DATABASE_SCHEMA_V28=IMPLEMENTED
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=37
|
||||
DATABASE_SCHEMA_V37=IMPLEMENTED
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
|
|
@ -260,7 +276,7 @@ PRAGMA user_version;
|
|||
Current value:
|
||||
|
||||
```text
|
||||
27
|
||||
37
|
||||
```
|
||||
|
||||
The independent actual-set loads documented in the current desktop, Android
|
||||
|
|
@ -719,7 +735,7 @@ distance km
|
|||
|
||||
The Android SQLite database is independent.
|
||||
|
||||
Current Android-local version: **17**. The explicit migration chain adds the
|
||||
Current Android-local version: **34**. The historical migration chain adds the
|
||||
durable draft in v4, equipment references in v5, per-set load in v6, occurrence
|
||||
identity/multi-occurrence support in v7, and the widened custom-equipment
|
||||
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw
|
||||
|
|
|
|||
|
|
@ -68,3 +68,32 @@ source definition and derived preparations. A deleted program cannot be
|
|||
resurrected or prepared; an archived program cannot be prepared. The optional
|
||||
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
|
||||
projection and does not alter this import format.
|
||||
|
||||
## Explicit ordered rescheduling in the audited development line
|
||||
|
||||
The `trainlog-program-recalage-v1` line adds a separate planning
|
||||
state. `planned_for` remains the imported date. `current_for` is an absolute
|
||||
current target for an active, unfinished session; `ceded` releases one selected
|
||||
future slot without completing or deleting its session identity. Completed
|
||||
cards still use the linked session's actual start timestamp. Positions from the
|
||||
imported Program determine the cascade order; titles never determine priority.
|
||||
|
||||
The Web action selects a first and last unfinished session, a resumption date,
|
||||
and the exact session identities whose slots are ceded. Its available days are
|
||||
the dated original Program slots in that interval on or after resumption.
|
||||
The preview lists original, prior current and proposed dates. It rejects a
|
||||
missing slot, a date beyond the Program end, a completed/in-progress/prepared
|
||||
session, or a stale revision. It does not invent rest-day or post-end slots.
|
||||
An explicitly ceded session keeps its identity and remains visible as ceded.
|
||||
Unchanged sessions in the selected range are displayed in the preview but need
|
||||
no planning row. Other sessions and Programs are not moved. Multiple actual or
|
||||
independent sessions may still share one civil day.
|
||||
|
||||
Confirmation binds the preview fingerprint to the current Program projection
|
||||
and writes absolute dates, ceded states, the Program revision and a durable
|
||||
operation response in one transaction. Exact operation replay returns that
|
||||
response; changed content under the same operation ID conflicts. A failed
|
||||
write rolls back every row. The development implementation requires a recent acknowledged
|
||||
Programs V2 generation for every previously contacted Android peer. A newly
|
||||
started offline Android Program draft remains protected by Android's V2 import
|
||||
rejection; the sync run then reports a conflict for explicit resolution.
|
||||
|
|
|
|||
82
docs/reviews/documentation_deep_review_v1.md
Normal file
82
docs/reviews/documentation_deep_review_v1.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Documentation deep review V1 — 2026-10-02
|
||||
|
||||
## Scope and evidence hierarchy
|
||||
|
||||
This review covers the linear development source at
|
||||
`f4ddcfdb05e9627397f7225c05f40212e9de1ea6`
|
||||
(`trainlog-sleep-single-selector-weeks-v1`). Its ancestors include `3a4ec34`
|
||||
(Program rollout), `8995843` (current Program projection), `c2935b8` (Web body
|
||||
figures), and the Sleep sport/PDF/paging changes. It does not integrate or
|
||||
describe as delivered the uncommitted Dashboard layout work in the separate
|
||||
`trainlog-dashboard-polish-v1` worktree. Stable v0.1.6 and the 0.1.7
|
||||
development line are different publication states.
|
||||
|
||||
Normative frozen contracts take priority over source implementation; source and
|
||||
tests establish capabilities of this commit; dated rollout records establish
|
||||
only the installation they observed; design/review files retain history. The
|
||||
observed PC/Web launcher path names the `f4ddcfd` package. That observation
|
||||
alone is not a byte comparison of a running process, an Android APK inspection,
|
||||
or proof of every synchronization service's package. The earlier verified
|
||||
Program rollout records desktop/Android schemas v37/v34. No private health
|
||||
data, PDF, database or device identifier was read for this review.
|
||||
|
||||
## Coverage inventory
|
||||
|
||||
The 105 tracked baseline Markdown files include root `README.md`, `AGENTS.md`,
|
||||
`CHANGELOG.md`, `android/README.md`, `format/README.md`, 17 primary `docs/*.md`
|
||||
owners, domain and design records, release notes and historical reviews. This
|
||||
revision adds an indexed Sleep guide and this review. The primary owners were
|
||||
inspected against code, migration constants, Meson/Gradle settings, tests and
|
||||
retained rollout records. Designs, releases and reviews were mapped as dated
|
||||
evidence rather than rewritten into current status. The tracked Markdown
|
||||
relative-link scan found no missing target before publication. External link
|
||||
availability was not verified.
|
||||
|
||||
| Document/section | Claim | Evidence (commit, path, symbol or test) | Gap | Correction | Status |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `AGENTS.md` scope | Analyse, Programmes, Sessions and Exercises are placeholders | `web/src/routes/`; `docs/web_sessions.md`; `docs/web_exercises.md` at `f4ddcfd` | Obsolete present tense | Names implemented routes | Corrected |
|
||||
| `AGENTS.md`, database and Android status | Current schemas v26/v24, v28/v17 or v36/v33 | `tui/include/trainlog/database.h` `TRAINLOG_DATABASE_SCHEMA_VERSION=37`; `TrainlogRepository.kt` helper `version=34`; migration tests | Mixed historical and current versions | Current source v37/v34; historical migration steps retained | Corrected |
|
||||
| `current_state.md`, roadmap | `c2935b8`/`3758875` Web package served | Observed launcher target names `sleep-single-selector-weeks-v1-f4ddcfd`; ancestor graph | Package claim stale; running bytes unverified | Reports launcher observation and separate Program rollout | Corrected with limit |
|
||||
| Sleep agenda | Old selector or calendar-week interpretation | `SleepDiaryWorkspace.tsx` `initialAgendaPageSize`, `recentEntries`, `visibleEntries`; Sleep component tests | Seven recorded rows can span more than seven dates | Current 7/14/21/28/All row counts, default seven; old values migration only | Corrected |
|
||||
| Sleep PDF and sport | Page controls could be confused with export range | `SleepDiaryWorkspace.tsx` `pdfStartDate`/`pdfEndDate`; `sleepVisualProjection`; Web PDF tests | Selection and visible page have different scopes | Documents independent inclusive range and factual sport bands | Verified in source |
|
||||
| Programs | Rescheduling labelled future/candidate | `docs/program_format_v1.md`; `docs/sync_exchange.md`; `tui/tests/test_web_programs.c`; Android v34 projection | Development capability mislabelled future | Describes implemented development contract and partial V1-peer convergence | Corrected |
|
||||
| Web package | `npm test` could be mistaken for embedded runtime proof | `meson_options.txt` `web`; `tui/meson.build` frontend target/tests; `tools/package_sync_candidate.py` asset gate | Source tests and package differ | README requires `-Dweb=enabled`, Meson tests, compatible bundle | Corrected |
|
||||
| Synchronization | One ACK might imply universal convergence | `docs/sync_exchange.md` Programs V2 matrix; generation and ACK implementation | V1-only peer omits rescheduled Program while other domains proceed | Added domain/format/selection/ACK matrix | Corrected |
|
||||
| Transport narrative | Current Web edits and architecture described USB as universal priority | `docs/sync_exchange.md` Bluetooth selector; `tools/sync_peer_worker.py`; private rollout record | Conflated paired Bluetooth path with separate USB/Drive workflow | Separated automatic paired choice from legacy configured workflow | Corrected |
|
||||
| Rollout readiness | Historical eight-generation limit could look universal/current | `tools/sync_generation_exchange.py` `MAX_RETAINED_PER_PEER=16`; Android `SyncGenerationService.MAX_RETAINED_PER_PEER=8`; `tools/sync_peer_worker.py` MTP timeout | Desktop/Android ceilings differ; timeout is not success proof | Separated limits and historical bridge instructions | Corrected |
|
||||
| Sleep user path | Capture, agenda and PDF behavior had no short indexed guide | `TrainlogRepository.kt`; `SleepDiaryWorkspace.tsx`; Sleep projection/PDF tests | Readers had to assemble workflow from state and code | Added indexed factual guide with privacy and evidence boundaries | Corrected |
|
||||
| Backup/rollback | Old executable could be mistaken for schema rollback | `tools/backup_trainlog_sqlite.py` SQLite backup API; `docs/sync_rollout_readiness.md` | Needs paired compatibility and retained backups | Existing procedure retained and linked from README | Verified in documentation |
|
||||
| Tests | Largest historical totals could look like current validation | `docs/tests.md` dated checkpoints; `tui/meson.build`; Web scripts | Build configuration and skipped tests were implicit | Added evidence-reading rule | Corrected |
|
||||
| 0.1.8 | Possible training ideas could look implemented | `docs/roadmap.md`; present catalog/feedback/Sleep/Cardio owners | No approved algorithm or medical rule | Proposed-only section with human validation | Corrected |
|
||||
|
||||
## Validation performed in the isolated documentation worktree
|
||||
|
||||
`python3 tools/validate_json.py` and
|
||||
`python3 tools/validate_import_contract.py` passed. With a local
|
||||
`-Dweb=enabled` Meson build, strict compilation and **114/114** registered
|
||||
tests passed, including the embedded frontend and package self-containment
|
||||
checks. A preliminary `-Dweb=disabled` run passed 111/112; its deployment
|
||||
package test rejected the intentionally absent Web assets as designed. The
|
||||
changed C/H format gate passed with no changed C/H file. Relative links across
|
||||
the tracked baseline plus new Markdown files and `git diff --check` passed.
|
||||
These checks used build artifacts in this worktree and synthetic test data;
|
||||
they did not exercise a personal device, database or service.
|
||||
|
||||
## Boundaries and open evidence
|
||||
|
||||
The source can prove format selection, persistence rules and test coverage; it
|
||||
cannot prove that a particular uninspected phone or service is currently
|
||||
running that source. The `f4ddcfd` launcher observation identifies a package
|
||||
path, not a live process hash. The private rollout and Firefox/PDF records are
|
||||
dated evidence for their stated configurations. Android path reconstruction for
|
||||
the Web silhouette is source parity evidence, not an Android runtime
|
||||
screenshot. The local desktop 16-generation and Android eight-generation
|
||||
admission limits, and the 90-second MTP operation budget, are bounded
|
||||
configuration, not a guarantee against interruption or proof that transient
|
||||
storage occupation is always lower than either ceiling.
|
||||
|
||||
No code defect was confirmed by this documentation pass. A broad line-by-line
|
||||
revalidation of every historical review and every external URL is outside the
|
||||
verified coverage; their current status must not be inferred from this matrix.
|
||||
No application, database, service, synchronization or personal-data operation
|
||||
belongs to this documentation branch.
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
# Roadmap
|
||||
|
||||
The Program cascade is deployed in the private daily installation from
|
||||
`trainlog-program-recalage-v1` at `3a4ec34`. The observed PC/Web launcher points
|
||||
to the `f4ddcfd` package, which includes
|
||||
the verified blue Sleep sport overlay and the readable paginated treatment and
|
||||
remarks PDF. Synchronization services retain their Program rollout paths. No
|
||||
new personal Program rescheduling is implied by this presentation work.
|
||||
|
||||
The controlled private rollout is complete for the paired daily installation:
|
||||
physical libmtp, verified backups, signing continuity, migrations, correlated
|
||||
ACKs, restart, and idempotent replay passed. Generation mode remains an
|
||||
|
|
@ -30,8 +37,9 @@ estimates.
|
|||
|
||||
## Current baseline
|
||||
|
||||
Stable v0.1.6 is the release baseline for the next stabilization cycle.
|
||||
Desktop schema v36, Android schema v33, Notcurses, direct `Documents/Trainlog`
|
||||
Stable v0.1.6 is the release baseline for the v0.1.7 stabilization cycle.
|
||||
The audited development source has desktop schema v37 and Android schema v34.
|
||||
Stable v0.1.6 had desktop schema v36 and Android schema v33. Notcurses, direct `Documents/Trainlog`
|
||||
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
|
||||
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
|
||||
Generator V1 is hidden pending V2. AI session-draft exchange retains its
|
||||
|
|
@ -76,9 +84,43 @@ release closes this implementation cycle without reopening its frozen
|
|||
contracts.
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=Stable v0.1.6 published; open 0.1.7 stabilization after the immutable release tag.
|
||||
CURRENT_OPERATIONAL_CURSOR=0.1.7 bugfix-only stabilization open after immutable stable v0.1.6.
|
||||
```
|
||||
|
||||
### 0.1.7 stabilization scope
|
||||
|
||||
The 0.1.7 line is restricted to observed bug fixes, regression fixes, small UI
|
||||
inconsistencies directly caused by a bug, documentation corrections, robustness,
|
||||
and tests associated with those corrections.
|
||||
|
||||
New features, new modules, architectural refactors not required to fix a bug,
|
||||
protocol evolution, new Android/Web/TUI capabilities, and speculative
|
||||
optimization are out of scope. Work outside this boundary waits for a later
|
||||
feature cycle rather than widening 0.1.7.
|
||||
|
||||
### Program rescheduling in the audited development line
|
||||
|
||||
Ordered Program rescheduling is implemented in the audited development line
|
||||
and deployed in the recorded private Program rollout. One explicit user action
|
||||
must select the starting session, restart date, affected successors, and usable
|
||||
slots; preview the old and new dates; then durably record one decision. Only
|
||||
unfinished sessions move, in Program position order. Explicitly ceded optional
|
||||
slots may absorb required sessions without marking optional sessions completed
|
||||
or removing historical identities. At most one principal Program session per
|
||||
selected slot is a rule for this action, not a limit on other same-day workouts.
|
||||
Rest days and Program bounds cannot be silently overridden. Preparations and
|
||||
in-progress executions require checks before applying the decision.
|
||||
|
||||
The result does not cascade again on display, import, replay, or Bluetooth
|
||||
reconnection. PC and Android must receive the same business state; a Web-only
|
||||
plan is insufficient. Compatibility requires proof in both supported exchange
|
||||
directions and across revisions or reexports, including explicit behavior for
|
||||
older clients unable to represent the decision. Payload acceptance alone does
|
||||
not establish convergence. This development contract does not reopen the
|
||||
Bluetooth transport, Sleep, HR, or general sync invariants. Before any real
|
||||
application, reread executions and preparations, confirm the restart date and
|
||||
slot choices, and preview the resulting plan again.
|
||||
|
||||
Sleep Diary V1 stays frozen/readable while active capable peers require
|
||||
`sleep-diary-v2`. The Web’s relative HR rise/fall overlays remain descriptive
|
||||
measured-series presentation only, never inferred sleep states or causes.
|
||||
|
|
@ -139,7 +181,8 @@ remaining per-installation opt-ins are deployment policy, not the v0.1.3 cursor.
|
|||
TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
|
||||
system, permanent Header/Footer, empty-data states and health status. The
|
||||
Dashboard slices below are complete; this does not authorize implementation of
|
||||
the remaining route placeholders.
|
||||
the then-remaining route placeholders; those routes are implemented on the
|
||||
audited 0.1.7 development line.
|
||||
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid,
|
||||
explicit edit mode, drag/resize, keyboard alternative and derived responsive
|
||||
projections without persisting layout state.
|
||||
|
|
@ -380,6 +423,20 @@ normal capture.
|
|||
|
||||
## Later
|
||||
|
||||
### Proposed 0.1.8 training assistance (not implemented)
|
||||
|
||||
Explore gradual, explained progression or maintaining a load from existing
|
||||
performance and subjective feedback. Compare exercises and equipment only when
|
||||
their context is genuinely comparable; account for effort, discomfort and
|
||||
missing observations. Explore requested or evidenced targeted strengthening,
|
||||
reasoned exercise diversity and dumbbell use without diagnosing weakness.
|
||||
Define cautious cardio reference points before any gradual introduction, with
|
||||
no maximal test or generated prescription from this documentation work. Reuse
|
||||
the existing medication diary, Sleep and Cardio facts as context without a
|
||||
second intake workflow or asserted medical causality. A human must validate
|
||||
any proposed training change before it alters a workout. These are ideas, not
|
||||
an implementation decision, delivery date, recovery score or clinical rule.
|
||||
|
||||
- evolve training analytics only where sufficient real history supports the
|
||||
metric, without aggregating incomparable loads;
|
||||
- add body baseline and trend comparisons while keeping direct measurements
|
||||
|
|
|
|||
63
docs/sleep_diary.md
Normal file
63
docs/sleep_diary.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Sleep Diary: capture, review and PDF
|
||||
|
||||
This guide describes the Web and Android behavior in the audited `f4ddcfd`
|
||||
0.1.7 development source. The [current state](current_state.md) owns rollout
|
||||
and validation status; [synchronization exchange](sync_exchange.md) owns the
|
||||
versioned Sleep companion. Sleep facts and medication intakes are not a medical
|
||||
interpretation or an automatic training decision.
|
||||
|
||||
## Capture and correction
|
||||
|
||||
Android's quick actions record bedtime (`Couché`), a waking interval
|
||||
(`Réveil`), final get-up (`Levé`) and time-stamped medication intakes. An intake
|
||||
may be recorded during the day or before bedtime. A pre-bed intake creates or
|
||||
reuses the same durable night/day entry; `Couché` later adds to that entry.
|
||||
There is no separate medicine log to fill out. Quantity is a count of the
|
||||
separately stored per-unit dose. Each meaningful edit advances the entry's
|
||||
causal revision; correction uses the existing entry rather than inventing
|
||||
another night. The Android owner and draft/quick-publication rules are detailed
|
||||
in [Android](android.md#sleep-diary-v1-and-active-v2-companion).
|
||||
|
||||
The Sleep day is assigned using the local 18:00-to-18:00 boundary. Each Web
|
||||
agenda row and its heart-rate detail use that night's exact offset and a
|
||||
24-hour elapsed timeline. A missing factual sleep interval may receive a
|
||||
clearly labelled presentation estimate when both bedtime and final get-up
|
||||
exist: bedtime plus 45 minutes through final get-up minus 10 minutes, minus
|
||||
structured waking overlap for the displayed duration. It does not write a
|
||||
Sleep fact. No missing heart-rate samples or sleep stages are manufactured.
|
||||
|
||||
## Review the agenda
|
||||
|
||||
In Analyse → Sommeil, the agenda is newest first. Its single display selector
|
||||
shows 7, 14, 21, 28 or All **recorded entries** per page; seven is the first-use
|
||||
default. These are row counts, not complete calendar weeks: seven entries can
|
||||
span more than seven dates. The choice is browser-local and survives reload;
|
||||
previous 5/10/15 choices migrate to 7/14/21. The other Analyse sections keep
|
||||
their own period control. Select a night to inspect and edit that entry and
|
||||
its measured heart-rate timeline. Paging does not discard the selected night,
|
||||
editor state, loaded statistics or PDF range.
|
||||
|
||||
Blue sport bands come from completed session start/end history, not the
|
||||
Program plan. A missing or invalid end is shown as a point marker with no
|
||||
invented duration. The Web agenda omits the redundant sport text list; the PDF
|
||||
retains a dated sport appendix. Sport does not become a Sleep event or change a
|
||||
sleep total. If the full read reaches its 3,660-night cap, the Web reports that
|
||||
completeness cannot be established instead of silently treating a truncated
|
||||
set as All. Missing data and load errors have explicit states.
|
||||
|
||||
## Preview and export
|
||||
|
||||
The vector PDF preview and download share an inclusive range of **night start
|
||||
dates**. That range is initialized from loaded history and is independent of
|
||||
both the visible agenda page and selected detail night. Sport is read completely
|
||||
before preview/export. The PDF uses the same 18:00 geometry as Web, prints
|
||||
factual sport intervals in blue, and lists sessions even when there is no
|
||||
matching Sleep row. Treatments and remarks wrap by available width and continue
|
||||
on dated pages instead of truncating silently. Review the chosen range before
|
||||
export; a PDF is a local copy of potentially sensitive information.
|
||||
|
||||
The descriptive heart-rate rise/fall overlays compare measured BPM with a
|
||||
recent reference. They do not infer sleep stages, causes, readiness or medical
|
||||
conditions. The source-backed projection and evidence limits are summarized in
|
||||
[current state](current_state.md#versions-and-compatibility) and the
|
||||
[documentation review](reviews/documentation_deep_review_v1.md).
|
||||
|
|
@ -32,8 +32,25 @@ offset-bearing timestamps, appreciations, notes, logical deletion, and an
|
|||
integer `quantity` in `1..99` on every medication intake. `dose_value` and
|
||||
`dose_unit` remain the per-unit snapshot; quantity is never encoded in notes.
|
||||
V1 input omits `quantity` and is normalized to one unit. Exact revision replay
|
||||
is unchanged; sibling revisions and resurrection after deletion are rejected
|
||||
in the generation consumption transaction.
|
||||
is unchanged; resurrection after deletion is rejected in the generation
|
||||
consumption transaction. For distinct V2 sibling tips of the same entry with
|
||||
an unchanged night envelope and deletion state, the last actual user edit
|
||||
wins, independent of device. Revision edit timestamps are compared as instants;
|
||||
event times, generation publication, import, and ACK times never establish
|
||||
priority. An equal edit instant or inconsistent provenance remains an explicit
|
||||
conflict. This selection uses the complete winning snapshot, not a field or
|
||||
event union, so an intentional removal is not resurrected. The losing revision
|
||||
remains immutable in local history. When the local snapshot wins, a fresh
|
||||
technical successor descends from the received revision and retains the
|
||||
winning user-edit timestamp and exact payload, allowing the other peer to
|
||||
advance causally without rewriting either original revision or generation.
|
||||
|
||||
A durable Sleep rejection keeps its exact ACK and does not advance either
|
||||
causal tip. The full-generation worker carries the consumer's bounded Sleep
|
||||
diagnostic through to the Web run state, where an unresolved concurrent
|
||||
revision is reported as `data_conflict` rather than an opaque `internal_error`.
|
||||
An old rejected generation remains rejected after a later, newly captured
|
||||
generation resolves its distinct conflict.
|
||||
|
||||
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal
|
||||
ownership.
|
||||
|
|
@ -244,6 +261,28 @@ does not publish to it or let it override an artifact in the new endpoint.
|
|||
|
||||
## 3. Artifact table
|
||||
|
||||
The following is a navigation matrix for the audited `f4ddcfd` source. A
|
||||
capability advertisement selects a companion; file presence alone is not an
|
||||
ACK or proof that every domain converged. The detailed versioned rules below
|
||||
remain authoritative.
|
||||
|
||||
| Domain | Producer → consumer | Format/version | Selection and persistence | ACK/proof and limit |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Completed sessions | Android ↔ desktop | `trainlog-mobile-export` V3; V1/V2 legacy input | Default automatic snapshot; stable IDs in separate SQLite stores | V3 import/replay is idempotent; this is not a full-generation ACK |
|
||||
| Manual preparations | Desktop → Android | `trainlog-session-preparations` V2 | Full generation on capable peer; revisions, deliveries and withdrawals retained | Correlated generation ACK closes delivery/withdrawal; an active draft is preserved |
|
||||
| Programs | Desktop → Android | `trainlog-programs` V1 or V2 | Exactly one selected by peer capability; V2 carries current dates, ceded state and revision sequence; desktop owns plan | Correlated ACK; V1-only peer cannot represent a rescheduled live plan, so Program convergence may remain incomplete while other domains proceed |
|
||||
| Program executions | Android → desktop | `trainlog-program-executions` V1 | Stable in-progress/completed provenance, separate from `trainlog-program` V1 desktop import | Generation validation and ACK; no invented completed session |
|
||||
| Sleep diary | Android ↔ desktop | `sleep-diary-v2`; frozen V1 readable | Current causal revision and immutable history; v2 carries intake quantity | Generation/ACK plus revision validation; concurrent ambiguous edits can reject |
|
||||
| Heart rate and Cardio | Android → desktop | Heart Rate V1, session timeline V1, cardio sessions/calibration/guidance V1; separate heart-rate corrections V1 | Factual captures and versioned companions, selected by capabilities | Correlated generation/ACK; no sensor or medical inference on desktop |
|
||||
| Exercise/equipment context | Both directions | Catalog, aliases, BODY ZONES and equipment companions below | Stable IDs, revision and causal rules apply per domain | A generation ACK does not override a per-domain conflict |
|
||||
|
||||
`trainlog-program` V1 is a strict desktop file import defined in
|
||||
[Program format V1](program_format_v1.md); it is never the synchronized
|
||||
`trainlog-programs` companion. `/api/v1` is the local Web API, not a JSON
|
||||
exchange format. Bluetooth, MTP and private Drive move bytes for the same
|
||||
full-generation business protocol; default V3 compatibility and installation
|
||||
opt-in remain separate from format capability.
|
||||
|
||||
| Direction | File | Format |
|
||||
| --- | --- | --- |
|
||||
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
|
||||
|
|
@ -260,6 +299,7 @@ does not publish to it or let it override an artifact in the new endpoint.
|
|||
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
|
||||
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
|
||||
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
|
||||
| PC -> Android | `programs-v2.json` | capability-selected `trainlog-programs` v2 companion: original and current dates, ceded state, ordered revision sequence, and the same deletion tombstones |
|
||||
| Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance |
|
||||
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||
| Android -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 |
|
||||
|
|
@ -282,6 +322,33 @@ V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does
|
|||
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
|
||||
semantics.
|
||||
|
||||
### Programs V2 capability matrix in the audited development line
|
||||
|
||||
`trainlog-programs` V2 is a separate strict artifact selected only for a peer
|
||||
advertising `programs-v2`. Its session adds `position`, nullable `current_for`
|
||||
and `planning_state=active|ceded`; its Program adds `revision_sequence`.
|
||||
`planned_for`, stable IDs, source entries and tombstones retain their V1
|
||||
meaning. One generation contains exactly one Programs version. Android v34
|
||||
stores the V2 projection transactionally and rejects a lower sequence, a
|
||||
same-sequence different revision, a later V1 downgrade, or a changed plan for
|
||||
its currently active Program draft. Execution facts return through the
|
||||
unchanged Program-executions V1 companion and retain their actual timestamps.
|
||||
|
||||
| Desktop state | Android capability | Program result | Other domains |
|
||||
|---|---|---|---|
|
||||
| No reschedule | `programs-v1` only | V1 original plan | Normal generation |
|
||||
| No reschedule | `programs-v2` | V2 projection; V1 is omitted | Normal generation |
|
||||
| Rescheduled live Program | `programs-v2` | V2 absolute current plan | Normal generation |
|
||||
| Rescheduled live Program | V1 only, absent or stale V2 | Program artifact omitted; run reports incomplete Program convergence after ACK | Other artifacts still synchronize |
|
||||
| Program draft started offline after the last V2 ACK | `programs-v2` | Android rejects a changed plan without rewriting its draft; explicit conflict resolution is required | Generation rejection is durable |
|
||||
|
||||
The desktop commit requires that the latest generation to each previously
|
||||
contacted Android peer was an acknowledged V2 Program generation no older than
|
||||
five minutes. This bounds, but cannot eliminate, a phone-side race: Android's
|
||||
active-draft check is the final guard. A failed or downgraded peer cannot be
|
||||
reported as current for the Program domain. The existing generation, request,
|
||||
ACK, tombstone, transport, Sleep and HR wire contracts are unchanged.
|
||||
|
||||
## Optional Program executions companion V1
|
||||
|
||||
`trainlog-program-executions` V1 is an Android-to-desktop full-generation
|
||||
|
|
@ -1067,7 +1134,7 @@ named by that validated reference. It does not recursively mirror retained
|
|||
Android generations, desktop publications, or unrelated exchange history.
|
||||
Local correlation is checked every 50 ms without USB work; complete MTP pulls
|
||||
are throttled to a three-second cadence. Each MTP operation has its own
|
||||
30-second ceiling while the conversation retains its configured global
|
||||
90-second ceiling while the conversation retains its configured global
|
||||
deadline. Exhausting the global deadline while waiting for a correlated
|
||||
reference reports `timeout waiting for correlated android-generation-v1.json`;
|
||||
only an adapter that exceeds its independent operation budget reports
|
||||
|
|
|
|||
|
|
@ -51,26 +51,29 @@ generalizes pairing to another phone or authorizes an unconfigured desktop.
|
|||
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
|
||||
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
|
||||
foreign-key checks.
|
||||
4. V3 is not an Android backup. Current Android has a complete verified backup
|
||||
container. For the installed schema-v17 release, build the pinned bridge
|
||||
candidate, verify signing/version continuity separately, install only with
|
||||
explicit approval, create and verify the user backup, and retain it before
|
||||
installing current Android. None of those device/user operations occurred in
|
||||
this software mission.
|
||||
4. V3 is not an Android backup. At the original September 2026 rollout, the
|
||||
installed schema-v17 phone required a pinned bridge candidate, verified
|
||||
signing/version continuity, and a complete retained user backup before the
|
||||
newer Android package. That is historical procedure evidence, not a claim
|
||||
about the currently installed phone schema or an instruction to repeat the
|
||||
bridge. A new rollout must inspect its actual versions and backups first.
|
||||
5. Replacing a binary does not roll back a schema. Before any exchange, a
|
||||
mutually compatible captured pair may be restored. After causal writes or
|
||||
ACKs, never restore only one peer: stop synchronization, preserve both stores
|
||||
and all artifacts, and use an explicit paired recovery plan.
|
||||
6. The eight-generation-per-peer ceiling is an admission gate. Exhaustion must
|
||||
preserve pending generations, tombstones, finalizations and ACK ledgers; it
|
||||
must never trigger hidden eviction.
|
||||
6. Per-peer retained-generation ceilings are admission gates: the audited
|
||||
desktop `sync_generation_exchange.py` permits 16 active outgoing entries;
|
||||
Android `SyncGenerationService` permits eight. Exhaustion preserves pending
|
||||
generations, tombstones, finalizations and ACK ledgers; it never triggers
|
||||
hidden eviction. These counts are not a bound on all transient staging or
|
||||
transport objects.
|
||||
|
||||
The grouped rollout approval covered the signed APK, desktop bundle, verified
|
||||
user backups, coordinated service quiescence, pairing, synthetic smoke and
|
||||
non-destructive hardware smoke. Those steps are complete for the recorded
|
||||
private installation. Drive remains separate and untested.
|
||||
|
||||
The latest software-only candidate set was rebuilt under the private root
|
||||
The software-only candidate recorded for the original rollout was rebuilt under the private root
|
||||
`/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit
|
||||
`dd8c91a946a9c836965818d8cc35b8f8bce74658`. Its desktop inventory records every
|
||||
packaged file digest plus schemas, protocols, entry points and runtime
|
||||
|
|
@ -84,7 +87,7 @@ synthetic schema-v17 backup SHA-256 is
|
|||
`06eb3999c6f3ce2fd8f551b38727a0a67fe6b48dde4c3244b3d50357afa7e6a3`.
|
||||
These are unsigned development evidence, not deployment authorization.
|
||||
|
||||
## Compatible set
|
||||
## Compatible set for the original rollout
|
||||
|
||||
- desktop binary containing schema v21, the Web API and the generation worker;
|
||||
- `sync_generation_exchange.py` and all matching transaction-neutral codecs;
|
||||
|
|
@ -110,11 +113,16 @@ These are unsigned development evidence, not deployment authorization.
|
|||
|
||||
Do not delete an unacknowledged generation, the last acknowledged generation,
|
||||
its previous valid generation, or causal evidence. An ACK timeout is unresolved,
|
||||
not rollback. Eight retained outgoing generations exhaust admission and require
|
||||
not rollback. Sixteen retained outgoing generations exhaust admission and require
|
||||
operator diagnosis; this tranche adds no tombstone or evidence garbage
|
||||
collection. A migrated database is recovered from its verified backup, never by
|
||||
assuming an older binary reverses its migration.
|
||||
|
||||
The audited MTP peer worker has a 90-second per-operation budget, further
|
||||
limited by the remaining conversation deadline. It is a timeout bound, not a
|
||||
success guarantee or proof that an interruption cause has disappeared.
|
||||
|
||||
The recorded private installation completed physical MTP, service coordination
|
||||
and APK/desktop deployment. Actual Drive configuration remains untested; the
|
||||
eight-generation admission bound and foreground-phone requirement remain.
|
||||
Android eight-generation admission bound and foreground-phone requirement
|
||||
remain; the audited desktop limit is 16.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,27 @@
|
|||
# Tests and validation
|
||||
|
||||
## Reading the evidence
|
||||
|
||||
Counts below belong to the stated checkpoint, source revision and build
|
||||
configuration. They are historical evidence, not a claim that the present
|
||||
documentation checkout ran those tests. A native build with `-Dweb=enabled`
|
||||
registers embedded Web asset tests in Meson; `npm test` separately runs Vitest
|
||||
against Web source. A default `web=auto` build can omit embedded assets, so a
|
||||
green source test alone does not validate the packaged Web application.
|
||||
Skipped Android fixture tests, unrun instrumented tests and physical Bluetooth,
|
||||
MTP or Drive evidence are distinct outcomes. The primary personal phone is
|
||||
reserved for authorized manual smoke checks, not host automation.
|
||||
|
||||
## Sleep V2 concurrent-edit selection in 0.1.7
|
||||
|
||||
Synthetic Desktop and Android regressions cover both edit-time orderings,
|
||||
including a later user edit that records an earlier sleep event. They check
|
||||
the complete winning snapshot, retained sibling revisions, no event/intake
|
||||
union or duplication, causal successor import on the other peer, exact replay,
|
||||
and rejection when edit instants are equal. The real paired-device validation
|
||||
completed separate automatic, Web-with-automatic-service, and post-reconnection
|
||||
Bluetooth generation/ACK cycles without changing an old rejected ACK.
|
||||
|
||||
## Sleep revision collision recovery
|
||||
|
||||
Collision regressions distinguish exact replay from same-ID parent or payload
|
||||
|
|
@ -388,6 +410,23 @@ 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.
|
||||
|
||||
The completed-sport Sleep overlay is covered by C read-only history
|
||||
intersection/pagination checks, Web pagination and failure-state tests, and
|
||||
pure geometry tests for 05:00 placement, midnight, 18:00 clipping, missing and
|
||||
invalid ends, and the Europe/Paris fall offset. PDF tests assert vector blue
|
||||
bands, black outlines, factual labels, and unchanged Sleep totals. Synthetic
|
||||
Firefox validation uses an isolated schema-v37 database and the embedded
|
||||
`-Dweb=enabled` binary, then inspects a downloaded PDF rendered by `pdftoppm`.
|
||||
Synthetic French and English reports of 7, 14, 21, and 30 nights are rasterized
|
||||
for page, margin, and monochrome inspection. No personal health artifact is
|
||||
stored in the repository.
|
||||
The private PC/Web deployment was checked separately after the rollout switch:
|
||||
the running executable and the served JavaScript/CSS hashes matched the
|
||||
`e2c07f1` bundle. Read-only Firefox inspection found 9 blue segments across
|
||||
11 existing Sleep rows; a downloaded three-page PDF contained the same sampled
|
||||
factual start time and a printable sport listing. Its private images and PDF
|
||||
remain outside the repository.
|
||||
|
||||
`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
|
||||
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
||||
activity map and direct totals, all compact/medium/large disclosure levels,
|
||||
|
|
|
|||
|
|
@ -51,5 +51,7 @@ Create, update and retirement first commit only to the desktop database. They
|
|||
do not call synchronization and do not require a phone. The next user-triggered
|
||||
normal synchronization reuses the existing PC catalogue, exercise profile
|
||||
state, BODY ZONES, equipment companions and causal deletion artifacts through
|
||||
the existing USB-priority engine with Drive as mirror/fallback. No Web
|
||||
the configured generation engine (Bluetooth primary with direct MTP recovery
|
||||
on the paired installation, or the separate USB/Drive workflow). Without that
|
||||
opt-in, the default V3 path retains its documented compatibility limits. No Web
|
||||
Exercises artifact or protocol version was introduced.
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ Inactive execution drafts and completed sessions use the existing causal
|
|||
operation/state boundary. Active or stale drafts conflict, and completed-session
|
||||
deletion records a finalization so older snapshots cannot recreate the session.
|
||||
|
||||
Programs are planning data owned by desktop schema v26. The Programs subtab
|
||||
Programs are planning data owned by desktop SQLite (introduced in v25 and
|
||||
extended through v37). The Programs subtab
|
||||
uses responsive cards and a timeline detail view backed by real imported
|
||||
definitions, sessions, usage, and provenance data. Import first runs the
|
||||
same strict Core validator in preview mode, then commits only after explicit
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
project(
|
||||
'trainlog',
|
||||
'c',
|
||||
version: '0.1.6',
|
||||
version: '0.1.7',
|
||||
meson_version: '>=1.3.0',
|
||||
default_options: [
|
||||
'c_std=c17',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from pathlib import Path
|
|||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import export_programs
|
||||
import export_programs_v2
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
|
|
@ -79,6 +80,41 @@ class ProgramsExchangeTest(unittest.TestCase):
|
|||
"requested_at": "2026-09-18T12:00:00Z",
|
||||
}], payload["deletions"])
|
||||
|
||||
def test_v2_carries_current_date_and_ceded_state_without_changing_v1(self):
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE program_session_planning(
|
||||
program_session_id TEXT PRIMARY KEY,current_for TEXT,
|
||||
state TEXT,operation_id TEXT,updated_at TEXT);
|
||||
CREATE TABLE program_revision_sequences(
|
||||
program_id TEXT PRIMARY KEY,revision_sequence INTEGER NOT NULL);
|
||||
INSERT INTO program_revision_sequences VALUES('pg_live',1);
|
||||
INSERT INTO program_session_planning VALUES(
|
||||
'pgs_live','2026-10-01','active','op_fixture','2026-09-30T12:00:00Z');
|
||||
PRAGMA user_version=37;
|
||||
""")
|
||||
db.commit()
|
||||
initial = export_programs.export_programs(self.database)
|
||||
current = export_programs_v2.export_programs_v2(self.database)
|
||||
self.assertEqual(1, initial["version"])
|
||||
self.assertEqual(2, current["version"])
|
||||
original = initial["programs"][0]["sessions"][0]
|
||||
planned = current["programs"][0]["sessions"][0]
|
||||
self.assertIsNone(original["planned_for"])
|
||||
self.assertEqual(set(original), {
|
||||
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences"
|
||||
})
|
||||
self.assertEqual("2026-10-01", planned["current_for"])
|
||||
self.assertEqual(1, current["programs"][0]["revision_sequence"])
|
||||
self.assertEqual("active", planned["planning_state"])
|
||||
self.assertEqual(0, planned["position"])
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.execute("UPDATE program_session_planning SET current_for=NULL,state='ceded'")
|
||||
db.commit()
|
||||
ceded = export_programs_v2.export_programs_v2(self.database)
|
||||
self.assertIsNone(ceded["programs"][0]["sessions"][0]["current_for"])
|
||||
self.assertEqual("ceded", ceded["programs"][0]["sessions"][0]["planning_state"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -246,6 +246,131 @@ class SleepExchangeTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(ValueError, "positive"):
|
||||
exchange.validate(self.document(invalid))
|
||||
|
||||
def test_contradictory_final_get_up_siblings_remain_unresolved(self):
|
||||
base = "slr_20000000-0000-4000-8000-000000000002"
|
||||
desktop_tip = "slr_40000000-0000-4000-8000-000000000004"
|
||||
android_parent = "slr_50000000-0000-4000-8000-000000000005"
|
||||
android_tip = "slr_60000000-0000-4000-8000-000000000006"
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(base))))
|
||||
|
||||
desktop = entry(desktop_tip, base)
|
||||
desktop["events"].append({
|
||||
"event_id": "sle_70000000-0000-4000-8000-000000000007",
|
||||
"type": "final_get_up", "start_at": "2026-10-25T06:30:00+01:00",
|
||||
"end_at": None,
|
||||
})
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(desktop)))
|
||||
before = self.db.execute(
|
||||
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions "
|
||||
"ORDER BY revision_id"
|
||||
).fetchall()
|
||||
|
||||
android = entry(android_tip, android_parent)
|
||||
android["ancestry"] = [android_tip, android_parent, base]
|
||||
android["events"][0]["end_at"] = "2026-10-25T06:50:00+01:00"
|
||||
android["events"].append({
|
||||
"event_id": "sle_80000000-0000-4000-8000-000000000008",
|
||||
"type": "final_get_up", "start_at": "2026-10-25T06:50:00+01:00",
|
||||
"end_at": None,
|
||||
})
|
||||
with self.assertRaisesRegex(ValueError, "concurrent sleep diary revision"):
|
||||
exchange.apply(self.db, self.document(android))
|
||||
|
||||
self.assertEqual(before, self.db.execute(
|
||||
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions "
|
||||
"ORDER BY revision_id"
|
||||
).fetchall())
|
||||
self.assertEqual(desktop_tip, self.db.execute(
|
||||
"SELECT current_revision_id FROM sleep_diary_entries"
|
||||
).fetchone()[0])
|
||||
self.assertEqual(1, self.db.execute(
|
||||
"SELECT COUNT(*) FROM sleep_diary_events WHERE revision_id=? "
|
||||
"AND event_type='final_get_up'", (desktop_tip,)
|
||||
).fetchone()[0])
|
||||
|
||||
def test_v2_newer_local_edit_converges_without_using_event_time(self):
|
||||
base = "slr_20000000-0000-4000-8000-000000000002"
|
||||
local_id = "slr_40000000-0000-4000-8000-000000000004"
|
||||
remote_id = "slr_50000000-0000-4000-8000-000000000005"
|
||||
|
||||
def v2(item):
|
||||
document = self.document(item)
|
||||
document["version"] = 2
|
||||
document["entries"][0]["intakes"][0]["quantity"] = 1
|
||||
return document
|
||||
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, v2(entry(base))))
|
||||
self.db.commit()
|
||||
peer = sqlite3.connect(":memory:")
|
||||
self.db.backup(peer)
|
||||
try:
|
||||
local = entry(local_id, base)
|
||||
local["updated_at"] = "2026-10-25T20:00:00+01:00"
|
||||
local["events"][0]["end_at"] = "2026-10-25T06:00:00+01:00"
|
||||
remote = entry(remote_id, base)
|
||||
remote["updated_at"] = "2026-10-25T19:00:00+01:00"
|
||||
remote["events"][0]["end_at"] = "2026-10-25T07:00:00+01:00"
|
||||
remote["events"].append({
|
||||
"event_id": "sle_60000000-0000-4000-8000-000000000006",
|
||||
"type": "final_get_up",
|
||||
"start_at": "2026-10-25T07:00:00+01:00",
|
||||
"end_at": None,
|
||||
})
|
||||
extra_intake = dict(remote["intakes"][0])
|
||||
extra_intake["intake_id"] = "mdi_60000000-0000-4000-8000-000000000006"
|
||||
extra_intake["quantity"] = 2
|
||||
remote["intakes"].append(extra_intake)
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, v2(local)))
|
||||
self.assertEqual((1, 0), exchange.apply(peer, v2(remote)))
|
||||
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, exchange.build(peer)))
|
||||
successor = exchange.build(self.db)["entries"][0]
|
||||
self.assertEqual(remote_id, successor["parent_revision_id"])
|
||||
self.assertEqual(local["events"], successor["events"])
|
||||
self.assertEqual(1, len(successor["events"]))
|
||||
self.assertEqual(local["updated_at"], successor["updated_at"])
|
||||
self.assertEqual(1, len(successor["intakes"]))
|
||||
self.assertEqual(1, successor["intakes"][0]["quantity"])
|
||||
self.assertEqual((0, 1), exchange.apply(self.db, exchange.build(peer)))
|
||||
self.assertEqual((1, 0), exchange.apply(peer, exchange.build(self.db)))
|
||||
self.assertEqual((0, 1), exchange.apply(peer, exchange.build(self.db)))
|
||||
self.assertEqual(
|
||||
successor["revision_id"],
|
||||
exchange.build(peer)["entries"][0]["revision_id"],
|
||||
)
|
||||
self.assertEqual(4, self.db.execute(
|
||||
"SELECT COUNT(*) FROM sleep_diary_revisions"
|
||||
).fetchone()[0])
|
||||
self.assertEqual("ok", self.db.execute("PRAGMA integrity_check").fetchone()[0])
|
||||
finally:
|
||||
peer.close()
|
||||
|
||||
def test_v2_newer_remote_edit_wins_and_equal_time_rejects(self):
|
||||
base = "slr_20000000-0000-4000-8000-000000000002"
|
||||
local = entry("slr_40000000-0000-4000-8000-000000000004", base)
|
||||
remote = entry("slr_50000000-0000-4000-8000-000000000005", base)
|
||||
|
||||
def v2(item):
|
||||
document = self.document(item)
|
||||
document["version"] = 2
|
||||
document["entries"][0]["intakes"][0]["quantity"] = 1
|
||||
return document
|
||||
|
||||
exchange.apply(self.db, v2(entry(base)))
|
||||
local["updated_at"] = "2026-10-25T19:00:00+01:00"
|
||||
remote["updated_at"] = "2026-10-25T20:00:00+01:00"
|
||||
local["events"][0]["end_at"] = "2026-10-25T07:00:00+01:00"
|
||||
remote["events"][0]["end_at"] = "2026-10-25T06:00:00+01:00"
|
||||
exchange.apply(self.db, v2(local))
|
||||
tied = entry("slr_60000000-0000-4000-8000-000000000006", base)
|
||||
tied["updated_at"] = local["updated_at"]
|
||||
with self.assertRaisesRegex(ValueError, "indeterminate edit order"):
|
||||
exchange.apply(self.db, v2(tied))
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, v2(remote)))
|
||||
self.assertEqual(remote["revision_id"], exchange.build(self.db)["entries"][0]["revision_id"])
|
||||
self.assertEqual(remote["events"], exchange.build(self.db)["entries"][0]["events"])
|
||||
self.assertEqual((0, 1), exchange.apply(self.db, v2(local)))
|
||||
|
||||
def test_draft_is_durable_but_not_exported_until_validated(self):
|
||||
revision = "slr_20000000-0000-4000-8000-000000000002"
|
||||
item = entry(revision)
|
||||
|
|
|
|||
|
|
@ -76,11 +76,12 @@ 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"], 36)
|
||||
self.assertEqual(inventory["android_schema"], 32)
|
||||
self.assertEqual(inventory["product_version"], "0.1.7")
|
||||
self.assertEqual(inventory["desktop_schema"], 37)
|
||||
self.assertEqual(inventory["android_schema"], 34)
|
||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||
self.assertIn("programs-v1", inventory["protocols"])
|
||||
self.assertIn("programs-v2", inventory["protocols"])
|
||||
self.assertIn("program-executions-v1", inventory["protocols"])
|
||||
self.assertIn("sleep-diary-v2", inventory["protocols"])
|
||||
self.assertIn("cardio-sessions-v1", inventory["protocols"])
|
||||
|
|
@ -114,7 +115,7 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
text=True,
|
||||
)
|
||||
self.assertEqual(linked.returncode, 0, linked.stderr)
|
||||
self.assertIn("trainlog 0.1.6", linked.stdout)
|
||||
self.assertIn("trainlog 0.1.7", linked.stdout)
|
||||
imported = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
|
|
|
|||
|
|
@ -128,21 +128,25 @@ class SyncErrorClassificationTest(unittest.TestCase):
|
|||
)
|
||||
|
||||
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
|
||||
timeout = subprocess.TimeoutExpired(["adapter", "push"], 30)
|
||||
operation_budget = sync_peer_worker.MTP_OPERATION_TIMEOUT_SECONDS
|
||||
timeout = subprocess.TimeoutExpired(
|
||||
["adapter", "push"], operation_budget,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
transport = Path(directory) / "transport"
|
||||
transport.mkdir()
|
||||
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
r"^transport_timeout: MTP push did not finish within 30 seconds$",
|
||||
rf"^transport_timeout: MTP push did not finish within "
|
||||
rf"{operation_budget:g} seconds$",
|
||||
) as caught:
|
||||
sync_peer_worker.run_adapter(
|
||||
Path("/adapter"),
|
||||
"push",
|
||||
"peer_00000000-0000-4000-8000-000000000001",
|
||||
transport,
|
||||
sync_peer_worker.time.monotonic() + 60,
|
||||
sync_peer_worker.time.monotonic() + operation_budget + 10,
|
||||
)
|
||||
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")
|
||||
|
||||
|
|
@ -159,6 +163,12 @@ class SyncErrorClassificationTest(unittest.TestCase):
|
|||
sync_orchestrator.failure_code(RuntimeError("revision conflict")),
|
||||
"data_conflict",
|
||||
)
|
||||
self.assertEqual(
|
||||
sync_orchestrator.failure_code(RuntimeError(
|
||||
"desktop rejected Android generation: concurrent sleep diary revision"
|
||||
)),
|
||||
"data_conflict",
|
||||
)
|
||||
|
||||
def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
|
|||
|
|
@ -71,6 +71,58 @@ 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_rescheduled_program_requires_v2_capability_before_publication(self):
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE program_session_planning(
|
||||
program_session_id TEXT PRIMARY KEY,current_for TEXT,state TEXT);
|
||||
CREATE TABLE programs(program_id TEXT PRIMARY KEY,deleted_at TEXT);
|
||||
CREATE TABLE program_sessions(
|
||||
program_session_id TEXT PRIMARY KEY,program_id TEXT);
|
||||
INSERT INTO programs VALUES('pg_fixture',NULL);
|
||||
INSERT INTO program_sessions VALUES('pgs_fixture','pg_fixture');
|
||||
INSERT INTO program_session_planning VALUES('pgs_fixture','2026-10-01','active');
|
||||
PRAGMA user_version=37;
|
||||
""")
|
||||
db.commit()
|
||||
artifacts = (
|
||||
("history", "trainlog-mobile-export", 4, "history.json", True,
|
||||
"controlled.py", ()),
|
||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||
"export_programs.py", ()),
|
||||
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
|
||||
"export_programs_v2.py", ()),
|
||||
)
|
||||
|
||||
def exporter(tool, _extra, output, _snapshot):
|
||||
if tool == "controlled.py":
|
||||
output.write_text(json.dumps({"format": "trainlog-mobile-export", "version": 4}))
|
||||
return
|
||||
output.write_text(json.dumps({"format": "trainlog-programs",
|
||||
"version": 2 if tool.endswith("_v2.py") else 1,
|
||||
"programs": [], "deletions": []}))
|
||||
|
||||
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
|
||||
mock.patch.object(generation, "run_export", exporter):
|
||||
with self.assertRaisesRegex(generation.GenerationError, "Programs V2 capability"):
|
||||
generation.capture_desktop(self.database, self.root / "legacy", PEER_B, RUN, GEN)
|
||||
self.assertFalse((self.root / "legacy" / "staging" / GEN).exists())
|
||||
legacy_stage, legacy_manifest, _ = generation.capture_desktop(
|
||||
self.database, self.root / "legacy-domain-only", PEER_B, RUN, GEN,
|
||||
set(), True,
|
||||
)
|
||||
self.assertFalse((legacy_stage / "programs-v1.json").exists())
|
||||
self.assertEqual(["history"], [a["logical_name"] for a in legacy_manifest["artifacts"]])
|
||||
stage, manifest, _ = generation.capture_desktop(
|
||||
self.database, self.root / "modern", PEER_B, RUN,
|
||||
"gen_55555555-5555-4555-8555-555555555555",
|
||||
{"programs-v2"},
|
||||
)
|
||||
self.assertTrue((stage / "programs-v2.json").is_file())
|
||||
self.assertFalse((stage / "programs-v1.json").exists())
|
||||
self.assertEqual(["history", "programs-v2"],
|
||||
[a["logical_name"] for a in manifest["artifacts"]])
|
||||
|
||||
def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
|
||||
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,19 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_rejected_android_generation_retains_consumer_diagnostic(self):
|
||||
error = worker.rejected_android_generation_error(
|
||||
{"diagnostic": "concurrent sleep diary revision"}
|
||||
)
|
||||
self.assertEqual(
|
||||
str(error),
|
||||
"desktop rejected Android generation: concurrent sleep diary revision",
|
||||
)
|
||||
self.assertEqual(
|
||||
str(worker.rejected_android_generation_error({"diagnostic": ""})),
|
||||
"desktop rejected Android generation",
|
||||
)
|
||||
|
||||
def test_drive_adapter_uses_remaining_conversation_deadline(self):
|
||||
completed = subprocess.CompletedProcess([], 0, b"", b"")
|
||||
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
||||
|
|
@ -45,7 +58,8 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
self.transport, 100.0, clock=lambda: 50.0,
|
||||
self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0,
|
||||
clock=lambda: 50.0,
|
||||
)
|
||||
self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"])
|
||||
|
||||
|
|
@ -99,7 +113,8 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
self.transport, 100.0, clock=lambda: 50.0,
|
||||
self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0,
|
||||
clock=lambda: 50.0,
|
||||
)
|
||||
|
||||
def test_delayed_correlated_generation_survives_multiple_polls(self):
|
||||
|
|
|
|||
|
|
@ -126,6 +126,73 @@ class SyncdRoutingTest(unittest.TestCase):
|
|||
self.assertEqual(1, len(commands))
|
||||
self.assertEqual("sy_old", json.loads(state.read_text())["run_id"])
|
||||
|
||||
def test_transient_terminal_request_remains_eligible_after_reconnect(self):
|
||||
terminal_states = (
|
||||
("failed", "device_unavailable"),
|
||||
("failed", "sync_in_progress"),
|
||||
("failed", "transport_timeout"),
|
||||
("interrupted", "interrupted"),
|
||||
)
|
||||
for phase, error_code in terminal_states:
|
||||
with (
|
||||
self.subTest(phase=phase, error_code=error_code),
|
||||
tempfile.TemporaryDirectory() as directory,
|
||||
):
|
||||
root = Path(directory)
|
||||
request = "sr_22222222-2222-4222-8222-222222222222"
|
||||
state = root / "trainlog.db.sync-run.json"
|
||||
state.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"phase": phase,
|
||||
"result": phase,
|
||||
"error_code": error_code,
|
||||
"request_id": request,
|
||||
"seen_request_ids": [request],
|
||||
}
|
||||
)
|
||||
)
|
||||
self._write_request(
|
||||
root / syncd.FULL_GENERATION_REQUEST_NAME,
|
||||
request,
|
||||
"2026-09-25T07:49:01Z",
|
||||
)
|
||||
|
||||
_, seen = syncd.load_request_state(state)
|
||||
|
||||
self.assertNotIn(request, seen)
|
||||
self.assertEqual(
|
||||
(request, "full_generation", []),
|
||||
syncd.select_request(root, seen),
|
||||
)
|
||||
|
||||
def test_non_transient_terminal_request_remains_consumed(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
request = "sr_22222222-2222-4222-8222-222222222222"
|
||||
state = root / "trainlog.db.sync-run.json"
|
||||
state.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"phase": "failed",
|
||||
"result": "failed",
|
||||
"error_code": "data_conflict",
|
||||
"request_id": request,
|
||||
"seen_request_ids": [request],
|
||||
}
|
||||
)
|
||||
)
|
||||
self._write_request(
|
||||
root / syncd.FULL_GENERATION_REQUEST_NAME,
|
||||
request,
|
||||
"2026-09-25T07:49:01Z",
|
||||
)
|
||||
|
||||
_, seen = syncd.load_request_state(state)
|
||||
|
||||
self.assertIn(request, seen)
|
||||
self.assertIsNone(syncd.select_request(root, seen))
|
||||
|
||||
def test_daemon_skips_mtp_probe_while_transport_lock_is_busy(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
fail("desktop schema v33-v36 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36, 37):
|
||||
fail("desktop schema v33-v37 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
fail("desktop schema v34-v36 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36, 37):
|
||||
fail("desktop schema v34-v37 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
raise CausalError(f"desktop schema v20-v36 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, 37):
|
||||
raise CausalError(f"desktop schema v20-v37 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
raise LifecycleError(f"desktop schema v19-v36 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, 37):
|
||||
raise LifecycleError(f"desktop schema v19-v37 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -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, 36)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
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-v36 requis")
|
||||
raise ValueError("schema desktop v15-v37 requis")
|
||||
|
||||
root = {
|
||||
"format": "TRAINLOG_AI_EXPORT",
|
||||
|
|
|
|||
|
|
@ -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, 36)
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v18-v36 requis")
|
||||
raise ValueError("schema desktop v18-v37 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = tuple(range(8, 37))
|
||||
supported_versions = tuple(range(8, 38))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v36 requis")
|
||||
raise ValueError("schema desktop v8 à v37 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"))
|
||||
|
|
|
|||
|
|
@ -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, 37))
|
||||
supported_versions = tuple(range(8, 38))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v36 requis")
|
||||
raise ValueError("schema desktop v8 à v37 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(
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
raise ValueError("schema desktop v12-v36 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, 37):
|
||||
raise ValueError("schema desktop v12-v37 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -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, 37))
|
||||
supported_versions = tuple(range(11, 38))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v11-v36 requis")
|
||||
raise ValueError("schema desktop v11-v37 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 = []
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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, 37):
|
||||
if version not in range(8, 38):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
supported_versions = range(11, 37)
|
||||
supported_versions = range(11, 38)
|
||||
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-v36 requis (v10 accepté pour export V2 explicite)")
|
||||
raise ValueError("schema desktop v11-v37 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")
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise ValueError("desktop schema v26 through v32 required")
|
||||
programs = []
|
||||
for row in db.execute(
|
||||
|
|
|
|||
59
tools/export_programs_v2.py
Normal file
59
tools/export_programs_v2.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export the versioned desktop-owned Program planning projection."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from export_programs import export_programs
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
def export_programs_v2(database: Path) -> dict:
|
||||
# WHY: V1's strict session fields are frozen. V2 carries absolute current
|
||||
# dates and ceded-slot state without changing the imported source date.
|
||||
value = export_programs(database)
|
||||
with closing(connect_database(database)) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 37:
|
||||
raise ValueError("desktop schema v37 required for Programs V2")
|
||||
for program in value["programs"]:
|
||||
sequence = db.execute(
|
||||
"SELECT revision_sequence FROM program_revision_sequences WHERE program_id=?",
|
||||
(program["program_id"],),
|
||||
).fetchone()
|
||||
program["revision_sequence"] = sequence[0] if sequence is not None else 0
|
||||
planning = {
|
||||
row[0]: (row[1], row[2])
|
||||
for row in db.execute(
|
||||
"SELECT p.program_session_id,p.current_for,p.state "
|
||||
"FROM program_session_planning p JOIN program_sessions s "
|
||||
"ON s.program_session_id=p.program_session_id WHERE s.program_id=?",
|
||||
(program["program_id"],),
|
||||
)
|
||||
}
|
||||
for position, session in enumerate(program["sessions"]):
|
||||
current, state = planning.get(
|
||||
session["program_session_id"], (session["planned_for"], "active")
|
||||
)
|
||||
session["position"] = position
|
||||
session["current_for"] = current
|
||||
session["planning_state"] = state
|
||||
value["version"] = 2
|
||||
return value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.output.write_text(
|
||||
json.dumps(export_programs_v2(args.database), ensure_ascii=False,
|
||||
sort_keys=True, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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, 36)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("desktop schema v24, v25, v26 through v32 required")
|
||||
deliveries = []
|
||||
|
|
|
|||
|
|
@ -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, 37)
|
||||
supported_versions = range(15, 38)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v15-v36 requis")
|
||||
raise ValueError("schema desktop v15-v37 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()
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ def split_target(value: str) -> tuple[str, str]:
|
|||
|
||||
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}")
|
||||
if version not in range(27, 38):
|
||||
raise CorrectionError(f"heart-rate correction requires schema v27-v37, found v{version}")
|
||||
|
||||
|
||||
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -282,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, 36):
|
||||
fail("desktop schema v30-v36 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v30-v37 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -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, 37)
|
||||
supported_versions = range(18, 38)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v18-v36 requis")
|
||||
raise ImportFailure("schema desktop v18-v37 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")
|
||||
|
|
|
|||
|
|
@ -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, 37)
|
||||
supported_versions = range(8, 38)
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v36 requis")
|
||||
fail("schema desktop v8 à v37 requis")
|
||||
proof_path = args.mobile_export
|
||||
if proof_path is None:
|
||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||
|
|
|
|||
|
|
@ -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, 37)
|
||||
supported_versions = range(8, 38)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v36 requis")
|
||||
fail("schema desktop v8 à v37 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
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
supported_versions = range(12, 37)
|
||||
supported_versions = range(12, 38)
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v12-v36 requis")
|
||||
fail("schema desktop v12-v37 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}")
|
||||
|
|
|
|||
|
|
@ -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, 37)
|
||||
supported_versions = range(11, 38)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v11-v36 requis")
|
||||
raise ImportFailure("schema desktop v11-v37 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
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
fail("schema desktop v17-v36 requis")
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("schema desktop v17-v37 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -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, 37):
|
||||
if version not in range(5, 38):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v36 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v37 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, 36):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36")
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v37")
|
||||
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")}
|
||||
|
|
|
|||
|
|
@ -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, 37)
|
||||
supported_versions = range(15, 38)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v15-v36 requis")
|
||||
fail("schema desktop v15-v37 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"]):
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ RUNTIME_TOOLS = [
|
|||
"post_sync_ai_drive.py",
|
||||
"export_session_preparations.py",
|
||||
"export_programs.py",
|
||||
"export_programs_v2.py",
|
||||
"program_execution_exchange.py",
|
||||
]
|
||||
|
||||
|
|
@ -81,6 +82,14 @@ def main() -> int:
|
|||
raise RuntimeError("compiled generation MTP adapter is missing")
|
||||
if not sync_once.is_file():
|
||||
raise RuntimeError("compiled legacy synchronization helper is missing")
|
||||
# WHY: web=auto can build a native executable without any embedded UI.
|
||||
# CONTRACT: a rollout bundle must contain the frontend it will serve.
|
||||
build_options = json.loads(subprocess.check_output(
|
||||
["meson", "introspect", "--buildoptions", str(args.build)], text=True
|
||||
))
|
||||
web = next((option["value"] for option in build_options if option["name"] == "web"), None)
|
||||
if web != "enabled":
|
||||
raise RuntimeError("candidate requires an explicitly embedded Web frontend")
|
||||
(args.output / "bin").mkdir(parents=True)
|
||||
(args.output / "libexec").mkdir(parents=True)
|
||||
(args.output / "tools").mkdir(parents=True)
|
||||
|
|
@ -119,9 +128,9 @@ def main() -> int:
|
|||
"source_commit": subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.6",
|
||||
"desktop_schema": 36,
|
||||
"android_schema": 32,
|
||||
"product_version": "0.1.7",
|
||||
"desktop_schema": 37,
|
||||
"android_schema": 34,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
"mobile-history-v4",
|
||||
|
|
@ -135,6 +144,7 @@ def main() -> int:
|
|||
"session-preparations-v2",
|
||||
"ai-session-drafts-v2",
|
||||
"programs-v1",
|
||||
"programs-v2",
|
||||
"program-executions-v1",
|
||||
"sleep-diary-v2",
|
||||
"cardio-sessions-v1",
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v27 through v32 required")
|
||||
added = 0
|
||||
advanced = 0
|
||||
|
|
|
|||
|
|
@ -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, 36):
|
||||
fail("desktop schema v31-v36 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v31-v37 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import argparse
|
|||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -378,6 +379,42 @@ def revision_is_ancestor(db: sqlite3.Connection, parent_sql: str, owner_id: str,
|
|||
return False
|
||||
|
||||
|
||||
def concurrent_entry_winner(db: sqlite3.Connection, item: dict, local_revision: str,
|
||||
wire_version: int) -> dict | None:
|
||||
"""Choose a complete Sleep snapshot only when user-edit instants are ordered.
|
||||
|
||||
WHY: an import or ACK is not a new user edit. Revision creation times are
|
||||
recorded by the editing clients, whereas generation times are transport
|
||||
times. CONTRACT: only V2 sibling tips with the same entry envelope and no
|
||||
deletion transition admit last-user-edit-wins. Equal or inconsistent
|
||||
timestamps remain conflicts, never a peer-order tie break.
|
||||
"""
|
||||
if wire_version != 2:
|
||||
fail("concurrent sleep diary revision")
|
||||
row = db.execute(
|
||||
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted,"
|
||||
"r.created_at FROM sleep_diary_entries e JOIN sleep_diary_revisions r "
|
||||
"ON r.revision_id=e.current_revision_id WHERE e.entry_id=? AND r.revision_id=?",
|
||||
(item["entry_id"], local_revision),
|
||||
).fetchone()
|
||||
if (row is None or tuple(row[:3]) !=
|
||||
(item["night_start_date"], item["night_end_date"], item["created_at"]) or
|
||||
bool(row[4]) != item["deleted"] or row[3] != row[5]):
|
||||
fail("concurrent sleep diary revision")
|
||||
local_at = datetime.fromisoformat(timestamp(row[5]).replace("Z", "+00:00"))
|
||||
remote_at = datetime.fromisoformat(timestamp(item["updated_at"]).replace("Z", "+00:00"))
|
||||
if local_at == remote_at:
|
||||
fail("concurrent sleep diary revision has indeterminate edit order")
|
||||
if local_at < remote_at:
|
||||
return None
|
||||
current = db.execute(
|
||||
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,"
|
||||
"current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
||||
(item["entry_id"],),
|
||||
).fetchone()
|
||||
return _persisted_entry(db, current)
|
||||
|
||||
|
||||
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||
validate(root)
|
||||
wire_version = root["version"]
|
||||
|
|
@ -484,8 +521,14 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
|||
unchanged += 1
|
||||
continue
|
||||
remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:]
|
||||
winning_local = None
|
||||
if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]:
|
||||
fail("concurrent sleep diary revision")
|
||||
winning_local = concurrent_entry_winner(db, item, local[0], wire_version)
|
||||
if known_owner is not None and winning_local is not None:
|
||||
# A previously retained losing branch cannot cause a fresh
|
||||
# reconciliation revision on every immutable replay.
|
||||
unchanged += 1
|
||||
continue
|
||||
# WHY: one-tap Sleep creates several local revisions before its first
|
||||
# synchronization. The current snapshot carries their bounded lineage
|
||||
# by identity, so a new desktop must be able to seed the tip without
|
||||
|
|
@ -505,17 +548,30 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
|||
"current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
|
||||
(item["night_start_date"], item["night_end_date"], item["updated_at"],
|
||||
item["revision_id"], int(item["deleted"]), item["entry_id"], local[0]))
|
||||
db.execute("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
|
||||
(item["revision_id"], item["entry_id"], item["parent_revision_id"], item["updated_at"],
|
||||
item["sleep_quality"], item["wake_quality"], item["day_form"], item["treatment_and_notes"]))
|
||||
db.executemany("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
|
||||
[(item["revision_id"], event["event_id"], event["type"], event["start_at"], event["end_at"])
|
||||
for event in item["events"]])
|
||||
db.executemany("INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
[(item["revision_id"], intake["intake_id"], intake["medication_id"],
|
||||
intake["medication_name"], intake["taken_at"], intake["dose_value"],
|
||||
intake["dose_unit"], intake.get("quantity", 1), intake["note"], intake["created_at"])
|
||||
for intake in item["intakes"]])
|
||||
if known_owner is None:
|
||||
db.execute(
|
||||
"INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,"
|
||||
"created_at,sleep_quality,wake_quality,day_form,treatment_and_notes) "
|
||||
"VALUES(?,?,?,?,?,?,?,?)",
|
||||
(item["revision_id"], item["entry_id"], item["parent_revision_id"],
|
||||
item["updated_at"], item["sleep_quality"], item["wake_quality"],
|
||||
item["day_form"], item["treatment_and_notes"]),
|
||||
)
|
||||
db.executemany(
|
||||
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) "
|
||||
"VALUES(?,?,?,?,?)",
|
||||
[(item["revision_id"], event["event_id"], event["type"],
|
||||
event["start_at"], event["end_at"]) for event in item["events"]],
|
||||
)
|
||||
db.executemany(
|
||||
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,"
|
||||
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) "
|
||||
"VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
[(item["revision_id"], intake["intake_id"], intake["medication_id"],
|
||||
intake["medication_name"], intake["taken_at"], intake["dose_value"],
|
||||
intake["dose_unit"], intake.get("quantity", 1), intake["note"],
|
||||
intake["created_at"]) for intake in item["intakes"]],
|
||||
)
|
||||
# An imported revision has already crossed the synchronization
|
||||
# boundary; recording both markers prevents it appearing as a draft.
|
||||
db.execute(
|
||||
|
|
@ -527,6 +583,45 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
|||
(item["entry_id"], item["revision_id"], root["generated_at"],
|
||||
item["revision_id"], root["generated_at"]),
|
||||
)
|
||||
if winning_local is not None:
|
||||
successor = "slr_" + str(uuid.uuid4())
|
||||
# INVARIANT: the successor's edit timestamp remains the winning
|
||||
# user edit, not the later technical reconciliation time. Its
|
||||
# complete payload is copied exactly; the losing branch stays
|
||||
# immutable and addressable by its original revision identity.
|
||||
db.execute(
|
||||
"INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,"
|
||||
"created_at,sleep_quality,wake_quality,day_form,treatment_and_notes) "
|
||||
"VALUES(?,?,?,?,?,?,?,?)",
|
||||
(successor, item["entry_id"], item["revision_id"],
|
||||
winning_local["updated_at"], winning_local["sleep_quality"],
|
||||
winning_local["wake_quality"], winning_local["day_form"],
|
||||
winning_local["treatment_and_notes"]),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) "
|
||||
"SELECT ?,event_id,event_type,start_at,end_at "
|
||||
"FROM sleep_diary_events WHERE revision_id=?",
|
||||
(successor, winning_local["revision_id"]),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,"
|
||||
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) "
|
||||
"SELECT ?,intake_id,medication_id,"
|
||||
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at "
|
||||
"FROM sleep_medication_intakes WHERE revision_id=?",
|
||||
(successor, winning_local["revision_id"]),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE sleep_diary_entries SET updated_at=?,current_revision_id=? "
|
||||
"WHERE entry_id=? AND current_revision_id=?",
|
||||
(winning_local["updated_at"], successor, item["entry_id"], item["revision_id"]),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE sleep_diary_publication_state SET validated_revision_id=?,"
|
||||
"validated_at=? WHERE entry_id=?",
|
||||
(successor, root["generated_at"], item["entry_id"]),
|
||||
)
|
||||
applied += 1
|
||||
return applied, unchanged
|
||||
|
||||
|
|
@ -542,8 +637,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, 36):
|
||||
fail("desktop schema v30-v36 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v30-v37 required")
|
||||
if args.command == "export":
|
||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")), encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -51,7 +51,11 @@ MAX_GENERATION = 256 * 1024 * 1024
|
|||
MAX_NAME = 64
|
||||
MAX_PATH = 240
|
||||
MAX_DIAGNOSTIC = 1024
|
||||
MAX_RETAINED_PER_PEER = 8
|
||||
# WHY: interrupted MTP publications can leave immutable generations awaiting
|
||||
# an ACK alongside the two acknowledged lineage members kept for recovery.
|
||||
# CONTRACT: admission remains bounded and never erases unresolved generations.
|
||||
# INVARIANT: captured, published and waiting generations still consume a slot.
|
||||
MAX_RETAINED_PER_PEER = 16
|
||||
ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
HEX = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
|
@ -82,6 +86,8 @@ ARTIFACTS = (
|
|||
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||
"export_programs.py", ()),
|
||||
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
|
||||
"export_programs_v2.py", ()),
|
||||
("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False,
|
||||
"sleep_diary_exchange.py", ("export",)),
|
||||
)
|
||||
|
|
@ -221,9 +227,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, 36)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise GenerationError("desktop schema v24 through v36 required")
|
||||
raise GenerationError("desktop schema v24 through v37 required")
|
||||
|
||||
|
||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||
|
|
@ -366,7 +372,9 @@ def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path)
|
|||
|
||||
|
||||
def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
||||
run_id: str | None = None, generation_id: str | None = None):
|
||||
run_id: str | None = None, generation_id: str | None = None,
|
||||
peer_capabilities: set[str] | None = None,
|
||||
allow_program_skip: bool = False):
|
||||
if not valid_id(consumer, "peer"):
|
||||
raise GenerationError("invalid expected consumer")
|
||||
generation = generation_id or "gen_" + str(uuid.uuid4())
|
||||
|
|
@ -405,9 +413,35 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
producer = peer_identity(source, "desktop")
|
||||
source.commit()
|
||||
source.backup(target)
|
||||
with closing(connect_database(snapshot)) as planning_db:
|
||||
has_planning_table = planning_db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
||||
"AND name='program_session_planning'"
|
||||
).fetchone() is not None
|
||||
has_reschedule = has_planning_table and planning_db.execute(
|
||||
"SELECT 1 FROM program_session_planning plan "
|
||||
"JOIN program_sessions session ON session.program_session_id=plan.program_session_id "
|
||||
"JOIN programs program ON program.program_id=session.program_id "
|
||||
"WHERE program.deleted_at IS NULL LIMIT 1"
|
||||
).fetchone() is not None
|
||||
supports_v2 = peer_capabilities is not None and "programs-v2" in peer_capabilities
|
||||
# CONTRACT: one generation contains exactly one Program revision per
|
||||
# identity. A V1-only peer cannot preserve a changed current schedule.
|
||||
# INVARIANT: reject before publication rather than claiming convergence.
|
||||
skip_programs = has_reschedule and not supports_v2
|
||||
if skip_programs and not allow_program_skip:
|
||||
raise GenerationError("Programs V2 capability required for rescheduled programs")
|
||||
if supports_v2 and not has_planning_table:
|
||||
raise GenerationError("Programs V2 requires desktop schema v37")
|
||||
descriptors = []
|
||||
total = 0
|
||||
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
|
||||
if skip_programs and logical in ("programs-v1", "programs-v2"):
|
||||
continue
|
||||
if logical == "programs-v1" and supports_v2:
|
||||
continue
|
||||
if logical == "programs-v2" and not supports_v2:
|
||||
continue
|
||||
output = stage / filename
|
||||
run_export(tool, extra, output, snapshot)
|
||||
size, checksum = digest_file(output)
|
||||
|
|
@ -521,7 +555,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
|
||||
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
|
||||
(operation["operation_id"], generation, 0 if existed else 1))
|
||||
programs_path = stage / "programs-v1.json"
|
||||
programs_path = stage / ("programs-v2.json" if supports_v2 else "programs-v1.json")
|
||||
if programs_path.is_file():
|
||||
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
|
||||
for deletion in captured_programs["deletions"]:
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@ def failure_code(error: BaseException) -> str:
|
|||
return "device_unavailable"
|
||||
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
|
||||
return "capacity_exhausted"
|
||||
if "conflict" in diagnostic or "wrong consumer" in diagnostic:
|
||||
if ("conflict" in diagnostic or "wrong consumer" in diagnostic or
|
||||
"concurrent sleep diary revision" in diagnostic):
|
||||
return "data_conflict"
|
||||
return "internal_error"
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@ CAPS = {
|
|||
|
||||
ACK_RECOVERY_CAP = "generation-ack-recovery-v1"
|
||||
|
||||
MTP_OPERATION_TIMEOUT_SECONDS = 30.0
|
||||
# WHY: a verified personal phone can need more than 30 seconds to publish an
|
||||
# immutable Programs generation over MTP. CONTRACT: this bounds each adapter
|
||||
# call independently of the configured overall conversation deadline.
|
||||
# INVARIANT: an adapter overrun remains an ambiguous transport failure.
|
||||
MTP_OPERATION_TIMEOUT_SECONDS = 90.0
|
||||
MTP_POLL_INTERVAL_SECONDS = 3.0
|
||||
|
||||
|
||||
|
|
@ -72,6 +76,15 @@ def emit(run_id: str, phase: str, **values: object) -> None:
|
|||
print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True)
|
||||
|
||||
|
||||
def rejected_android_generation_error(ack: dict) -> RuntimeError:
|
||||
"""Keep the durable peer rejection visible without changing its ACK."""
|
||||
# WHY: the browser cannot classify a known causal conflict when the worker
|
||||
# drops the consumer's bounded diagnostic at this process boundary.
|
||||
diagnostic = ack.get("diagnostic", "")
|
||||
detail = f": {diagnostic}" if diagnostic else ""
|
||||
return RuntimeError(f"desktop rejected Android generation{detail}")
|
||||
|
||||
|
||||
def bounded(path: Path, limit: int = 65536) -> bytes:
|
||||
with path.open("rb") as stream:
|
||||
raw = stream.read(limit + 1)
|
||||
|
|
@ -805,7 +818,7 @@ def main() -> int:
|
|||
deadline,
|
||||
)
|
||||
if ack["result"] != "consumed":
|
||||
raise RuntimeError("desktop rejected Android generation")
|
||||
raise rejected_android_generation_error(ack)
|
||||
emit(
|
||||
args.run_id,
|
||||
"local_import_committed",
|
||||
|
|
@ -818,8 +831,13 @@ def main() -> int:
|
|||
# diagnostics to the separately bounded stderr channel.
|
||||
with redirect_stdout(sys.stderr):
|
||||
stage, manifest, digest = generation.capture_desktop(
|
||||
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id
|
||||
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id,
|
||||
set(peer["capabilities"]), True,
|
||||
)
|
||||
program_domain_incompatible = not any(
|
||||
artifact["logical_name"] in ("programs-v1", "programs-v2")
|
||||
for artifact in manifest["artifacts"]
|
||||
)
|
||||
del stage
|
||||
published = generation.publish(
|
||||
args.database, outgoing_id, args.transport_root / "desktop-objects"
|
||||
|
|
@ -917,6 +935,14 @@ def main() -> int:
|
|||
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
|
||||
raise RuntimeError("generation evidence did not corroborate completion")
|
||||
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
|
||||
if program_domain_incompatible:
|
||||
# WHY: the old peer cannot retain the new absolute schedule, but its
|
||||
# unrelated domains can still converge through the same generation.
|
||||
# CONTRACT: never report whole-run success while its Program view is stale.
|
||||
raise RuntimeError(
|
||||
"Programs V2 capability required: other domains synchronized, "
|
||||
"Program planning not published to this peer"
|
||||
)
|
||||
ai_post_sync = publish_ai_export_to_android(args, deadline)
|
||||
report = {
|
||||
"format": "trainlog-sync-worker-report",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ REQUEST_NAME = "trainlog-sync-request-v1.json"
|
|||
FULL_GENERATION_REQUEST_NAME = "trainlog-sync-full-generation-request-v1.json"
|
||||
SEEN_REQUEST_LIMIT = 64
|
||||
REQUEST_ID = re.compile(r"sr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
||||
RETRYABLE_REQUEST_ERROR_CODES = {
|
||||
"device_unavailable",
|
||||
"sync_in_progress",
|
||||
"transport_timeout",
|
||||
}
|
||||
|
||||
|
||||
def request_stop(
|
||||
|
|
@ -132,8 +137,26 @@ def load_request_state(path: Path) -> tuple[dict, list[str]]:
|
|||
seen = [item for item in state.get("seen_request_ids", [])
|
||||
if isinstance(item, str) and REQUEST_ID.fullmatch(item)]
|
||||
historical = state.get("request_id")
|
||||
if isinstance(historical, str) and REQUEST_ID.fullmatch(historical) and historical not in seen:
|
||||
seen.append(historical)
|
||||
retryable = (
|
||||
state.get("phase") == "interrupted"
|
||||
or (
|
||||
state.get("phase") == "failed"
|
||||
and state.get("error_code") in RETRYABLE_REQUEST_ERROR_CODES
|
||||
)
|
||||
)
|
||||
if isinstance(historical, str) and REQUEST_ID.fullmatch(historical):
|
||||
# WHY: the daemon records admission before starting the bounded
|
||||
# generation conversation. A transient disconnect can therefore leave
|
||||
# the only Android arrival request in the seen ledger even though no
|
||||
# terminal exchange was possible. CONTRACT: retry only explicit
|
||||
# transient terminal states; completed and business-rejected requests
|
||||
# remain exactly-once admissions. INVARIANT: retry keeps the stable
|
||||
# request_id while the generation engine creates a fresh, idempotent
|
||||
# run_id and revalidates all durable generation/ACK evidence.
|
||||
if retryable:
|
||||
seen = [item for item in seen if item != historical]
|
||||
elif historical not in seen:
|
||||
seen.append(historical)
|
||||
return state, seen[-SEEN_REQUEST_LIMIT:]
|
||||
|
||||
|
||||
|
|
@ -143,7 +166,8 @@ def select_request(root: Path, seen: list[str]) -> tuple[str, str, list[str]] |
|
|||
WHY: modern Android may publish the same intent on full-generation and
|
||||
legacy channels, while stale legacy files remain durable on MTP. CONTRACT:
|
||||
full-generation wins and an older alternate intent is retired. INVARIANT:
|
||||
one request_id starts at most one daemon conversation.
|
||||
one request_id owns at most one active daemon conversation; only an
|
||||
explicitly transient terminal result permits its later readmission.
|
||||
"""
|
||||
full = request(root / FULL_GENERATION_REQUEST_NAME)
|
||||
legacy = request(root / REQUEST_NAME)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 36
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 37
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,4 +63,17 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
|||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
/* WHY: original Program dates are immutable source evidence. CONTRACT: the
|
||||
* preview is read only; commit requires its exact fact/choice fingerprint,
|
||||
* current Program revision and a stable operation ID. INVARIANT: one commit
|
||||
* changes absolute planning dates in one transaction, never history rows. */
|
||||
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
|
||||
const char *program_id,
|
||||
const char *operation_id,
|
||||
const char *body,
|
||||
size_t body_size,
|
||||
bool commit,
|
||||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -41,6 +41,19 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
|
|||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
/* WHY: the Sleep agenda needs factual completed sessions without fetching one
|
||||
* detail per row. CONTRACT: start/end are bounded ISO instants; intervals
|
||||
* intersect [start,end), and a session with no valid end is a point at start.
|
||||
* Pages are ordered by factual start and stable ID. No state is mutated.
|
||||
* INVARIANT: the caller owns output_json and must paginate until more=false. */
|
||||
TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database,
|
||||
const char *start,
|
||||
const char *end,
|
||||
size_t offset,
|
||||
size_t limit,
|
||||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
/* CONTRACT: kind is one of preparation, proposal, draft, or history. The
|
||||
* returned detail is a factual snapshot and performs no lifecycle transition. */
|
||||
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ trainlog_core_sources = files(
|
|||
'src/web_sessions.c',
|
||||
'src/web_session_deletions.c',
|
||||
'src/web_programs.c',
|
||||
'src/web_program_reschedule.c',
|
||||
'src/web_sleep.c',
|
||||
'src/web_exercises.c',
|
||||
'src/web_equipment.c',
|
||||
|
|
|
|||
|
|
@ -1578,6 +1578,39 @@ static const char *const MIGRATE_V35_TO_V36_SQL =
|
|||
"immutable');END;"
|
||||
"PRAGMA user_version=36;COMMIT;";
|
||||
|
||||
/* WHY: a Program's imported date is evidence, while an explicit reschedule
|
||||
* owns its current target and an optional relinquishment of a future slot.
|
||||
* CONTRACT: absent planning rows retain the original date and active state;
|
||||
* operation identities bind replay to an exact request digest. INVARIANT:
|
||||
* migration changes no execution, preparation, source date, or history row. */
|
||||
static const char *const MIGRATE_V36_TO_V37_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TABLE IF NOT EXISTS program_session_planning("
|
||||
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
|
||||
"ON DELETE RESTRICT,"
|
||||
"current_for TEXT,"
|
||||
"state TEXT NOT NULL CHECK(state IN('active','ceded')),"
|
||||
"operation_id TEXT NOT NULL,updated_at TEXT NOT NULL,"
|
||||
"CHECK((state='active' AND current_for IS NOT NULL) OR "
|
||||
"(state='ceded' AND current_for IS NULL)));"
|
||||
"CREATE TABLE IF NOT EXISTS program_reschedule_operations("
|
||||
"operation_id TEXT PRIMARY KEY,"
|
||||
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
||||
"request_sha256 TEXT NOT NULL CHECK(length(request_sha256)=64),"
|
||||
"expected_revision TEXT NOT NULL,"
|
||||
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
|
||||
"CREATE INDEX IF NOT EXISTS program_reschedule_operations_program "
|
||||
"ON program_reschedule_operations(program_id,created_at);"
|
||||
"CREATE TABLE IF NOT EXISTS program_revision_sequences("
|
||||
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
||||
"revision_sequence INTEGER NOT NULL CHECK(revision_sequence>=1));"
|
||||
"CREATE TRIGGER IF NOT EXISTS programs_revision_sequence AFTER UPDATE OF revision_id "
|
||||
"ON programs WHEN OLD.revision_id<>NEW.revision_id BEGIN "
|
||||
"INSERT INTO program_revision_sequences(program_id,revision_sequence) "
|
||||
"VALUES(NEW.program_id,1) ON CONFLICT(program_id) DO UPDATE SET "
|
||||
"revision_sequence=revision_sequence+1;END;"
|
||||
"PRAGMA user_version=37;COMMIT;";
|
||||
|
||||
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
|
||||
* before medication capture joined the same unreleased migration. CONTRACT:
|
||||
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
||||
|
|
@ -2413,7 +2446,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
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 == 36) {
|
||||
version == 36 || version == 37) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -2585,6 +2618,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status == TRAINLOG_STATUS_OK && version < 36) {
|
||||
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 37) {
|
||||
status = execute_sql(database, MIGRATE_V36_TO_V37_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = ensure_v18_ai_draft_publication_state(database);
|
||||
}
|
||||
|
|
@ -2604,7 +2640,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 v36" : "migrate database to schema v36",
|
||||
version == 0 ? "create schema v37" : "migrate database to schema v37",
|
||||
database->connection,
|
||||
SQLITE_ERROR);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
|
|
|
|||
|
|
@ -646,26 +646,26 @@ static bool add_measurements(TrainlogDatabase *database,
|
|||
static bool
|
||||
add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) {
|
||||
static const char SQL[] =
|
||||
/* WHY: imported dates are historical after Program rescheduling.
|
||||
* CONTRACT: select one pending, non-ceded session with its current date.
|
||||
* INVARIANT: a present planning row with NULL current_for stays undated. */
|
||||
"WITH eligible AS (SELECT ps2.program_id,ps2.program_session_id,ps2.title,ps2.position,"
|
||||
"CASE WHEN pp.program_session_id IS NULL THEN ps2.planned_for ELSE pp.current_for END "
|
||||
"AS current_for FROM program_sessions ps2 LEFT JOIN program_session_planning pp ON "
|
||||
"pp.program_session_id=ps2.program_session_id LEFT JOIN program_session_executions pe2 "
|
||||
"ON pe2.program_session_id=ps2.program_session_id WHERE COALESCE(pp.state,'active')"
|
||||
"!='ceded' AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted')),"
|
||||
"ranked AS (SELECT eligible.*,ROW_NUMBER() OVER (PARTITION BY program_id ORDER BY "
|
||||
"CASE WHEN current_for IS NULL THEN 1 ELSE 0 END,current_for,position,"
|
||||
"program_session_id) AS rank FROM eligible) "
|
||||
"SELECT p.program_id,p.title,COUNT(ps.program_session_id),"
|
||||
"SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END),"
|
||||
"(SELECT ps2.title FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 ON "
|
||||
"pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
|
||||
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
|
||||
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1),"
|
||||
"(SELECT ps2.program_session_id FROM program_sessions ps2 LEFT JOIN "
|
||||
"program_session_executions pe2 ON pe2.program_session_id=ps2.program_session_id WHERE "
|
||||
"ps2.program_id=p.program_id AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted') "
|
||||
"ORDER BY CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position "
|
||||
"LIMIT 1),"
|
||||
"(SELECT ps2.planned_for FROM program_sessions ps2 LEFT JOIN program_session_executions "
|
||||
"pe2 "
|
||||
"ON pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
|
||||
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
|
||||
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1) "
|
||||
"next.title,next.program_session_id,next.current_for "
|
||||
"FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN "
|
||||
"program_session_executions pe ON pe.program_session_id=ps.program_session_id WHERE "
|
||||
"program_session_executions pe ON pe.program_session_id=ps.program_session_id LEFT JOIN "
|
||||
"ranked next ON next.program_id=p.program_id AND next.rank=1 WHERE "
|
||||
"p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at "
|
||||
"DESC "
|
||||
"DESC,p.program_id "
|
||||
"LIMIT 1";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_val *program = yyjson_mut_obj(document);
|
||||
|
|
|
|||
645
tui/src/web_program_reschedule.c
Normal file
645
tui/src/web_program_reschedule.c
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
#include "trainlog/web_programs.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <openssl/evp.h>
|
||||
#include <sqlite3.h>
|
||||
#include <yyjson.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "trainlog/id.h"
|
||||
|
||||
typedef struct PlanningRow {
|
||||
const char *id;
|
||||
const char *original;
|
||||
const char *current;
|
||||
const char *execution;
|
||||
const char *planning;
|
||||
const char *assigned;
|
||||
bool cede;
|
||||
} PlanningRow;
|
||||
|
||||
static TrainlogStatus
|
||||
planning_error(const char *reason, TrainlogStatus status, char **output, size_t *size) {
|
||||
size_t length = strlen(reason) + 16U;
|
||||
*output = malloc(length);
|
||||
if (*output == NULL) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
*size = (size_t)snprintf(*output, length, "{\"error\":\"%s\"}", reason);
|
||||
return status;
|
||||
}
|
||||
|
||||
static bool planning_digest(
|
||||
const char *first, size_t first_size, const char *second, size_t second_size, char output[65]) {
|
||||
EVP_MD_CTX *context = EVP_MD_CTX_new();
|
||||
unsigned char digest[32];
|
||||
unsigned int length = 0U;
|
||||
bool okay = context != NULL && EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
|
||||
EVP_DigestUpdate(context, first, first_size) == 1 &&
|
||||
EVP_DigestUpdate(context, second, second_size) == 1 &&
|
||||
EVP_DigestFinal_ex(context, digest, &length) == 1 && length == 32U;
|
||||
EVP_MD_CTX_free(context);
|
||||
if (!okay) {
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0U; index < 32U; ++index) {
|
||||
(void)snprintf(output + index * 2U, 3U, "%02x", digest[index]);
|
||||
}
|
||||
output[64] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool valid_civil_date(const char *value) {
|
||||
unsigned year, month, day;
|
||||
int days;
|
||||
if (value == NULL || strlen(value) != 10U || value[4] != '-' || value[7] != '-' ||
|
||||
sscanf(value, "%4u-%2u-%2u", &year, &month, &day) != 3 || year < 1900U || year > 9999U ||
|
||||
month < 1U || month > 12U) {
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0U; index < 10U; ++index) {
|
||||
if (index != 4U && index != 7U && (value[index] < '0' || value[index] > '9')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
static const int MONTH_DAYS[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
|
||||
days = MONTH_DAYS[month - 1U];
|
||||
if (month == 2U && (year % 4U == 0U && (year % 100U != 0U || year % 400U == 0U))) {
|
||||
days = 29;
|
||||
}
|
||||
return day >= 1U && day <= (unsigned)days;
|
||||
}
|
||||
|
||||
static bool
|
||||
add_nullable(yyjson_mut_doc *document, yyjson_mut_val *object, const char *key, const char *value) {
|
||||
return value == NULL ? yyjson_mut_obj_add_null(document, object, key)
|
||||
: yyjson_mut_obj_add_strcpy(document, object, key, value);
|
||||
}
|
||||
|
||||
static bool request_valid(yyjson_val *root, bool commit) {
|
||||
yyjson_obj_iter iterator;
|
||||
yyjson_val *key;
|
||||
size_t count = 0U;
|
||||
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != (commit ? 6U : 5U)) {
|
||||
return false;
|
||||
}
|
||||
iterator = yyjson_obj_iter_with(root);
|
||||
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
|
||||
const char *name = yyjson_get_str(key);
|
||||
if (strcmp(name, "start_session_id") != 0 && strcmp(name, "through_session_id") != 0 &&
|
||||
strcmp(name, "start_date") != 0 && strcmp(name, "ceded_session_ids") != 0 &&
|
||||
strcmp(name, "expected_revision") != 0 &&
|
||||
!(commit && strcmp(name, "preview_sha256") == 0)) {
|
||||
return false;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
return count == (commit ? 6U : 5U) && yyjson_is_str(yyjson_obj_get(root, "start_session_id")) &&
|
||||
yyjson_is_str(yyjson_obj_get(root, "through_session_id")) &&
|
||||
yyjson_is_str(yyjson_obj_get(root, "expected_revision")) &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "start_session_id")) <= 128U &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "through_session_id")) <= 128U &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "expected_revision")) <= 128U &&
|
||||
valid_civil_date(yyjson_get_str(yyjson_obj_get(root, "start_date"))) &&
|
||||
yyjson_is_arr(yyjson_obj_get(root, "ceded_session_ids")) &&
|
||||
yyjson_arr_size(yyjson_obj_get(root, "ceded_session_ids")) <= 64U &&
|
||||
(!commit || (yyjson_is_str(yyjson_obj_get(root, "preview_sha256")) &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "preview_sha256")) == 64U));
|
||||
}
|
||||
|
||||
static bool has_preparation(TrainlogDatabase *database, const char *session_id) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
bool present = true;
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM session_preparations WHERE "
|
||||
"source_program_session_id=? AND withdrawn_at IS NULL LIMIT 1",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) == SQLITE_OK &&
|
||||
sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) == SQLITE_OK) {
|
||||
present = sqlite3_step(statement) != SQLITE_DONE;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return present;
|
||||
}
|
||||
|
||||
static TrainlogStatus peer_planning_fresh(TrainlogDatabase *database) {
|
||||
static const char SQL[] =
|
||||
"SELECT EXISTS(SELECT 1 FROM "
|
||||
"(SELECT DISTINCT consumer_peer_id FROM sync_generations "
|
||||
"WHERE producer_kind='desktop') peer WHERE "
|
||||
"NOT EXISTS(SELECT 1 FROM sync_generations generation "
|
||||
"JOIN sync_generation_artifacts artifact "
|
||||
"ON artifact.generation_id=generation.generation_id "
|
||||
"WHERE generation.generation_id=(SELECT latest.generation_id "
|
||||
"FROM sync_generations latest WHERE latest.consumer_peer_id=peer.consumer_peer_id "
|
||||
"AND latest.producer_kind='desktop' "
|
||||
"ORDER BY julianday(latest.generated_at) DESC,latest.generation_id DESC LIMIT 1) "
|
||||
"AND generation.status='acknowledged' AND artifact.logical_name='programs-v2' "
|
||||
"AND julianday(generation.acknowledged_at)>=julianday('now','-5 minutes')))";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(statement) == SQLITE_ROW) {
|
||||
status =
|
||||
sqlite3_column_int(statement, 0) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return status;
|
||||
}
|
||||
|
||||
static TrainlogStatus build_preview(TrainlogDatabase *database,
|
||||
const char *program_id,
|
||||
yyjson_val *request,
|
||||
char **output,
|
||||
size_t *output_size) {
|
||||
char *detail = NULL;
|
||||
size_t detail_size = 0U;
|
||||
yyjson_doc *source = NULL;
|
||||
yyjson_mut_doc *response = NULL;
|
||||
yyjson_val *program;
|
||||
yyjson_val *sessions;
|
||||
yyjson_val *ceded;
|
||||
yyjson_mut_val *root;
|
||||
yyjson_mut_val *moves;
|
||||
PlanningRow rows[64] = {0};
|
||||
const char *slots[64] = {0};
|
||||
size_t first = 64U;
|
||||
size_t last = 64U;
|
||||
size_t count;
|
||||
size_t slot_count = 0U;
|
||||
size_t next_slot = 0U;
|
||||
char fingerprint[65];
|
||||
char *choices = NULL;
|
||||
size_t choices_size = 0U;
|
||||
TrainlogStatus status =
|
||||
trainlog_web_programs_detail_json(database, program_id, &detail, &detail_size);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
return status;
|
||||
}
|
||||
source = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
|
||||
program = source == NULL ? NULL : yyjson_doc_get_root(source);
|
||||
sessions = program == NULL ? NULL : yyjson_obj_get(program, "sessions");
|
||||
ceded = yyjson_obj_get(request, "ceded_session_ids");
|
||||
count = yyjson_arr_size(sessions);
|
||||
if (program == NULL || count > 64U ||
|
||||
!yyjson_equals_str(yyjson_obj_get(program, "state"), "active") ||
|
||||
!yyjson_equals_str(yyjson_obj_get(program, "revision_id"),
|
||||
yyjson_get_str(yyjson_obj_get(request, "expected_revision"))) ||
|
||||
!yyjson_is_str(yyjson_obj_get(program, "end_date"))) {
|
||||
status = planning_error(
|
||||
"stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
for (size_t index = 0U; index < count; ++index) {
|
||||
yyjson_val *session = yyjson_arr_get(sessions, index);
|
||||
rows[index].id = yyjson_get_str(yyjson_obj_get(session, "program_session_id"));
|
||||
rows[index].original = yyjson_get_str(yyjson_obj_get(session, "planned_for"));
|
||||
rows[index].current = yyjson_get_str(yyjson_obj_get(session, "current_for"));
|
||||
rows[index].execution = yyjson_get_str(yyjson_obj_get(session, "execution_state"));
|
||||
rows[index].planning = yyjson_get_str(yyjson_obj_get(session, "planning_state"));
|
||||
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "start_session_id"))) ==
|
||||
0) {
|
||||
first = index;
|
||||
}
|
||||
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "through_session_id"))) ==
|
||||
0) {
|
||||
last = index;
|
||||
}
|
||||
}
|
||||
if (first >= count || last >= count || first > last ||
|
||||
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
|
||||
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
|
||||
status = planning_error(
|
||||
"invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
|
||||
yyjson_val *id = yyjson_arr_get(ceded, index);
|
||||
bool matched = false;
|
||||
if (!yyjson_is_str(id) || yyjson_get_len(id) > 128U) {
|
||||
status = planning_error(
|
||||
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
for (size_t candidate = first; candidate <= last; ++candidate) {
|
||||
if (strcmp(rows[candidate].id, yyjson_get_str(id)) == 0 && !rows[candidate].cede) {
|
||||
rows[candidate].cede = true;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
status = planning_error(
|
||||
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
for (size_t index = first; index <= last; ++index) {
|
||||
if (strcmp(rows[index].execution, "todo") != 0 ||
|
||||
has_preparation(database, rows[index].id) || rows[index].original == NULL ||
|
||||
!valid_civil_date(rows[index].original)) {
|
||||
status = planning_error(
|
||||
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
if (strcmp(rows[index].planning, "ceded") == 0 && !rows[index].cede) {
|
||||
status = planning_error(
|
||||
"ceded_session_requires_selection", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
// WHY: a second explicit decision starts from the durable current
|
||||
// schedule. A ceded identity has no current date, so its original slot
|
||||
// is available only when the user selects that identity again.
|
||||
const char *slot = rows[index].current == NULL ? rows[index].original : rows[index].current;
|
||||
if (strcmp(slot, yyjson_get_str(yyjson_obj_get(request, "start_date"))) >= 0) {
|
||||
slots[slot_count++] = slot;
|
||||
}
|
||||
}
|
||||
// Selected Program slots define permitted days; no rest day is synthesized.
|
||||
// Position order may differ from dates, so sort candidate dates explicitly.
|
||||
for (size_t outer = 0U; outer < slot_count; ++outer) {
|
||||
for (size_t inner = outer + 1U; inner < slot_count; ++inner) {
|
||||
if (strcmp(slots[inner], slots[outer]) < 0) {
|
||||
const char *swap = slots[outer];
|
||||
slots[outer] = slots[inner];
|
||||
slots[inner] = swap;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A previously ceded original day may now be occupied by an active
|
||||
// session. It remains one calendar slot for this operation.
|
||||
size_t unique_slots = 0U;
|
||||
for (size_t index = 0U; index < slot_count; ++index) {
|
||||
if (unique_slots == 0U || strcmp(slots[index], slots[unique_slots - 1U]) != 0) {
|
||||
slots[unique_slots++] = slots[index];
|
||||
}
|
||||
}
|
||||
slot_count = unique_slots;
|
||||
for (size_t index = first; index <= last; ++index) {
|
||||
if (rows[index].cede) {
|
||||
continue;
|
||||
}
|
||||
if (next_slot >= slot_count ||
|
||||
strcmp(slots[next_slot], yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
|
||||
status = planning_error("insufficient_or_duplicate_slots",
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT,
|
||||
output,
|
||||
output_size);
|
||||
goto done;
|
||||
}
|
||||
rows[index].assigned = slots[next_slot++];
|
||||
}
|
||||
// CONTRACT: the choice digest includes stable identities in their supplied
|
||||
// order. The fact digest includes the complete current Program projection.
|
||||
{
|
||||
yyjson_mut_doc *choice_document = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *choice_root =
|
||||
choice_document == NULL ? NULL : yyjson_mut_obj(choice_document);
|
||||
if (choice_document == NULL || choice_root == NULL) {
|
||||
yyjson_mut_doc_free(choice_document);
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
yyjson_mut_doc_set_root(choice_document, choice_root);
|
||||
(void)yyjson_mut_obj_add_strcpy(
|
||||
choice_document,
|
||||
choice_root,
|
||||
"start_session_id",
|
||||
yyjson_get_str(yyjson_obj_get(request, "start_session_id")));
|
||||
(void)yyjson_mut_obj_add_strcpy(
|
||||
choice_document,
|
||||
choice_root,
|
||||
"through_session_id",
|
||||
yyjson_get_str(yyjson_obj_get(request, "through_session_id")));
|
||||
(void)yyjson_mut_obj_add_strcpy(choice_document,
|
||||
choice_root,
|
||||
"start_date",
|
||||
yyjson_get_str(yyjson_obj_get(request, "start_date")));
|
||||
yyjson_mut_val *ids = yyjson_mut_arr(choice_document);
|
||||
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
|
||||
(void)yyjson_mut_arr_add_strcpy(
|
||||
choice_document, ids, yyjson_get_str(yyjson_arr_get(ceded, index)));
|
||||
}
|
||||
(void)yyjson_mut_obj_add_val(choice_document, choice_root, "ceded_session_ids", ids);
|
||||
choices = yyjson_mut_write(choice_document, YYJSON_WRITE_NOFLAG, &choices_size);
|
||||
yyjson_mut_doc_free(choice_document);
|
||||
}
|
||||
if (choices == NULL ||
|
||||
!planning_digest(detail, detail_size, choices, choices_size, fingerprint)) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
response = yyjson_mut_doc_new(NULL);
|
||||
root = response == NULL ? NULL : yyjson_mut_obj(response);
|
||||
moves = response == NULL ? NULL : yyjson_mut_arr(response);
|
||||
if (response == NULL || root == NULL || moves == NULL) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
yyjson_mut_doc_set_root(response, root);
|
||||
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
|
||||
(void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
|
||||
(void)yyjson_mut_obj_add_strcpy(
|
||||
response, root, "revision_id", yyjson_get_str(yyjson_obj_get(program, "revision_id")));
|
||||
(void)yyjson_mut_obj_add_strcpy(response, root, "preview_sha256", fingerprint);
|
||||
(void)yyjson_mut_obj_add_val(response, root, "moves", moves);
|
||||
for (size_t index = first; index <= last; ++index) {
|
||||
yyjson_mut_val *move = yyjson_mut_obj(response);
|
||||
(void)yyjson_mut_obj_add_strcpy(response, move, "program_session_id", rows[index].id);
|
||||
(void)add_nullable(response, move, "original_for", rows[index].original);
|
||||
(void)add_nullable(response, move, "old_for", rows[index].current);
|
||||
(void)add_nullable(response, move, "new_for", rows[index].assigned);
|
||||
const char *change =
|
||||
rows[index].cede ? "ceded"
|
||||
: rows[index].current != NULL && strcmp(rows[index].current, rows[index].assigned) == 0
|
||||
? "unchanged"
|
||||
: "rescheduled";
|
||||
(void)yyjson_mut_obj_add_strcpy(response, move, "change", change);
|
||||
(void)yyjson_mut_arr_add_val(moves, move);
|
||||
}
|
||||
*output = yyjson_mut_write(response, YYJSON_WRITE_NOFLAG, output_size);
|
||||
status = *output == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
||||
done:
|
||||
free(choices);
|
||||
yyjson_mut_doc_free(response);
|
||||
yyjson_doc_free(source);
|
||||
free(detail);
|
||||
return status;
|
||||
}
|
||||
|
||||
static TrainlogStatus apply_preview(TrainlogDatabase *database,
|
||||
const char *operation_id,
|
||||
const char *program_id,
|
||||
const char *expected_revision,
|
||||
const char *request_digest,
|
||||
const char *preview,
|
||||
char **output,
|
||||
size_t *output_size) {
|
||||
yyjson_doc *document = yyjson_read(preview, strlen(preview), YYJSON_READ_NOFLAG);
|
||||
yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document);
|
||||
yyjson_val *moves = root == NULL ? NULL : yyjson_obj_get(root, "moves");
|
||||
sqlite3_stmt *statement = NULL;
|
||||
char revision[80];
|
||||
char now[32];
|
||||
time_t clock_now = time(NULL);
|
||||
struct tm utc;
|
||||
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
if (moves == NULL || clock_now == (time_t)-1 || gmtime_r(&clock_now, &utc) == NULL ||
|
||||
strftime(now, sizeof(now), "%Y-%m-%dT%H:%M:%SZ", &utc) == 0U ||
|
||||
trainlog_id_generate("pgr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"UPDATE programs SET revision_id=?,updated_at=? WHERE program_id=? "
|
||||
"AND revision_id=? AND deleted_at IS NULL AND state='active'",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
||||
status = TRAINLOG_STATUS_CONFLICT;
|
||||
goto done;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO program_session_planning "
|
||||
"(program_session_id,current_for,state,operation_id,updated_at) "
|
||||
"VALUES(?,?,?,?,?) ON CONFLICT(program_session_id) DO UPDATE SET "
|
||||
"current_for=excluded.current_for,state=excluded.state,"
|
||||
"operation_id=excluded.operation_id,updated_at=excluded.updated_at",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK) {
|
||||
goto done;
|
||||
}
|
||||
for (size_t index = 0U; index < yyjson_arr_size(moves); ++index) {
|
||||
yyjson_val *move = yyjson_arr_get(moves, index);
|
||||
yyjson_val *new_for = yyjson_obj_get(move, "new_for");
|
||||
if (yyjson_equals_str(yyjson_obj_get(move, "change"), "unchanged")) {
|
||||
continue;
|
||||
}
|
||||
sqlite3_reset(statement);
|
||||
sqlite3_clear_bindings(statement);
|
||||
if (sqlite3_bind_text(statement,
|
||||
1,
|
||||
yyjson_get_str(yyjson_obj_get(move, "program_session_id")),
|
||||
-1,
|
||||
SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
(yyjson_is_null(new_for)
|
||||
? sqlite3_bind_null(statement, 2)
|
||||
: sqlite3_bind_text(
|
||||
statement, 2, yyjson_get_str(new_for), -1, SQLITE_TRANSIENT)) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement,
|
||||
3,
|
||||
yyjson_equals_str(yyjson_obj_get(move, "change"), "ceded") ? "ceded"
|
||||
: "active",
|
||||
-1,
|
||||
SQLITE_STATIC) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE) {
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
{
|
||||
size_t length = strlen(preview) + strlen(revision) + 52U;
|
||||
*output = malloc(length);
|
||||
if (*output == NULL) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
*output_size = (size_t)snprintf(*output,
|
||||
length,
|
||||
"%.*s,\"applied\":true,\"new_revision_id\":\"%s\"}",
|
||||
(int)strlen(preview) - 1,
|
||||
preview,
|
||||
revision);
|
||||
}
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO program_reschedule_operations VALUES(?,?,?,?,?,?)",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, request_digest, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 5, *output, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 6, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE) {
|
||||
goto done;
|
||||
}
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
done:
|
||||
(void)sqlite3_finalize(statement);
|
||||
yyjson_doc_free(document);
|
||||
return status;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
|
||||
const char *program_id,
|
||||
const char *operation_id,
|
||||
const char *body,
|
||||
size_t body_size,
|
||||
bool commit,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
yyjson_doc *request_doc = NULL;
|
||||
yyjson_val *request;
|
||||
sqlite3_stmt *statement = NULL;
|
||||
char request_digest[65];
|
||||
char *preview = NULL;
|
||||
size_t preview_size = 0U;
|
||||
TrainlogStatus status;
|
||||
if (database == NULL || program_id == NULL || body == NULL || output_json == NULL ||
|
||||
output_size == NULL || (commit && (operation_id == NULL || operation_id[0] == '\0')) ||
|
||||
body_size > 16384U || (operation_id != NULL && strlen(operation_id) > 128U)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
request_doc = yyjson_read(body, body_size, YYJSON_READ_NOFLAG);
|
||||
request = request_doc == NULL ? NULL : yyjson_doc_get_root(request_doc);
|
||||
if (!request_valid(request, commit) ||
|
||||
!planning_digest(body, body_size, "", 0U, request_digest)) {
|
||||
yyjson_doc_free(request_doc);
|
||||
return planning_error("invalid_reschedule_request",
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT,
|
||||
output_json,
|
||||
output_size);
|
||||
}
|
||||
if (sqlite3_exec(
|
||||
database->connection, commit ? "BEGIN IMMEDIATE" : "BEGIN", NULL, NULL, NULL) !=
|
||||
SQLITE_OK) {
|
||||
yyjson_doc_free(request_doc);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if (commit) {
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT program_id,request_sha256,response_json FROM "
|
||||
"program_reschedule_operations WHERE operation_id=?",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
if (sqlite3_step(statement) == SQLITE_ROW) {
|
||||
const char *known_program = (const char *)sqlite3_column_text(statement, 0);
|
||||
const char *known_digest = (const char *)sqlite3_column_text(statement, 1);
|
||||
const char *saved = (const char *)sqlite3_column_text(statement, 2);
|
||||
if (known_program != NULL && known_digest != NULL && saved != NULL &&
|
||||
strcmp(known_program, program_id) == 0 &&
|
||||
strcmp(known_digest, request_digest) == 0) {
|
||||
*output_json = strdup(saved);
|
||||
*output_size = *output_json == NULL ? 0U : strlen(*output_json);
|
||||
status = *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
status = planning_error("operation_identity_conflict",
|
||||
TRAINLOG_STATUS_CONFLICT,
|
||||
output_json,
|
||||
output_size);
|
||||
}
|
||||
goto finish;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM program_requests WHERE request_id=? "
|
||||
"UNION ALL SELECT 1 FROM program_deletions WHERE request_id=?",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
int cross_command = sqlite3_step(statement);
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
if (cross_command != SQLITE_DONE) {
|
||||
status = cross_command == SQLITE_ROW ? planning_error("operation_identity_conflict",
|
||||
TRAINLOG_STATUS_CONFLICT,
|
||||
output_json,
|
||||
output_size)
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
// WHY: the desktop's last imported draft snapshot cannot prove that
|
||||
// Android stayed idle while offline. A recent acknowledged V2 Program
|
||||
// generation is required; Android independently rejects a changed
|
||||
// schedule if a Program draft starts after that acknowledgement.
|
||||
status = peer_planning_fresh(database);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||
status = planning_error("program_peer_not_fresh", status, output_json, output_size);
|
||||
}
|
||||
goto finish;
|
||||
}
|
||||
}
|
||||
status = build_preview(database, program_id, request, &preview, &preview_size);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
if (preview != NULL) {
|
||||
*output_json = preview;
|
||||
*output_size = preview_size;
|
||||
preview = NULL;
|
||||
}
|
||||
goto finish;
|
||||
}
|
||||
if (!commit) {
|
||||
*output_json = preview;
|
||||
*output_size = preview_size;
|
||||
preview = NULL;
|
||||
goto finish;
|
||||
}
|
||||
{
|
||||
yyjson_doc *preview_doc = yyjson_read(preview, preview_size, YYJSON_READ_NOFLAG);
|
||||
yyjson_val *preview_root = preview_doc == NULL ? NULL : yyjson_doc_get_root(preview_doc);
|
||||
const char *fingerprint = yyjson_get_str(yyjson_obj_get(preview_root, "preview_sha256"));
|
||||
if (fingerprint == NULL ||
|
||||
strcmp(fingerprint, yyjson_get_str(yyjson_obj_get(request, "preview_sha256"))) != 0) {
|
||||
status = planning_error(
|
||||
"stale_reschedule_preview", TRAINLOG_STATUS_CONFLICT, output_json, output_size);
|
||||
} else {
|
||||
status = apply_preview(database,
|
||||
operation_id,
|
||||
program_id,
|
||||
yyjson_get_str(yyjson_obj_get(request, "expected_revision")),
|
||||
request_digest,
|
||||
preview,
|
||||
output_json,
|
||||
output_size);
|
||||
}
|
||||
yyjson_doc_free(preview_doc);
|
||||
}
|
||||
finish:
|
||||
(void)sqlite3_finalize(statement);
|
||||
free(preview);
|
||||
yyjson_doc_free(request_doc);
|
||||
if (status == TRAINLOG_STATUS_OK && commit) {
|
||||
if (sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
|
||||
return status;
|
||||
}
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
||||
if (status != TRAINLOG_STATUS_OK && *output_json == NULL) {
|
||||
return planning_error("reschedule_unavailable", status, output_json, output_size);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
|
@ -365,9 +365,12 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database,
|
|||
!add_column_text(document, session, "title", statement, 2) ||
|
||||
!add_column_text(document, session, "session_type", statement, 3) ||
|
||||
!add_column_text(document, session, "planned_for", statement, 4) ||
|
||||
!add_column_text(document, session, "note", statement, 5) ||
|
||||
!add_column_text(document, session, "execution_state", statement, 6) ||
|
||||
!add_column_text(document, session, "execution_session_id", statement, 7) ||
|
||||
!add_column_text(document, session, "current_for", statement, 5) ||
|
||||
!add_column_text(document, session, "planning_state", statement, 6) ||
|
||||
!add_column_text(document, session, "note", statement, 7) ||
|
||||
!add_column_text(document, session, "execution_state", statement, 8) ||
|
||||
!add_column_text(document, session, "execution_session_id", statement, 9) ||
|
||||
!add_column_text(document, session, "execution_started_at", statement, 10) ||
|
||||
!yyjson_mut_obj_add_val(document, session, "occurrences", entries) ||
|
||||
!yyjson_mut_arr_add_val(sessions, session)) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
|
|
@ -382,15 +385,24 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
|
|||
yyjson_mut_val *sessions) {
|
||||
static const char SESSION_SQL[] =
|
||||
"SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
|
||||
"ps.note,CASE WHEN pe.state='completed' THEN 'completed' "
|
||||
"CASE WHEN pp.state='ceded' THEN NULL ELSE COALESCE(pp.current_for,ps.planned_for) END,"
|
||||
"COALESCE(pp.state,'active'),ps.note,"
|
||||
"CASE WHEN pe.state='completed' THEN 'completed' "
|
||||
"WHEN pe.state='in_progress' THEN 'in_progress' "
|
||||
"WHEN pe.state='deleted' THEN 'deleted' "
|
||||
"WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE "
|
||||
"sp.source_program_session_id=ps.program_session_id AND sp.withdrawn_at IS NULL) "
|
||||
"THEN 'prepared' ELSE 'todo' END,"
|
||||
"CASE WHEN pe.state='deleted' THEN NULL ELSE pe.session_id END "
|
||||
"FROM program_sessions ps LEFT JOIN program_session_executions pe "
|
||||
"CASE WHEN pe.state='deleted' THEN NULL ELSE pe.session_id END,"
|
||||
/* WHY: completion belongs to the linked session's actual start date.
|
||||
* CONTRACT: expose that timestamp only for completed executions.
|
||||
* INVARIANT: the imported planned_for date and history are read only. */
|
||||
"CASE WHEN pe.state='completed' THEN actual.started_at ELSE NULL END "
|
||||
"FROM program_sessions ps LEFT JOIN program_session_planning pp "
|
||||
"ON pp.program_session_id=ps.program_session_id "
|
||||
"LEFT JOIN program_session_executions pe "
|
||||
"ON pe.program_session_id=ps.program_session_id "
|
||||
"LEFT JOIN sessions actual ON actual.session_id=pe.session_id "
|
||||
"WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id";
|
||||
sqlite3_stmt *session_statement = NULL;
|
||||
int step;
|
||||
|
|
@ -1159,7 +1171,25 @@ static TrainlogStatus program_replay_archive(TrainlogDatabase *database,
|
|||
result = SQLITE_OK;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
if (result != SQLITE_OK || *found) {
|
||||
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM program_reschedule_operations WHERE operation_id=?",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return result == SQLITE_ROW ? TRAINLOG_STATUS_CONFLICT
|
||||
: result == SQLITE_DONE ? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
|
||||
|
|
@ -1351,7 +1381,9 @@ static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database,
|
|||
}
|
||||
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM program_requests WHERE request_id=?1",
|
||||
"SELECT 1 FROM program_requests WHERE request_id=?1 "
|
||||
"UNION ALL SELECT 1 FROM program_reschedule_operations "
|
||||
"WHERE operation_id=?1",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
|
|
@ -1603,7 +1635,7 @@ static char *program_build_preparation_body(yyjson_val *session,
|
|||
command,
|
||||
root,
|
||||
"planned_for",
|
||||
yyjson_val_mut_copy(command, yyjson_obj_get(session, "planned_for")));
|
||||
yyjson_val_mut_copy(command, yyjson_obj_get(session, "current_for")));
|
||||
(void)yyjson_mut_obj_add_val(
|
||||
command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note")));
|
||||
(void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft");
|
||||
|
|
@ -1646,6 +1678,10 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
|||
yyjson_doc_free(document);
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
if (!yyjson_equals_str(yyjson_obj_get(session, "planning_state"), "active")) {
|
||||
yyjson_doc_free(document);
|
||||
return TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
body = program_build_preparation_body(session, program_id, program_session_id, &body_size);
|
||||
yyjson_doc_free(document);
|
||||
if (body == NULL) {
|
||||
|
|
|
|||
|
|
@ -705,14 +705,22 @@ static enum MHD_Result queue_owned_programs_json(struct MHD_Connection *connecti
|
|||
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||
enum MHD_Result result =
|
||||
queue_json(connection,
|
||||
MHD_HTTP_CONFLICT,
|
||||
json == NULL ? "{\"error\":\"program_conflict\"}\n" : json,
|
||||
NULL);
|
||||
free(json);
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL);
|
||||
return result;
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
||||
enum MHD_Result result =
|
||||
queue_json(connection,
|
||||
MHD_HTTP_UNPROCESSABLE_CONTENT,
|
||||
json == NULL ? "{\"error\":\"invalid_program\"}\n" : json,
|
||||
NULL);
|
||||
free(json);
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL);
|
||||
return result;
|
||||
}
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
|
|
@ -930,6 +938,8 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
|||
static const char PREFIX[] = "/api/v1/sessions/program/";
|
||||
static const char ARCHIVE_SUFFIX[] = "/archive";
|
||||
static const char DELETE_SUFFIX[] = "/delete";
|
||||
static const char PREVIEW_SUFFIX[] = "/reschedule/preview";
|
||||
static const char APPLY_SUFFIX[] = "/reschedule/apply";
|
||||
const char *identity;
|
||||
const char *sessions_marker;
|
||||
char *json = NULL;
|
||||
|
|
@ -950,6 +960,38 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
|||
identity = url + strlen(PREFIX);
|
||||
identity_length = strlen(identity);
|
||||
sessions_marker = strstr(identity, "/sessions/");
|
||||
if ((identity_length > strlen(PREVIEW_SUFFIX) &&
|
||||
strcmp(identity + identity_length - strlen(PREVIEW_SUFFIX), PREVIEW_SUFFIX) == 0) ||
|
||||
(identity_length > strlen(APPLY_SUFFIX) &&
|
||||
strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0)) {
|
||||
bool commit = strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0;
|
||||
size_t suffix_length = commit ? strlen(APPLY_SUFFIX) : strlen(PREVIEW_SUFFIX);
|
||||
char program_id[TRAINLOG_ID_MAX + 1U];
|
||||
const char *operation_id =
|
||||
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
|
||||
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||
"{\"error\":\"method_not_allowed\"}\n",
|
||||
"POST");
|
||||
}
|
||||
if (!program_mutation_allowed(context, connection) ||
|
||||
!copy_path_component(
|
||||
program_id, sizeof(program_id), identity, identity_length - suffix_length) ||
|
||||
(commit && (operation_id == NULL || operation_id[0] == '\0'))) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
||||
}
|
||||
status = trainlog_web_programs_reschedule_json(context->database,
|
||||
program_id,
|
||||
operation_id,
|
||||
request->body,
|
||||
request->body_size,
|
||||
commit,
|
||||
&json,
|
||||
&json_size);
|
||||
return queue_owned_programs_json(connection, status, json, json_size);
|
||||
}
|
||||
if (identity_length > strlen(ARCHIVE_SUFFIX) &&
|
||||
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
|
||||
return handle_program_archive(context, connection, identity, identity_length, method);
|
||||
|
|
@ -1893,6 +1935,14 @@ static enum MHD_Result handle_request(void *closure,
|
|||
context->database, search, offset, limit, &json, &json_size);
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
if (strcmp(url, "/api/v1/sessions/sport") == 0) {
|
||||
const char *start =
|
||||
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start");
|
||||
const char *end = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "end");
|
||||
TrainlogStatus status = trainlog_web_sessions_sport_json(
|
||||
context->database, start, end, offset, limit, &json, &json_size);
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) {
|
||||
if (strcmp(url, collections[index].path) == 0) {
|
||||
TrainlogWebSessionsPageQuery query = {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
#include <yyjson.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "timestamp.h"
|
||||
#include "trainlog/id.h"
|
||||
|
||||
typedef struct ListDefinition {
|
||||
|
|
@ -95,6 +96,117 @@ write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size
|
|||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database,
|
||||
const char *start,
|
||||
const char *end,
|
||||
size_t offset,
|
||||
size_t limit,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
static const char SQL[] =
|
||||
"SELECT s.session_id,s.session_type,s.started_at,s.ended_at,"
|
||||
"COALESCE((SELECT group_concat(name, ', ') FROM (SELECT DISTINCT x.name AS name FROM "
|
||||
"session_exercises se JOIN exercises x ON x.id=se.exercise_row_id WHERE "
|
||||
"se.session_row_id=s.id ORDER BY se.position LIMIT 8)),s.session_type) "
|
||||
"FROM sessions s WHERE unixepoch(s.started_at) IS NOT NULL AND "
|
||||
"unixepoch(s.started_at)<unixepoch(?2) AND "
|
||||
"(CASE WHEN unixepoch(s.ended_at)>unixepoch(s.started_at) "
|
||||
"THEN unixepoch(s.ended_at)>unixepoch(?1) ELSE unixepoch(s.started_at)>=unixepoch(?1) "
|
||||
"END) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
|
||||
"c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=1) "
|
||||
"ORDER BY unixepoch(s.started_at),s.session_id LIMIT ?3 OFFSET ?4";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_doc *document = NULL;
|
||||
yyjson_mut_val *root;
|
||||
yyjson_mut_val *items;
|
||||
TrainlogStatus status;
|
||||
TrainlogStatus end_status;
|
||||
size_t count = 0U;
|
||||
int step = SQLITE_DONE;
|
||||
TrainlogTimestampKey start_key;
|
||||
TrainlogTimestampKey end_key;
|
||||
|
||||
if (database == NULL || start == NULL || end == NULL || output_json == NULL ||
|
||||
output_size == NULL || limit == 0U || limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX ||
|
||||
offset > INT64_MAX || strlen(start) > 40U || strlen(end) > 40U ||
|
||||
!trainlog_timestamp_parse(start, strlen(start), &start_key) ||
|
||||
!trainlog_timestamp_parse(end, strlen(end), &end_key) ||
|
||||
trainlog_timestamp_compare(&start_key, &end_key) >= 0) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
status = trainlog_database_read_snapshot_begin(database);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
return status;
|
||||
}
|
||||
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, start, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, end, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)(limit + 1U)) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 4, (sqlite3_int64)offset) != SQLITE_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
document = yyjson_mut_doc_new(NULL);
|
||||
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||
items = document == NULL ? NULL : yyjson_mut_arr(document);
|
||||
if (root == NULL || items == NULL) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
yyjson_mut_doc_set_root(document, root);
|
||||
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
||||
!yyjson_mut_obj_add_uint(document, root, "offset", offset) ||
|
||||
!yyjson_mut_obj_add_val(document, root, "items", items)) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
yyjson_mut_val *item;
|
||||
if (count == limit) {
|
||||
++count;
|
||||
break;
|
||||
}
|
||||
item = yyjson_mut_obj(document);
|
||||
if (item == NULL || !add_nullable_text(document, item, "identity", statement, 0) ||
|
||||
!add_nullable_text(document, item, "session_type", statement, 1) ||
|
||||
!add_nullable_text(document, item, "started_at", statement, 2) ||
|
||||
!add_nullable_text(document, item, "ended_at", statement, 3) ||
|
||||
!add_nullable_text(document, item, "label", statement, 4) ||
|
||||
!yyjson_mut_arr_add_val(items, item)) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
if (step != SQLITE_DONE && count <= limit) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
if (!yyjson_mut_obj_add_bool(document, root, "more", count > limit) ||
|
||||
!yyjson_mut_obj_add_uint(document, root, "next_offset", offset + limit)) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
|
||||
finish_sport:
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
||||
status == TRAINLOG_STATUS_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = end_status;
|
||||
}
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
yyjson_mut_doc_free(document);
|
||||
return status;
|
||||
}
|
||||
return write_document(document, output_json, output_size);
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
|
||||
const TrainlogWebSessionsPageQuery *query,
|
||||
char **output_json,
|
||||
|
|
|
|||
|
|
@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
|
|||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strncmp(diagnostic,
|
||||
"migrate database to schema v36: SQLite rc=",
|
||||
strlen("migrate database to schema v36: SQLite rc=")) == 0);
|
||||
"migrate database to schema v37: SQLite rc=",
|
||||
strlen("migrate database to schema v37: SQLite rc=")) == 0);
|
||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||
|
|
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
|
|||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=37;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=38;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 37 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 38 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v36") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v37") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
|
|
@ -225,7 +225,8 @@ static bool test_factual_models(void) {
|
|||
{
|
||||
yyjson_val *program = yyjson_obj_get(root, "active_program");
|
||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_title")), "Next") == 0);
|
||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") == 0);
|
||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") ==
|
||||
0);
|
||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")),
|
||||
"2026-09-21") == 0);
|
||||
}
|
||||
|
|
@ -250,6 +251,123 @@ static bool test_factual_models(void) {
|
|||
return true;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return test_empty_and_periods() && test_factual_models() ? 0 : 1;
|
||||
static bool check_next_session(TrainlogDatabase *database,
|
||||
const char *expected_id,
|
||||
const char *expected_title,
|
||||
const char *expected_date) {
|
||||
yyjson_doc *document = NULL;
|
||||
yyjson_val *program;
|
||||
yyjson_val *id;
|
||||
yyjson_val *title;
|
||||
yyjson_val *date;
|
||||
|
||||
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
||||
program = yyjson_obj_get(yyjson_doc_get_root(document), "active_program");
|
||||
CHECK(!yyjson_is_null(program));
|
||||
id = yyjson_obj_get(program, "next_session_id");
|
||||
title = yyjson_obj_get(program, "next_session_title");
|
||||
date = yyjson_obj_get(program, "next_session_planned_for");
|
||||
CHECK(expected_id == NULL ? yyjson_is_null(id)
|
||||
: yyjson_is_str(id) && strcmp(yyjson_get_str(id), expected_id) == 0);
|
||||
CHECK(expected_title == NULL
|
||||
? yyjson_is_null(title)
|
||||
: yyjson_is_str(title) && strcmp(yyjson_get_str(title), expected_title) == 0);
|
||||
CHECK(expected_date == NULL
|
||||
? yyjson_is_null(date)
|
||||
: yyjson_is_str(date) && strcmp(yyjson_get_str(date), expected_date) == 0);
|
||||
yyjson_doc_free(document);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_current_program_plan(void) {
|
||||
TrainlogDatabase *database = NULL;
|
||||
yyjson_doc *document = NULL;
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
/* WHY: source dates remain historical; current planning and execution are
|
||||
* independent facts. These rows exercise their precedence without touching
|
||||
* any user database or invoking the rescheduling command. */
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO programs VALUES('pg_current','Current',NULL,'active',NULL,"
|
||||
"NULL,'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1,"
|
||||
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
|
||||
"NULL);"
|
||||
"INSERT INTO program_sessions VALUES"
|
||||
"('ps_ceded','pg_current',0,'Optional','training','2026-09-29',NULL),"
|
||||
"('ps_s2c','pg_current',1,'S2 C','training','2026-09-30',NULL),"
|
||||
"('ps_later','pg_current',2,'Later','training','2026-10-03',NULL),"
|
||||
"('ps_tie','pg_current',3,'Tie','training','2026-10-02',NULL),"
|
||||
"('ps_undated','pg_current',4,'Undated','training',NULL,NULL);",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, "ps_ceded", "Optional", "2026-09-29"));
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_planning VALUES"
|
||||
"('ps_ceded',NULL,'ceded','op_a','2026-10-01T00:00:00Z'),"
|
||||
"('ps_s2c','2026-10-02','active','op_b','2026-10-01T00:00:00Z');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_executions VALUES"
|
||||
"('ps_s2c','pg_current','se_fixture_1','in_progress',"
|
||||
"'2026-10-02T12:00:00Z');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"UPDATE program_session_executions SET state='completed' "
|
||||
"WHERE program_session_id='ps_s2c';",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, "ps_tie", "Tie", "2026-10-02"));
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_executions VALUES"
|
||||
"('ps_tie','pg_current','se_fixture_2','completed','2026-10-02T12:00:00Z'),"
|
||||
"('ps_later','pg_current','se_fixture_3','deleted','2026-10-03T12:00:00Z');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, "ps_undated", "Undated", NULL));
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_executions VALUES"
|
||||
"('ps_undated','pg_current','se_fixture_4','completed',"
|
||||
"'2026-10-04T12:00:00Z');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, NULL, NULL, NULL));
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"UPDATE programs SET state='archived' WHERE program_id='pg_current';",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
||||
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
|
||||
yyjson_doc_free(document);
|
||||
document = NULL;
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"UPDATE programs SET state='active',deleted_at='2026-10-05T00:00:00Z' "
|
||||
"WHERE program_id='pg_current';",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
||||
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
|
||||
yyjson_doc_free(document);
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return test_empty_and_periods() && test_factual_models() && test_current_program_plan() ? 0 : 1;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue