Compare commits
No commits in common. "trainlog-docs-deep-review-v1" and "main" have entirely different histories.
trainlog-d
...
main
123 changed files with 502 additions and 5243 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -8,13 +8,12 @@ Trainlog consists of:
|
||||||
- a Unix/Linux C17 terminal TUI for durable history, correction, analysis,
|
- a Unix/Linux C17 terminal TUI for durable history, correction, analysis,
|
||||||
visualization, and manual synchronization, using Notcurses as its completed
|
visualization, and manual synchronization, using Notcurses as its completed
|
||||||
active rendering/input backend;
|
active rendering/input backend;
|
||||||
- a loopback-only local Web adapter with implemented Dashboard, Analyse,
|
- a loopback-only local Web adapter whose Dashboard is implemented on the
|
||||||
Programmes, Sessions, and Exercises routes on the 0.1.7 development line;
|
0.1.2 development branch; its Analyse, Programmes, Sessions, and Exercises
|
||||||
|
routes remain placeholders pending separate contracts;
|
||||||
- a small user-session PC agent, `trainlog-syncd`, for Android-triggered
|
- a small user-session PC agent, `trainlog-syncd`, for Android-triggered
|
||||||
synchronization;
|
synchronization;
|
||||||
- versioned JSON synchronization artifacts exchanged through configured
|
- versioned JSON synchronization artifacts exchanged over direct MTP.
|
||||||
Bluetooth Classic RFCOMM, direct MTP recovery, or a separate private Drive
|
|
||||||
workflow.
|
|
||||||
|
|
||||||
The desktop SQLite database is the canonical long-term history.
|
The desktop SQLite database is the canonical long-term history.
|
||||||
|
|
||||||
|
|
@ -171,7 +170,7 @@ It is not the canonical analytics store.
|
||||||
The Android UI is driven by exercise metadata, never by exercise-name
|
The Android UI is driven by exercise metadata, never by exercise-name
|
||||||
heuristics.
|
heuristics.
|
||||||
|
|
||||||
Android local SQLite schema v34 retains the exactly-one durable active-session
|
Android local SQLite schema v24 retains the exactly-one durable active-session
|
||||||
draft introduced in v4.
|
draft introduced in v4.
|
||||||
Every meaningful draft/form mutation is persisted by the repository. Back,
|
Every meaningful draft/form mutation is persisted by the repository. Back,
|
||||||
backgrounding and process death never delete the draft. Home offers explicit
|
backgrounding and process death never delete the draft. Home offers explicit
|
||||||
|
|
@ -185,7 +184,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
|
||||||
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
|
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
|
||||||
durable flattened exercise-alias mapping used to resolve retired creator IDs.
|
durable flattened exercise-alias mapping used to resolve retired creator IDs.
|
||||||
Desktop schema v12 owns the same alias compatibility boundary. Later additive
|
Desktop schema v12 owns the same alias compatibility boundary. Later additive
|
||||||
desktop migrations reach v37 and Android migrations reach v34 without changing
|
desktop migrations reach v26 and Android migrations reach v24 without changing
|
||||||
that identity contract. The active
|
that identity contract. The active
|
||||||
completed-session exchange is the separate strict
|
completed-session exchange is the separate strict
|
||||||
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
|
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
|
||||||
|
|
@ -242,16 +241,14 @@ Desktop SQLite schema is versioned with:
|
||||||
PRAGMA user_version;
|
PRAGMA user_version;
|
||||||
```
|
```
|
||||||
|
|
||||||
The current desktop schema is v37. Schema v9 added an occurrence-owned explicit
|
The current desktop schema is v26. Schema v9 added an occurrence-owned explicit
|
||||||
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
|
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
|
||||||
flattened exercise aliases; v13-v18 add machine metadata, feedback and
|
flattened exercise aliases; v13-v18 add machine metadata, feedback and
|
||||||
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
|
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
|
||||||
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
|
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
|
||||||
generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
|
generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
|
||||||
and durable preparation withdrawal; v25 adds Programs; and v26 additively adds
|
and durable preparation withdrawal; v25 adds Programs; and v26 additively adds
|
||||||
logical Program deletion. Later additive migrations through v37 add Sleep,
|
logical Program deletion. None changes `TRAINLOG_FORMAT_V1`.
|
||||||
heart-rate, Cardio and Program planning state. None changes
|
|
||||||
`TRAINLOG_FORMAT_V1`.
|
|
||||||
|
|
||||||
Every incompatible schema evolution requires an explicit migration and
|
Every incompatible schema evolution requires an explicit migration and
|
||||||
regression coverage.
|
regression coverage.
|
||||||
|
|
|
||||||
101
CHANGELOG.md
101
CHANGELOG.md
|
|
@ -1,106 +1,5 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased — 0.1.7
|
|
||||||
|
|
||||||
- Reconciled the canonical documentation against the `f4ddcfd` development
|
|
||||||
lineage: current source schemas v37/v34, implemented Web routes and Program
|
|
||||||
rescheduling, observed package provenance, Sleep row paging, capability
|
|
||||||
selected exchange, Web packaging requirements and validation limits. The
|
|
||||||
[documentation review](docs/reviews/documentation_deep_review_v1.md) records
|
|
||||||
the evidence matrix. This entry changes no runtime behavior or format.
|
|
||||||
|
|
||||||
- Show the Web Sleep agenda newest first with one 7, 14, 21, 28, or All
|
|
||||||
recorded-night selector (7 by default). Sleep reads its full available history
|
|
||||||
without the Analysis period control; other Analysis sections retain their
|
|
||||||
period choice. The browser-local 5/10/15 preference migrates to 7/14/21,
|
|
||||||
and a capped full-history read fails explicitly. Page changes leave the
|
|
||||||
selected night, editor, statistics, and inclusive PDF range untouched.
|
|
||||||
Removed only the redundant textual sport list below the Web agenda; factual
|
|
||||||
blue overlays, the sport legend, and the complete PDF sport appendix remain.
|
|
||||||
|
|
||||||
- Corrected the Dashboard/Analysis active-Program next-session projection to
|
|
||||||
select title, identity, and current date from one pending Program session.
|
|
||||||
Recorded rescheduling dates replace imported dates, ceded slots are excluded,
|
|
||||||
and an overdue uncompleted session remains eligible. The read-only projection
|
|
||||||
does not change Program history, preparation priority, or synchronization.
|
|
||||||
|
|
||||||
- Aligned the Web front/back BODY ZONES figures with Android's normalized
|
|
||||||
Compose Canvas silhouettes. The Web now uses Android's head, neck and 11
|
|
||||||
region paths per face, while retaining the existing Catppuccin intensity
|
|
||||||
classes, 30-day session counts, legend, labels and keyboard focus behavior.
|
|
||||||
No Android, domain, database or synchronization behavior changed.
|
|
||||||
|
|
||||||
- Widened the Sleep diary PDF treatment/remarks column from 84 to 180 pt
|
|
||||||
and raised its text from 4 to 7.2 pt. Width-aware wrapping keeps paragraphs,
|
|
||||||
separate medication intakes, accents, doses, units, and quantities visible.
|
|
||||||
Row heights and page count now follow content; exceptional entries continue
|
|
||||||
on clearly dated pages. The former truncated observations duplicate is gone.
|
|
||||||
The 18:00 timeline and factual blue sport overlay use one shared PDF geometry;
|
|
||||||
Sleep, sport, database, and synchronization data are unchanged.
|
|
||||||
The private PC/Web rollout from `3758875` was verified with a four-page
|
|
||||||
browser-downloaded PDF; synchronization services retained their Program paths.
|
|
||||||
|
|
||||||
- Added a read-only completed-session overlay to the Web Sleep agenda and its
|
|
||||||
local vector PDF preview/export. Factual session start and end times share
|
|
||||||
the Sleep row's 18:00 axis in both renderers; blue outlined bands and a
|
|
||||||
printable paginated listing expose recorded names and times. Missing ends
|
|
||||||
remain point markers. The report flags sport without a recorded Sleep row,
|
|
||||||
and export waits for the complete paginated history read. Sleep entries,
|
|
||||||
session history, database schema, Android, and synchronization contracts
|
|
||||||
remain unchanged.
|
|
||||||
The private PC/Web rollout from `e2c07f1` was verified against its served
|
|
||||||
agenda and an actual downloaded PDF; the phone installation was untouched.
|
|
||||||
|
|
||||||
The isolated `trainlog-program-recalage-v1` candidate adds an explicit
|
|
||||||
preview-and-confirm cascade for unfinished Program sessions. It retains source
|
|
||||||
and actual dates, stores absolute current dates and ceded-slot state, and
|
|
||||||
selects a separate Programs V2 Android projection by capability. The source
|
|
||||||
candidate remains isolated from the stable checkout while its private desktop
|
|
||||||
and compatible Android build are installed for the coordinated rollout.
|
|
||||||
Rollout packaging rejects a native build without the embedded Web frontend.
|
|
||||||
The private rollout preserves parsed V1 Program number types during Android V2
|
|
||||||
upgrade and raises the bounded local MTP operation budget to 90 seconds and
|
|
||||||
retained outgoing generation capacity to 16 after interrupted exchanges.
|
|
||||||
|
|
||||||
Development opened after the stable v0.1.6 release. This line is restricted to
|
|
||||||
observed bug fixes, regression fixes, directly related small UI corrections,
|
|
||||||
documentation corrections, robustness, and associated tests. It does not admit
|
|
||||||
new features, modules, protocols, or speculative refactors.
|
|
||||||
|
|
||||||
- Corrected the Web Program calendar's completed-session placement using the
|
|
||||||
linked session's actual start date in the browser's history timezone. A
|
|
||||||
changed day displays both actual and imported dates, and concurrent cards
|
|
||||||
remain distinct. The Program detail Web API gains a nullable read-only
|
|
||||||
timestamp field; this changes no history, schema, Android behavior, or sync
|
|
||||||
artifact.
|
|
||||||
Durable ordered rescheduling remains outside the stabilized Programs V1
|
|
||||||
exchange: its strict snapshot has no current date or ceded-slot state.
|
|
||||||
- Fixed automatic Bluetooth synchronization recovery when RFCOMM disconnects
|
|
||||||
after `trainlog-syncd` admits Android's arrival request but before the
|
|
||||||
generation conversation can start. Requests whose durable terminal result
|
|
||||||
is explicitly transient (`device_unavailable`, `transport_timeout`,
|
|
||||||
`sync_in_progress`, or interruption) remain eligible after reconnect;
|
|
||||||
completed requests and business failures remain consumed. Generation, ACK,
|
|
||||||
causal, schema, MTP, Drive, and manual-sync contracts are unchanged.
|
|
||||||
- Fixed Android generation coordination after a durable desktop rejection.
|
|
||||||
The Android producer now retains and reports the correlated `rejected` ACK
|
|
||||||
but immediately releases its conversation lease instead of waiting for a
|
|
||||||
reciprocal desktop generation that the rejecting worker cannot publish.
|
|
||||||
This prevents a rejected stale revision from starving a subsequent Web or
|
|
||||||
automatic Bluetooth request; rejection, generation, ACK, causal, schema,
|
|
||||||
and transport contracts are unchanged.
|
|
||||||
- Preserved the desktop consumer's known Sleep revision rejection diagnostic
|
|
||||||
through the full-generation worker and classified it as `data_conflict` in
|
|
||||||
Web run state. A synthetic divergent-final-wake regression keeps the true
|
|
||||||
conflict rejected; this reporting fix does not reconcile real Sleep data or
|
|
||||||
turn a rejected generation into a successful exchange.
|
|
||||||
- Resolved safely comparable concurrent Sleep V2 edits by the last actual
|
|
||||||
user-edit instant, regardless of device or the event times being recorded.
|
|
||||||
Complete winning snapshots and both original immutable revisions are kept;
|
|
||||||
a local winner gains a fresh causal successor for peer convergence. Equal
|
|
||||||
timestamps, changed entry envelopes, and deletion transitions still reject
|
|
||||||
instead of guessing. Existing rejected generation ACKs are not rewritten.
|
|
||||||
|
|
||||||
## 0.1.6 — 2026-09-25
|
## 0.1.6 — 2026-09-25
|
||||||
|
|
||||||
- Corrected delayed session finalization so a heart-rate capture cannot extend
|
- Corrected delayed session finalization so a heart-rate capture cannot extend
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -145,20 +145,13 @@ cd ..
|
||||||
|
|
||||||
meson setup build-web -Dweb=enabled
|
meson setup build-web -Dweb=enabled
|
||||||
meson compile -C build-web
|
meson compile -C build-web
|
||||||
meson test -C build-web --print-errorlogs
|
|
||||||
./build-web/tui/trainlog --web
|
./build-web/tui/trainlog --web
|
||||||
```
|
```
|
||||||
|
|
||||||
The Web application listens on `127.0.0.1:8080` by default. Use
|
The Web application listens on `127.0.0.1:8080` by default. Use
|
||||||
`--port <port>` for an explicit alternative. For an existing build directory,
|
`--port <port>` for an explicit alternative. For an existing build directory,
|
||||||
use `meson configure build -Dweb=enabled` instead of running `meson setup`
|
use `meson configure build -Dweb=enabled` instead of running `meson setup`
|
||||||
again. The separate `npm test` run checks Web source; the enabled Meson build
|
again.
|
||||||
registers embedded frontend tests and places its assets in the executable.
|
|
||||||
The default `web=auto` configuration does not by itself prove embedded assets.
|
|
||||||
For a compatible service bundle, use the versioned candidate packaging
|
|
||||||
procedure in [rollout readiness](docs/sync_rollout_readiness.md); copying only
|
|
||||||
`trainlog` omits its matching synchronization helpers. Restarting a service
|
|
||||||
does not change the package selected by its launcher.
|
|
||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
|
|
@ -238,7 +231,7 @@ chronological detail in the [`CHANGELOG`](CHANGELOG.md).
|
||||||
|
|
||||||
| Boundary | Current state |
|
| Boundary | Current state |
|
||||||
| ------------------- | --------------------------------------------------------------------------------------------------------- |
|
| ------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
| Product | `0.1.7` bugfix-only development; latest stable release `v0.1.6` |
|
| Product | `0.1.6` stable |
|
||||||
| Desktop persistence | SQLite schema v36 |
|
| Desktop persistence | SQLite schema v36 |
|
||||||
| Android persistence | SQLite schema v33 |
|
| Android persistence | SQLite schema v33 |
|
||||||
| Mobile exchange | V3 active; V1/V2 readable; V4 staged only |
|
| Mobile exchange | V3 active; V1/V2 readable; V4 staged only |
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ ANDROID_BODY_SHELL=IMPLEMENTED
|
||||||
LOCAL_PERSISTENCE=IMPLEMENTED
|
LOCAL_PERSISTENCE=IMPLEMENTED
|
||||||
DIRECT_STORAGE=IMPLEMENTED
|
DIRECT_STORAGE=IMPLEMENTED
|
||||||
MTP_SYNC=IMPLEMENTED
|
MTP_SYNC=IMPLEMENTED
|
||||||
ANDROID_SCHEMA=34
|
ANDROID_SCHEMA=33
|
||||||
```
|
```
|
||||||
|
|
||||||
## Visual contract
|
## Visual contract
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ val trainlogVersionCode =
|
||||||
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
||||||
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
||||||
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||||
} ?: 8
|
} ?: 7
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* WHY: a user may need to finish one preserved draft after a completion bug
|
* WHY: a user may need to finish one preserved draft after a completion bug
|
||||||
|
|
@ -87,7 +87,7 @@ android {
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
versionCode = trainlogVersionCode
|
versionCode = trainlogVersionCode
|
||||||
versionName = "0.1.7"
|
versionName = "0.1.6"
|
||||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
|
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
|
||||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
|
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
|
||||||
testInstrumentationRunner =
|
testInstrumentationRunner =
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
||||||
manifest.getInt("version") == VERSION &&
|
manifest.getInt("version") == VERSION &&
|
||||||
manifest.getString("package") == context.packageName &&
|
manifest.getString("package") == context.packageName &&
|
||||||
manifest.getBoolean("plaintext") &&
|
manifest.getBoolean("plaintext") &&
|
||||||
manifest.getInt("schema") in 17..34
|
manifest.getInt("schema") in 17..33
|
||||||
) {
|
) {
|
||||||
"Unsupported backup."
|
"Unsupported backup."
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||||
require(db.version in 17..34) { "Unsupported Android schema ${db.version}." }
|
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
|
||||||
return db.version
|
return db.version
|
||||||
} finally {
|
} finally {
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,6 @@ internal class SyncGenerationCoordinator(
|
||||||
ACK_RECOVERY_CAPABILITY,
|
ACK_RECOVERY_CAPABILITY,
|
||||||
"session-preparations-v2",
|
"session-preparations-v2",
|
||||||
"programs-v1",
|
"programs-v1",
|
||||||
"programs-v2",
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -607,18 +606,6 @@ internal class SyncGenerationCoordinator(
|
||||||
}, pollTransport, lease)
|
}, pollTransport, lease)
|
||||||
service.acceptAcknowledgement(desktopAck)
|
service.acceptAcknowledgement(desktopAck)
|
||||||
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
|
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
|
||||||
/* WHY: a durable desktop rejection closes this Android-produced
|
|
||||||
* generation, so the desktop worker deliberately cannot publish a
|
|
||||||
* reciprocal generation for this conversation. CONTRACT: retain
|
|
||||||
* and validate the rejection ACK, then end this run immediately.
|
|
||||||
* INVARIANT: a rejected generation never holds the process-wide
|
|
||||||
* conversation lease while waiting for an impossible reference. */
|
|
||||||
val desktopAcknowledgement = JSONObject(desktopAck.toString(Charsets.UTF_8))
|
|
||||||
if (desktopAcknowledgement.getString("result") == "rejected") {
|
|
||||||
val diagnostic = desktopAcknowledgement.getString("diagnostic")
|
|
||||||
phase(runId, "desktop_rejected", "desktop-consumption-ack-v1.json", captured.generationId)
|
|
||||||
return ForegroundGenerationResult.Failed(diagnostic, captured.runId)
|
|
||||||
}
|
|
||||||
val referenceRaw =
|
val referenceRaw =
|
||||||
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
|
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
|
||||||
it.optString("run_id") == captured.runId
|
it.optString("run_id") == captured.runId
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
(ANDROID_KINDS.values +
|
(ANDROID_KINDS.values +
|
||||||
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
|
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
|
||||||
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) +
|
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) +
|
||||||
Kind("trainlog-programs", 1, "programs-v1.json", false) +
|
Kind("trainlog-programs", 1, "programs-v1.json", false))
|
||||||
Kind("trainlog-programs", 2, "programs-v2.json", false))
|
|
||||||
.map { it.format to it.version }
|
.map { it.format to it.version }
|
||||||
.toSet()
|
.toSet()
|
||||||
|
|
||||||
|
|
@ -774,8 +773,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
throw SyncGenerationException("artifact digest mismatch")
|
throw SyncGenerationException("artifact digest mismatch")
|
||||||
artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8)
|
artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8)
|
||||||
}
|
}
|
||||||
if ("programs-v1" in artifacts && "programs-v2" in artifacts)
|
|
||||||
throw SyncGenerationException("multiple Program revisions in one generation")
|
|
||||||
return ValidatedGeneration(manifest, sha256(raw), artifacts)
|
return ValidatedGeneration(manifest, sha256(raw), artifacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -950,10 +947,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
apply("programs-v1") {
|
apply("programs-v1") {
|
||||||
repository.applyProgramsV1Json(checkNotNull(a["programs-v1"]))
|
repository.applyProgramsV1Json(checkNotNull(a["programs-v1"]))
|
||||||
}
|
}
|
||||||
if ("programs-v2" in a)
|
|
||||||
apply("programs-v2") {
|
|
||||||
repository.applyProgramsV2Json(checkNotNull(a["programs-v2"]))
|
|
||||||
}
|
|
||||||
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
|
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
|
||||||
apply("exercise-profile-state") {
|
apply("exercise-profile-state") {
|
||||||
repository.applyExerciseProfileStateJson(
|
repository.applyExerciseProfileStateJson(
|
||||||
|
|
|
||||||
|
|
@ -676,9 +676,8 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
} catch (_: RuntimeException) { false }
|
} catch (_: RuntimeException) { false }
|
||||||
|
|
||||||
/** CONTRACT: one local edit transaction appends an immutable revision and
|
/** CONTRACT: one transaction appends an immutable revision and advances
|
||||||
* advances the guarded tip. Cross-device conflict selection belongs only
|
* the guarded tip. Wall time never resolves a concurrent edit. */
|
||||||
* to the versioned import path after both edit instants are validated. */
|
|
||||||
fun saveSleepDiary(draft: SleepDiaryDraft): SaveSleepDiaryResult =
|
fun saveSleepDiary(draft: SleepDiaryDraft): SaveSleepDiaryResult =
|
||||||
saveSleepDiary(draft, quickPublicationAt = null)
|
saveSleepDiary(draft, quickPublicationAt = null)
|
||||||
|
|
||||||
|
|
@ -1556,21 +1555,8 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
val remoteDescendsFromLocal =
|
val remoteDescendsFromLocal =
|
||||||
local != null && ancestry?.drop(1)?.contains(local.first) == true
|
local != null && ancestry?.drop(1)?.contains(local.first) == true
|
||||||
var winningLocalRevision: String? = null
|
if (local != null && !remoteDescendsFromLocal && parent != local.first)
|
||||||
if (local != null && !remoteDescendsFromLocal && parent != local.first) {
|
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
|
||||||
val localWins = sleepConcurrentLocalWins(
|
|
||||||
db, item, entryId, local.first, wireVersion,
|
|
||||||
)
|
|
||||||
if (localWins) {
|
|
||||||
if (knownOwner != null) {
|
|
||||||
// A retained losing branch must not generate another
|
|
||||||
// reconciliation revision when its artifact replays.
|
|
||||||
unchanged++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
winningLocalRevision = local.first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* The snapshot may start at a non-root current revision on a
|
/* The snapshot may start at a non-root current revision on a
|
||||||
* fresh peer. Preserve its causal parent identity even though
|
* fresh peer. Preserve its causal parent identity even though
|
||||||
* the historical parent payload is intentionally absent. */
|
* the historical parent payload is intentionally absent. */
|
||||||
|
|
@ -1620,77 +1606,21 @@ class TrainlogRepository(
|
||||||
arrayOf(entryId,draft.nightStartDate,draft.nightEndDate,draft.createdAt,draft.updatedAt,revisionId,if (deleted) 1 else 0))
|
arrayOf(entryId,draft.nightStartDate,draft.nightEndDate,draft.createdAt,draft.updatedAt,revisionId,if (deleted) 1 else 0))
|
||||||
else db.execSQL("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
|
else db.execSQL("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
|
||||||
arrayOf(draft.nightStartDate,draft.nightEndDate,draft.updatedAt,revisionId,if (deleted) 1 else 0,entryId,local.first))
|
arrayOf(draft.nightStartDate,draft.nightEndDate,draft.updatedAt,revisionId,if (deleted) 1 else 0,entryId,local.first))
|
||||||
if (knownOwner == null) {
|
db.execSQL("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
|
||||||
db.execSQL(
|
arrayOf(revisionId,entryId,parent,draft.updatedAt,draft.sleepQuality?.wireValue,draft.wakeQuality?.wireValue,draft.dayForm?.wireValue,draft.treatmentAndNotes))
|
||||||
"INSERT INTO sleep_diary_revisions(revision_id,entry_id," +
|
draft.events.forEach { event -> db.execSQL("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
|
||||||
"parent_revision_id,created_at,sleep_quality,wake_quality,day_form," +
|
arrayOf(revisionId,event.eventId,event.type.wireValue,event.startAt,event.endAt)) }
|
||||||
"treatment_and_notes) VALUES(?,?,?,?,?,?,?,?)",
|
draft.intakes.forEach { intake -> db.execSQL(
|
||||||
arrayOf(
|
"INSERT INTO sleep_medication_intakes(" +
|
||||||
revisionId, entryId, parent, draft.updatedAt,
|
"revision_id,intake_id,medication_id,medication_name,taken_at," +
|
||||||
draft.sleepQuality?.wireValue, draft.wakeQuality?.wireValue,
|
"dose_value,dose_unit,quantity,note,created_at" +
|
||||||
draft.dayForm?.wireValue, draft.treatmentAndNotes,
|
") VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||||
),
|
arrayOf<Any?>(revisionId, intake.intakeId, intake.medicationId,
|
||||||
)
|
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
|
||||||
draft.events.forEach { event ->
|
intake.quantity,
|
||||||
db.execSQL(
|
intake.note, intake.createdAt),
|
||||||
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type," +
|
) }
|
||||||
"start_at,end_at) VALUES(?,?,?,?,?)",
|
|
||||||
arrayOf(revisionId, event.eventId, event.type.wireValue,
|
|
||||||
event.startAt, event.endAt),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
draft.intakes.forEach { intake -> db.execSQL(
|
|
||||||
"INSERT INTO sleep_medication_intakes(" +
|
|
||||||
"revision_id,intake_id,medication_id,medication_name,taken_at," +
|
|
||||||
"dose_value,dose_unit,quantity,note,created_at" +
|
|
||||||
") VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
arrayOf<Any?>(revisionId, intake.intakeId, intake.medicationId,
|
|
||||||
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
|
|
||||||
intake.quantity, intake.note, intake.createdAt),
|
|
||||||
) }
|
|
||||||
}
|
|
||||||
markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at"))
|
markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at"))
|
||||||
if (winningLocalRevision != null) {
|
|
||||||
val successor = sleepId("slr")
|
|
||||||
// INVARIANT: this technical successor inherits the latest
|
|
||||||
// user's edit timestamp and exact snapshot, not the import
|
|
||||||
// time. Its remote parent makes the peer converge, while
|
|
||||||
// both original sibling revisions stay immutable.
|
|
||||||
db.execSQL(
|
|
||||||
"INSERT INTO sleep_diary_revisions(revision_id,entry_id," +
|
|
||||||
"parent_revision_id,created_at,sleep_quality,wake_quality,day_form," +
|
|
||||||
"treatment_and_notes) " +
|
|
||||||
"SELECT ?,entry_id,?,created_at,sleep_quality,wake_quality,day_form," +
|
|
||||||
"treatment_and_notes FROM sleep_diary_revisions WHERE revision_id=?",
|
|
||||||
arrayOf(successor, revisionId, winningLocalRevision),
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type," +
|
|
||||||
"start_at,end_at) " +
|
|
||||||
"SELECT ?,event_id,event_type,start_at,end_at FROM sleep_diary_events " +
|
|
||||||
"WHERE revision_id=?",
|
|
||||||
arrayOf(successor, winningLocalRevision),
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id," +
|
|
||||||
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) " +
|
|
||||||
"SELECT ?,intake_id,medication_id,medication_name,taken_at,dose_value," +
|
|
||||||
"dose_unit,quantity,note,created_at FROM sleep_medication_intakes " +
|
|
||||||
"WHERE revision_id=?",
|
|
||||||
arrayOf(successor, winningLocalRevision),
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"UPDATE sleep_diary_entries SET updated_at=(SELECT created_at FROM " +
|
|
||||||
"sleep_diary_revisions WHERE revision_id=?),current_revision_id=? " +
|
|
||||||
"WHERE entry_id=? AND current_revision_id=?",
|
|
||||||
arrayOf(winningLocalRevision, successor, entryId, revisionId),
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"UPDATE sleep_diary_publication_state SET validated_revision_id=?," +
|
|
||||||
"validated_at=? WHERE entry_id=?",
|
|
||||||
arrayOf(successor, root.getString("generated_at"), entryId),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
advanced++
|
advanced++
|
||||||
}
|
}
|
||||||
SleepDiaryImportResult.Applied(advanced, unchanged)
|
SleepDiaryImportResult.Applied(advanced, unchanged)
|
||||||
|
|
@ -1699,40 +1629,6 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sleepConcurrentLocalWins(
|
|
||||||
db: SQLiteDatabase,
|
|
||||||
item: JSONObject,
|
|
||||||
entryId: String,
|
|
||||||
localRevision: String,
|
|
||||||
wireVersion: Int,
|
|
||||||
): Boolean {
|
|
||||||
// WHY: generation/ACK timestamps are transport events. Only revision
|
|
||||||
// creation times reflect editing actions on the two clients.
|
|
||||||
// CONTRACT: V2 sibling tips with an unchanged entry envelope and
|
|
||||||
// deletion state may use last-user-edit-wins. An equal edit instant
|
|
||||||
// or inconsistent provenance remains an explicit conflict.
|
|
||||||
if (wireVersion != 2) throw IllegalArgumentException("concurrent sleep diary revision")
|
|
||||||
val local = db.rawQuery(
|
|
||||||
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," +
|
|
||||||
"r.created_at FROM sleep_diary_entries e JOIN sleep_diary_revisions r " +
|
|
||||||
"ON r.revision_id=e.current_revision_id WHERE e.entry_id=? AND r.revision_id=?",
|
|
||||||
arrayOf(entryId, localRevision),
|
|
||||||
).use { cursor ->
|
|
||||||
if (!cursor.moveToFirst()) null else List(6) { index -> cursor.getString(index) }
|
|
||||||
} ?: throw IllegalArgumentException("concurrent sleep diary revision")
|
|
||||||
if (local[0] != item.getString("night_start_date") ||
|
|
||||||
local[1] != item.getString("night_end_date") ||
|
|
||||||
local[2] != item.getString("created_at") ||
|
|
||||||
(local[4] == "1") != item.getBoolean("deleted") || local[3] != local[5]
|
|
||||||
) throw IllegalArgumentException("concurrent sleep diary revision")
|
|
||||||
val localAt = OffsetDateTime.parse(local[5]).toInstant()
|
|
||||||
val remoteAt = OffsetDateTime.parse(item.getString("updated_at")).toInstant()
|
|
||||||
if (localAt == remoteAt) throw IllegalArgumentException(
|
|
||||||
"concurrent sleep diary revision has indeterminate edit order",
|
|
||||||
)
|
|
||||||
return localAt > remoteAt
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sleepDiaryRevisionMatches(
|
private fun sleepDiaryRevisionMatches(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
item: JSONObject,
|
item: JSONObject,
|
||||||
|
|
@ -11825,39 +11721,7 @@ class TrainlogRepository(
|
||||||
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
|
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
|
||||||
* wins over a later stale snapshot and never touches drafts, preparations, history or catalog.
|
* wins over a later stale snapshot and never touches drafts, preparations, history or catalog.
|
||||||
*/
|
*/
|
||||||
fun applyProgramsV1Json(json: String): ProgramsImportResult = applyProgramsJson(json, 1)
|
fun applyProgramsV1Json(json: String): ProgramsImportResult {
|
||||||
|
|
||||||
fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2)
|
|
||||||
|
|
||||||
private fun legacyProgramDigest(program: JSONObject): String {
|
|
||||||
// WHY: serializing and reparsing JSONObject changes integral doubles (for
|
|
||||||
// example 52.0 to 52), which changes a V1 revision's causal digest.
|
|
||||||
// CONTRACT: discard only V2 fields while retaining the parsed V1 values.
|
|
||||||
val legacy = JSONObject()
|
|
||||||
program.keys().forEach { key ->
|
|
||||||
when (key) {
|
|
||||||
"revision_sequence" -> Unit
|
|
||||||
"sessions" -> {
|
|
||||||
val rows = JSONArray()
|
|
||||||
val sessions = program.getJSONArray("sessions")
|
|
||||||
for (index in 0 until sessions.length()) {
|
|
||||||
val source = sessions.getJSONObject(index)
|
|
||||||
val row = JSONObject()
|
|
||||||
source.keys().forEach { sessionKey ->
|
|
||||||
if (sessionKey !in setOf("position", "current_for", "planning_state"))
|
|
||||||
row.put(sessionKey, source.get(sessionKey))
|
|
||||||
}
|
|
||||||
rows.put(row)
|
|
||||||
}
|
|
||||||
legacy.put(key, rows)
|
|
||||||
}
|
|
||||||
else -> legacy.put(key, program.get(key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return causalDigest(legacy)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult {
|
|
||||||
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
|
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
|
||||||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
|
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
|
||||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||||
|
|
@ -11885,7 +11749,7 @@ class TrainlogRepository(
|
||||||
try {
|
try {
|
||||||
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
|
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
|
||||||
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
|
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
|
||||||
root.getInt("version") != documentVersion || !timestamp(root.opt("generated_at")) ||
|
root.getInt("version") != 1 || !timestamp(root.opt("generated_at")) ||
|
||||||
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
|
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
|
||||||
throw ProgramsImportInvalid("Unsupported Programs document.")
|
throw ProgramsImportInvalid("Unsupported Programs document.")
|
||||||
val programs = root.getJSONArray("programs")
|
val programs = root.getJSONArray("programs")
|
||||||
|
|
@ -11912,16 +11776,11 @@ class TrainlogRepository(
|
||||||
for (programIndex in 0 until programs.length()) {
|
for (programIndex in 0 until programs.length()) {
|
||||||
val program = programs.optJSONObject(programIndex)
|
val program = programs.optJSONObject(programIndex)
|
||||||
?: throw ProgramsImportInvalid("Invalid program.")
|
?: throw ProgramsImportInvalid("Invalid program.")
|
||||||
val programKeysValid = if (documentVersion == 2) program.exact(
|
if (!program.exact(
|
||||||
"program_id", "revision_id", "revision_sequence", "title", "note", "state", "start_date",
|
|
||||||
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
|
||||||
"source_payload_sha256", "sessions",
|
|
||||||
) else program.exact(
|
|
||||||
"program_id", "revision_id", "title", "note", "state", "start_date",
|
"program_id", "revision_id", "title", "note", "state", "start_date",
|
||||||
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
||||||
"source_payload_sha256", "sessions",
|
"source_payload_sha256", "sessions",
|
||||||
)
|
) ||
|
||||||
if (!programKeysValid ||
|
|
||||||
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
|
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
|
||||||
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
|
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
|
||||||
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
|
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
|
||||||
|
|
@ -11931,33 +11790,17 @@ class TrainlogRepository(
|
||||||
(program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
|
(program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
|
||||||
program.opt("sessions") !is JSONArray)
|
program.opt("sessions") !is JSONArray)
|
||||||
throw ProgramsImportInvalid("Invalid program metadata.")
|
throw ProgramsImportInvalid("Invalid program metadata.")
|
||||||
if (documentVersion == 2 && !integer(program.opt("revision_sequence"), 0, Int.MAX_VALUE))
|
|
||||||
throw ProgramsImportInvalid("Invalid Program revision sequence.")
|
|
||||||
val sessions = program.getJSONArray("sessions")
|
val sessions = program.getJSONArray("sessions")
|
||||||
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
|
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
|
||||||
for (sessionIndex in 0 until sessions.length()) {
|
for (sessionIndex in 0 until sessions.length()) {
|
||||||
val session = sessions.optJSONObject(sessionIndex)
|
val session = sessions.optJSONObject(sessionIndex)
|
||||||
?: throw ProgramsImportInvalid("Invalid program session.")
|
?: throw ProgramsImportInvalid("Invalid program session.")
|
||||||
val validKeys = if (documentVersion == 2) session.exact(
|
if (!session.exact("program_session_id", "title", "session_type", "planned_for", "note", "occurrences") ||
|
||||||
"program_session_id", "position", "title", "session_type", "planned_for",
|
|
||||||
"current_for", "planning_state", "note", "occurrences",
|
|
||||||
) else session.exact(
|
|
||||||
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences",
|
|
||||||
)
|
|
||||||
if (!validKeys ||
|
|
||||||
!bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) ||
|
!bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) ||
|
||||||
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
|
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
|
||||||
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
|
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
|
||||||
session.opt("occurrences") !is JSONArray)
|
session.opt("occurrences") !is JSONArray)
|
||||||
throw ProgramsImportInvalid("Invalid program session metadata.")
|
throw ProgramsImportInvalid("Invalid program session metadata.")
|
||||||
if (documentVersion == 2 &&
|
|
||||||
(!integer(session.opt("position"), 0, 255) || session.getInt("position") != sessionIndex ||
|
|
||||||
!date(session.opt("current_for")) ||
|
|
||||||
session.optString("planning_state") !in setOf("active", "ceded") ||
|
|
||||||
(session.optString("planning_state") == "ceded" && !session.isNull("current_for")) ||
|
|
||||||
(session.optString("planning_state") == "active" &&
|
|
||||||
session.isNull("current_for") && !session.isNull("planned_for"))))
|
|
||||||
throw ProgramsImportInvalid("Invalid Program planning state.")
|
|
||||||
val occurrences = session.getJSONArray("occurrences")
|
val occurrences = session.getJSONArray("occurrences")
|
||||||
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
|
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
|
||||||
val entryIds = mutableSetOf<String>()
|
val entryIds = mutableSetOf<String>()
|
||||||
|
|
@ -11983,12 +11826,6 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ExistingProjection(
|
|
||||||
val revisionId: String,
|
|
||||||
val digest: String,
|
|
||||||
val version: Int,
|
|
||||||
val sequence: Long,
|
|
||||||
)
|
|
||||||
val db = database.writableDatabase
|
val db = database.writableDatabase
|
||||||
db.beginTransaction()
|
db.beginTransaction()
|
||||||
try {
|
try {
|
||||||
|
|
@ -12021,76 +11858,28 @@ class TrainlogRepository(
|
||||||
.use { it.moveToFirst() }
|
.use { it.moveToFirst() }
|
||||||
if (tombstoned) { skipped++; continue }
|
if (tombstoned) { skipped++; continue }
|
||||||
val digest = causalDigest(program)
|
val digest = causalDigest(program)
|
||||||
val existing = db.rawQuery(
|
val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id))
|
||||||
"SELECT revision_id,snapshot_sha256,projection_version,revision_sequence " +
|
.use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) }
|
||||||
"FROM synced_programs WHERE program_id=?",
|
if (existing?.first == program.getString("revision_id")) {
|
||||||
arrayOf(id),
|
if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.")
|
||||||
).use { cursor ->
|
skipped++
|
||||||
if (!cursor.moveToFirst()) null else ExistingProjection(
|
continue
|
||||||
cursor.getString(0), cursor.getString(1), cursor.getInt(2), cursor.getLong(3),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val sequence = if (documentVersion == 2) program.getLong("revision_sequence") else 0L
|
|
||||||
// WHY: a later V1 artifact cannot erase current dates or a
|
|
||||||
// ceded slot after this phone has accepted Programs V2.
|
|
||||||
if (existing != null && documentVersion < existing.version)
|
|
||||||
throw ProgramsImportInvalid("Programs V2 capability required.")
|
|
||||||
if (existing != null && documentVersion == 2 &&
|
|
||||||
(sequence < existing.sequence ||
|
|
||||||
(sequence == existing.sequence && existing.version == 2 &&
|
|
||||||
existing.revisionId != program.getString("revision_id"))))
|
|
||||||
throw ProgramsImportInvalid("Stale Program revision sequence.")
|
|
||||||
if (existing?.revisionId == program.getString("revision_id")) {
|
|
||||||
if (existing.digest == digest) { skipped++; continue }
|
|
||||||
if (documentVersion != 2 || existing.version != 1 ||
|
|
||||||
existing.digest != legacyProgramDigest(program))
|
|
||||||
throw ProgramsImportInvalid("Program revision identity conflict.")
|
|
||||||
}
|
|
||||||
if (documentVersion == 2) {
|
|
||||||
val activeProgramSession = db.rawQuery(
|
|
||||||
"SELECT source_program_session_id FROM active_session_draft " +
|
|
||||||
"WHERE id=1 AND source_program_id=?",
|
|
||||||
arrayOf(id),
|
|
||||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
|
||||||
if (activeProgramSession != null) {
|
|
||||||
val newSessions = program.getJSONArray("sessions")
|
|
||||||
for (sessionIndex in 0 until newSessions.length()) {
|
|
||||||
val candidate = newSessions.getJSONObject(sessionIndex)
|
|
||||||
if (candidate.getString("program_session_id") == activeProgramSession) {
|
|
||||||
val existingPlan = db.rawQuery(
|
|
||||||
"SELECT current_for,planning_state FROM synced_program_sessions " +
|
|
||||||
"WHERE program_session_id=?",
|
|
||||||
arrayOf(activeProgramSession),
|
|
||||||
).use { cursor ->
|
|
||||||
if (!cursor.moveToFirst()) null else
|
|
||||||
(if (cursor.isNull(0)) null else cursor.getString(0)) to cursor.getString(1)
|
|
||||||
}
|
|
||||||
if (existingPlan != null &&
|
|
||||||
(existingPlan.first != candidate.optStringOrNull("current_for") ||
|
|
||||||
existingPlan.second != candidate.getString("planning_state")))
|
|
||||||
throw ProgramsImportInvalid("Active Program draft blocks reschedule.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
db.delete("synced_programs", "program_id=?", arrayOf(id))
|
db.delete("synced_programs", "program_id=?", arrayOf(id))
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"),
|
arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"),
|
||||||
program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
|
program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
|
||||||
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
|
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
|
||||||
program.getInt("source_version"), program.getString("source_payload_sha256"), digest,
|
program.getInt("source_version"), program.getString("source_payload_sha256"), digest),
|
||||||
documentVersion, sequence),
|
|
||||||
)
|
)
|
||||||
val sessions = program.getJSONArray("sessions")
|
val sessions = program.getJSONArray("sessions")
|
||||||
for (sessionIndex in 0 until sessions.length()) {
|
for (sessionIndex in 0 until sessions.length()) {
|
||||||
val session = sessions.getJSONObject(sessionIndex)
|
val session = sessions.getJSONObject(sessionIndex)
|
||||||
val sessionId = session.getString("program_session_id")
|
val sessionId = session.getString("program_session_id")
|
||||||
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?,?,?)", arrayOf<Any?>(
|
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?)", arrayOf<Any?>(
|
||||||
sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"),
|
sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"),
|
||||||
session.optStringOrNull("planned_for"), session.optStringOrNull("note"),
|
session.optStringOrNull("planned_for"), session.optStringOrNull("note")))
|
||||||
if (documentVersion == 2) session.optStringOrNull("current_for") else session.optStringOrNull("planned_for"),
|
|
||||||
if (documentVersion == 2) session.getString("planning_state") else "active"))
|
|
||||||
val entries = session.getJSONArray("occurrences")
|
val entries = session.getJSONArray("occurrences")
|
||||||
for (entryIndex in 0 until entries.length()) {
|
for (entryIndex in 0 until entries.length()) {
|
||||||
val entry = entries.getJSONObject(entryIndex)
|
val entry = entries.getJSONObject(entryIndex)
|
||||||
|
|
@ -12159,7 +11948,7 @@ class TrainlogRepository(
|
||||||
} ?: return null
|
} ?: return null
|
||||||
val sessions = mutableListOf<SyncedProgramSession>()
|
val sessions = mutableListOf<SyncedProgramSession>()
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT program_session_id,title,session_type,planned_for,note,current_for,planning_state " +
|
"SELECT program_session_id,title,session_type,planned_for,note " +
|
||||||
"FROM synced_program_sessions WHERE program_id=? " +
|
"FROM synced_program_sessions WHERE program_id=? " +
|
||||||
"ORDER BY position,program_session_id",
|
"ORDER BY position,program_session_id",
|
||||||
arrayOf(programId),
|
arrayOf(programId),
|
||||||
|
|
@ -12202,8 +11991,6 @@ class TrainlogRepository(
|
||||||
sessionCursor.getString(1),
|
sessionCursor.getString(1),
|
||||||
sessionCursor.getString(2),
|
sessionCursor.getString(2),
|
||||||
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
|
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
|
||||||
if (sessionCursor.isNull(5)) null else sessionCursor.getString(5),
|
|
||||||
sessionCursor.getString(6),
|
|
||||||
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
|
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
|
||||||
execution.first,
|
execution.first,
|
||||||
execution.second,
|
execution.second,
|
||||||
|
|
@ -12269,8 +12056,7 @@ class TrainlogRepository(
|
||||||
val available = db.rawQuery(
|
val available = db.rawQuery(
|
||||||
"SELECT s.session_type FROM synced_program_sessions s " +
|
"SELECT s.session_type FROM synced_program_sessions s " +
|
||||||
"JOIN synced_programs p ON p.program_id=s.program_id " +
|
"JOIN synced_programs p ON p.program_id=s.program_id " +
|
||||||
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active' " +
|
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active'",
|
||||||
"AND s.planning_state='active'",
|
|
||||||
arrayOf(programId, programSessionId),
|
arrayOf(programId, programSessionId),
|
||||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||||
?: return StartProgramSessionResult.NotAvailable
|
?: return StartProgramSessionResult.NotAvailable
|
||||||
|
|
@ -12564,7 +12350,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
34,
|
33,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -12918,34 +12704,6 @@ private class TrainlogDatabaseHelper(
|
||||||
createSleepImmutabilityTriggers(db)
|
createSleepImmutabilityTriggers(db)
|
||||||
version = 33
|
version = 33
|
||||||
}
|
}
|
||||||
if (version < 34 && newVersion >= 34) {
|
|
||||||
/* WHY: V1 retains the source date; V2 needs an independent current
|
|
||||||
* target and a durable ceded-slot state. INVARIANT: no local draft
|
|
||||||
* or completed workout is rewritten by the projection migration. */
|
|
||||||
if (!tableHasColumn(db, "synced_programs", "projection_version")) {
|
|
||||||
db.execSQL(
|
|
||||||
"ALTER TABLE synced_programs ADD COLUMN projection_version INTEGER NOT NULL " +
|
|
||||||
"DEFAULT 1 CHECK(projection_version IN(1,2))",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!tableHasColumn(db, "synced_programs", "revision_sequence")) {
|
|
||||||
db.execSQL(
|
|
||||||
"ALTER TABLE synced_programs ADD COLUMN revision_sequence INTEGER NOT NULL " +
|
|
||||||
"DEFAULT 0 CHECK(revision_sequence>=0)",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!tableHasColumn(db, "synced_program_sessions", "current_for")) {
|
|
||||||
db.execSQL("ALTER TABLE synced_program_sessions ADD COLUMN current_for TEXT")
|
|
||||||
db.execSQL("UPDATE synced_program_sessions SET current_for=planned_for")
|
|
||||||
}
|
|
||||||
if (!tableHasColumn(db, "synced_program_sessions", "planning_state")) {
|
|
||||||
db.execSQL(
|
|
||||||
"ALTER TABLE synced_program_sessions ADD COLUMN planning_state TEXT NOT NULL " +
|
|
||||||
"DEFAULT 'active' CHECK(planning_state IN('active','ceded'))",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
version = 34
|
|
||||||
}
|
|
||||||
|
|
||||||
if (version != newVersion) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
|
|
@ -12962,9 +12720,7 @@ private class TrainlogDatabaseHelper(
|
||||||
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
|
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
|
||||||
start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||||
source_format TEXT NOT NULL, source_version INTEGER NOT NULL,
|
source_format TEXT NOT NULL, source_version INTEGER NOT NULL,
|
||||||
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL,
|
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL
|
||||||
projection_version INTEGER NOT NULL DEFAULT 1 CHECK(projection_version IN(1,2)),
|
|
||||||
revision_sequence INTEGER NOT NULL DEFAULT 0 CHECK(revision_sequence>=0)
|
|
||||||
)""".trimIndent(),
|
)""".trimIndent(),
|
||||||
)
|
)
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
|
|
@ -12973,9 +12729,7 @@ private class TrainlogDatabaseHelper(
|
||||||
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
|
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
|
||||||
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
|
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
|
||||||
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
|
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
|
||||||
planned_for TEXT, note TEXT, current_for TEXT,
|
planned_for TEXT, note TEXT, UNIQUE(program_id,position)
|
||||||
planning_state TEXT NOT NULL DEFAULT 'active' CHECK(planning_state IN('active','ceded')),
|
|
||||||
UNIQUE(program_id,position)
|
|
||||||
)""".trimIndent(),
|
)""".trimIndent(),
|
||||||
)
|
)
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ data class SyncedProgramSession(
|
||||||
val title: String,
|
val title: String,
|
||||||
val sessionType: String,
|
val sessionType: String,
|
||||||
val plannedFor: String?,
|
val plannedFor: String?,
|
||||||
val currentFor: String?,
|
|
||||||
val planningState: String,
|
|
||||||
val note: String?,
|
val note: String?,
|
||||||
val executionState: ProgramSessionExecutionState,
|
val executionState: ProgramSessionExecutionState,
|
||||||
val executionSessionId: String?,
|
val executionSessionId: String?,
|
||||||
|
|
|
||||||
|
|
@ -118,12 +118,7 @@ fun ProgramDetailScreen(
|
||||||
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
|
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
|
||||||
program.note?.let { TrainlogInfo(it) }
|
program.note?.let { TrainlogInfo(it) }
|
||||||
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
|
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
|
||||||
val ceded = program.sessions.count { it.planningState == "ceded" }
|
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
|
||||||
TrainlogInfo(
|
|
||||||
"$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}" +
|
|
||||||
if (ceded > 0) " · $ceded créneau(x) cédé(s)" else "",
|
|
||||||
if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
message?.let { TrainlogInfo(it) }
|
message?.let { TrainlogInfo(it) }
|
||||||
program.sessions.forEachIndexed { index, session ->
|
program.sessions.forEachIndexed { index, session ->
|
||||||
|
|
@ -150,16 +145,12 @@ fun ProgramDetailScreen(
|
||||||
color = LocalTrainlogColors.current.text,
|
color = LocalTrainlogColors.current.text,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
ProgramStateBadge(
|
ProgramStateBadge(strings.getString(stateLabel), completed)
|
||||||
if (session.planningState == "ceded" && !completed) "Créneau cédé"
|
|
||||||
else strings.getString(stateLabel),
|
|
||||||
completed,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
|
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
|
||||||
session.currentFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||||
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
|
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
|
||||||
).joinToString(" · "),
|
).joinToString(" · "),
|
||||||
)
|
)
|
||||||
|
|
@ -173,7 +164,7 @@ fun ProgramDetailScreen(
|
||||||
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
|
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
|
||||||
)
|
)
|
||||||
when (session.executionState) {
|
when (session.executionState) {
|
||||||
ProgramSessionExecutionState.TODO -> if (session.planningState == "active") TrainlogButton(
|
ProgramSessionExecutionState.TODO -> TrainlogButton(
|
||||||
strings.getString(R.string.program_session_start),
|
strings.getString(R.string.program_session_start),
|
||||||
{ start(session.programSessionId) },
|
{ start(session.programSessionId) },
|
||||||
Modifier.testTag("program-session-start-${session.programSessionId}"),
|
Modifier.testTag("program-session-start-${session.programSessionId}"),
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||||
/* v13 intentionally adds the frozen machine identities and
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(before, listOf(
|
assertEquals(before, listOf(
|
||||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
||||||
reopened.close()
|
reopened.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
||||||
context.getDatabasePath(name).path,
|
context.getDatabasePath(name).path,
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { assertEquals(34, it.version) }
|
).use { assertEquals(33, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
|
||||||
context.getDatabasePath(name).path,
|
context.getDatabasePath(name).path,
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { assertEquals(34, it.version) }
|
).use { assertEquals(33, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(34, db.version)
|
assertEquals(33, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
||||||
null,
|
null,
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(34, db.version)
|
assertEquals(33, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT session_type,session_kind FROM sessions",
|
"SELECT session_type,session_kind FROM sessions",
|
||||||
null,
|
null,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(34, db.version)
|
assertEquals(33, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT COUNT(*) FROM exercises WHERE " +
|
"SELECT COUNT(*) FROM exercises WHERE " +
|
||||||
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
|
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(34, db.version)
|
assertEquals(33, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
||||||
"'cardio_guidance_runs','cardio_guidance_phases'," +
|
"'cardio_guidance_runs','cardio_guidance_phases'," +
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest {
|
||||||
migrated.listSleepDiary()
|
migrated.listSleepDiary()
|
||||||
migrated.close()
|
migrated.close()
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
1,
|
1,
|
||||||
scalar(
|
scalar(
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(34, cursor.getInt(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -70,91 +70,6 @@ class ProgramsProjectionRepositoryTest {
|
||||||
.put("deletions", deletions)
|
.put("deletions", deletions)
|
||||||
.toString()
|
.toString()
|
||||||
|
|
||||||
private fun artifactV2(
|
|
||||||
program: JSONObject,
|
|
||||||
sequence: Int = 0,
|
|
||||||
currentFor: String = "2026-10-01",
|
|
||||||
): String {
|
|
||||||
program.put("revision_sequence", sequence)
|
|
||||||
val session = program.getJSONArray("sessions").getJSONObject(0)
|
|
||||||
session.put("position", 0)
|
|
||||||
session.put("current_for", currentFor)
|
|
||||||
session.put("planning_state", "active")
|
|
||||||
return JSONObject()
|
|
||||||
.put("format", "trainlog-programs")
|
|
||||||
.put("version", 2)
|
|
||||||
.put("generated_at", "2026-09-18T12:00:00Z")
|
|
||||||
.put("programs", JSONArray().put(program))
|
|
||||||
.put("deletions", JSONArray())
|
|
||||||
.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun v2UpgradePreservesCurrentDateAndRejectsLegacyDowngrade() {
|
|
||||||
val name = "program-v2-${UUID.randomUUID()}.db"
|
|
||||||
var repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
assertEquals(
|
|
||||||
ProgramsImportResult.Applied(1, 0, 0),
|
|
||||||
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
|
|
||||||
)
|
|
||||||
val upgraded = program()
|
|
||||||
assertEquals(
|
|
||||||
ProgramsImportResult.Applied(1, 0, 0),
|
|
||||||
repository.applyProgramsV2Json(artifactV2(upgraded)),
|
|
||||||
)
|
|
||||||
assertEquals("2026-10-01", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
|
||||||
.sessions.single().currentFor)
|
|
||||||
val stale = artifactV2(program())
|
|
||||||
val newer = program().put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
|
|
||||||
assertEquals(
|
|
||||||
ProgramsImportResult.Applied(1, 0, 0),
|
|
||||||
repository.applyProgramsV2Json(artifactV2(newer, 1, "2026-10-02")),
|
|
||||||
)
|
|
||||||
assertTrue(repository.applyProgramsV2Json(stale) is ProgramsImportResult.Invalid)
|
|
||||||
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
|
||||||
.sessions.single().currentFor)
|
|
||||||
assertTrue(repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
|
|
||||||
is ProgramsImportResult.Invalid)
|
|
||||||
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
|
||||||
.sessions.single().currentFor)
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
}
|
|
||||||
repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
assertEquals("2026-10-02", repository.getSyncedProgram(program().getString("program_id"))!!
|
|
||||||
.sessions.single().currentFor)
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun v2UpgradeKeepsV1DigestForIntegralDecimalWeights() {
|
|
||||||
val name = "program-decimal-upgrade-${UUID.randomUUID()}.db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
// The desktop JSON exporter writes SQLite REAL values as 40.0.
|
|
||||||
// JSONObject's own serialization writes the same value as 40.
|
|
||||||
val v1 = artifact(JSONArray().put(program()))
|
|
||||||
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
|
|
||||||
val v2 = artifactV2(program())
|
|
||||||
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
|
|
||||||
assertTrue(v1.contains("\"target_weight_kg\":40.0"))
|
|
||||||
assertTrue(v2.contains("\"target_weight_kg\":40.0"))
|
|
||||||
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV1Json(v1))
|
|
||||||
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV2Json(v2))
|
|
||||||
assertEquals("2026-10-01", repository.getSyncedProgram(program().getString("program_id"))!!
|
|
||||||
.sessions.single().currentFor)
|
|
||||||
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV2Json(v2))
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun preparationArtifact(
|
private fun preparationArtifact(
|
||||||
includeProgramProvenance: Boolean = true,
|
includeProgramProvenance: Boolean = true,
|
||||||
deliveryState: String = "pending",
|
deliveryState: String = "pending",
|
||||||
|
|
@ -296,12 +211,6 @@ class ProgramsProjectionRepositoryTest {
|
||||||
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
assertEquals(programId, active.sourceProgramId)
|
assertEquals(programId, active.sourceProgramId)
|
||||||
assertEquals(programSessionId, active.sourceProgramSessionId)
|
assertEquals(programSessionId, active.sourceProgramSessionId)
|
||||||
val changedPlan = program().apply {
|
|
||||||
put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
|
|
||||||
}
|
|
||||||
assertTrue(repository.applyProgramsV2Json(artifactV2(changedPlan))
|
|
||||||
is ProgramsImportResult.Invalid)
|
|
||||||
assertEquals(null, repository.getSyncedProgram(programId)!!.sessions.single().currentFor)
|
|
||||||
val performed = active.copy(
|
val performed = active.copy(
|
||||||
exercises = active.exercises.map { occurrence ->
|
exercises = active.exercises.map { occurrence ->
|
||||||
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
|
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
|
||||||
|
|
@ -309,12 +218,6 @@ class ProgramsProjectionRepositoryTest {
|
||||||
)
|
)
|
||||||
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
||||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||||
assertEquals(
|
|
||||||
ProgramsImportResult.Applied(1, 0, 0),
|
|
||||||
repository.applyProgramsV2Json(artifactV2(changedPlan)),
|
|
||||||
)
|
|
||||||
assertEquals("2026-10-01", repository.getSyncedProgram(programId)!!
|
|
||||||
.sessions.single().currentFor)
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
ProgramSessionExecutionState.COMPLETED,
|
ProgramSessionExecutionState.COMPLETED,
|
||||||
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
|
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
|
||||||
|
|
@ -335,8 +238,6 @@ class ProgramsProjectionRepositoryTest {
|
||||||
try {
|
try {
|
||||||
val detail = repository.listSyncedPrograms().single()
|
val detail = repository.listSyncedPrograms().single()
|
||||||
assertEquals(1, detail.sessionCount)
|
assertEquals(1, detail.sessionCount)
|
||||||
assertEquals("2026-10-01", repository.getSyncedProgram(detail.programId)!!
|
|
||||||
.sessions.single().currentFor)
|
|
||||||
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
||||||
assertEquals(1, repository.listSessions().size)
|
assertEquals(1, repository.listSessions().size)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
||||||
repository = TrainlogRepository(context, name)
|
repository = TrainlogRepository(context, name)
|
||||||
assertEquals(1, repository.listPreparedSessions().size)
|
assertEquals(1, repository.listPreparedSessions().size)
|
||||||
repository.close()
|
repository.close()
|
||||||
assertEquals("34", scalarText(name, "PRAGMA user_version"))
|
assertEquals("33", scalarText(name, "PRAGMA user_version"))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"0",
|
"0",
|
||||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import com.labfytools.trainlog.model.MedicationIntake
|
||||||
import com.labfytools.trainlog.model.SleepMedication
|
import com.labfytools.trainlog.model.SleepMedication
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNotEquals
|
|
||||||
import org.junit.Assert.assertThrows
|
import org.junit.Assert.assertThrows
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -26,147 +25,6 @@ import org.robolectric.annotation.Config
|
||||||
class SleepDiaryRepositoryTest {
|
class SleepDiaryRepositoryTest {
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
@Test
|
|
||||||
fun concurrentV2SleepEditsChooseLatestUserEditAndConverge() {
|
|
||||||
for (localNewer in listOf(true, false)) {
|
|
||||||
val sourceName = "sleep-lww-source-${UUID.randomUUID()}.db"
|
|
||||||
val destinationName = "sleep-lww-destination-${UUID.randomUUID()}.db"
|
|
||||||
val source = TrainlogRepository(context, sourceName)
|
|
||||||
val destination = TrainlogRepository(context, destinationName)
|
|
||||||
try {
|
|
||||||
val initial = source.saveSleepDiary(
|
|
||||||
SleepDiaryDraft(
|
|
||||||
nightStartDate = "2026-10-24",
|
|
||||||
nightEndDate = "2026-10-25",
|
|
||||||
createdAt = "2026-10-24T22:00:00+02:00",
|
|
||||||
updatedAt = "2026-10-24T22:00:00+02:00",
|
|
||||||
sleepQuality = null,
|
|
||||||
wakeQuality = null,
|
|
||||||
dayForm = null,
|
|
||||||
treatmentAndNotes = "",
|
|
||||||
events = listOf(
|
|
||||||
SleepDiaryEvent(
|
|
||||||
"", SleepEventType.SLEEP,
|
|
||||||
"2026-10-24T23:00:00+02:00",
|
|
||||||
"2026-10-25T07:00:00+01:00",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
|
||||||
assertTrue(source.validateSleepDiary(
|
|
||||||
initial.entryId,
|
|
||||||
initial.revisionId,
|
|
||||||
"2026-10-25T08:00:00+01:00",
|
|
||||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved)
|
|
||||||
assertEquals(
|
|
||||||
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
|
|
||||||
destination.applySleepDiaryV1Json(source.buildSleepDiaryV1Json()),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun edit(repository: TrainlogRepository, at: String, wakeAt: String): String {
|
|
||||||
val current = repository.listSleepDiary().single()
|
|
||||||
val saved = repository.saveSleepDiary(
|
|
||||||
SleepDiaryDraft(
|
|
||||||
entryId = current.entryId,
|
|
||||||
expectedRevision = current.revisionId,
|
|
||||||
nightStartDate = current.nightStartDate,
|
|
||||||
nightEndDate = current.nightEndDate,
|
|
||||||
createdAt = current.createdAt,
|
|
||||||
updatedAt = at,
|
|
||||||
sleepQuality = null,
|
|
||||||
wakeQuality = null,
|
|
||||||
dayForm = null,
|
|
||||||
treatmentAndNotes = "",
|
|
||||||
events = current.events.map { it.copy(endAt = wakeAt) },
|
|
||||||
),
|
|
||||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
|
||||||
assertTrue(repository.validateSleepDiary(
|
|
||||||
current.entryId, saved.revisionId, at,
|
|
||||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved)
|
|
||||||
return saved.revisionId
|
|
||||||
}
|
|
||||||
|
|
||||||
val localAt = if (localNewer) "2026-10-25T10:00:00+01:00"
|
|
||||||
else "2026-10-25T09:00:00+01:00"
|
|
||||||
val remoteAt = if (localNewer) "2026-10-25T09:00:00+01:00"
|
|
||||||
else "2026-10-25T10:00:00+01:00"
|
|
||||||
val localRevision = edit(destination, localAt, "2026-10-25T06:00:00+01:00")
|
|
||||||
val remoteRevision = edit(source, remoteAt, "2026-10-25T07:30:00+01:00")
|
|
||||||
val remoteSnapshot = source.buildSleepDiaryV1Json()
|
|
||||||
val tiedSnapshot = org.json.JSONObject(remoteSnapshot)
|
|
||||||
val tiedEntry = tiedSnapshot.getJSONArray("entries").getJSONObject(0)
|
|
||||||
val tiedRevision = "slr_${UUID.randomUUID()}"
|
|
||||||
tiedEntry.put("revision_id", tiedRevision)
|
|
||||||
tiedEntry.put("updated_at", localAt)
|
|
||||||
tiedEntry.put(
|
|
||||||
"ancestry",
|
|
||||||
org.json.JSONArray().put(tiedRevision).put(initial.revisionId),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
TrainlogRepository.SleepDiaryImportResult.Rejected(
|
|
||||||
"concurrent sleep diary revision has indeterminate edit order",
|
|
||||||
),
|
|
||||||
destination.applySleepDiaryV1Json(tiedSnapshot.toString()),
|
|
||||||
)
|
|
||||||
assertEquals(localRevision, destination.listSleepDiary().single().revisionId)
|
|
||||||
assertEquals(
|
|
||||||
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
|
|
||||||
destination.applySleepDiaryV1Json(remoteSnapshot),
|
|
||||||
)
|
|
||||||
val winner = destination.listSleepDiary().single()
|
|
||||||
assertEquals(
|
|
||||||
if (localNewer) "2026-10-25T06:00:00+01:00"
|
|
||||||
else "2026-10-25T07:30:00+01:00",
|
|
||||||
winner.events.single().endAt,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
TrainlogRepository.SleepDiaryImportResult.Applied(0, 1),
|
|
||||||
destination.applySleepDiaryV1Json(remoteSnapshot),
|
|
||||||
)
|
|
||||||
val reconciled = destination.buildSleepDiaryV1Json()
|
|
||||||
if (localNewer) {
|
|
||||||
assertNotEquals(localRevision, winner.revisionId)
|
|
||||||
assertEquals(
|
|
||||||
remoteRevision,
|
|
||||||
org.json.JSONObject(reconciled).getJSONArray("entries")
|
|
||||||
.getJSONObject(0).getString("parent_revision_id"),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
assertEquals(remoteRevision, winner.revisionId)
|
|
||||||
}
|
|
||||||
assertEquals(
|
|
||||||
TrainlogRepository.SleepDiaryImportResult.Applied(
|
|
||||||
if (localNewer) 1 else 0, if (localNewer) 0 else 1,
|
|
||||||
),
|
|
||||||
source.applySleepDiaryV1Json(reconciled),
|
|
||||||
)
|
|
||||||
assertEquals(winner.revisionId, source.listSleepDiary().single().revisionId)
|
|
||||||
val retained = SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(destinationName).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
assertEquals(2, retained.rawQuery(
|
|
||||||
"SELECT COUNT(*) FROM sleep_diary_revisions " +
|
|
||||||
"WHERE revision_id IN (?,?)",
|
|
||||||
arrayOf(localRevision, remoteRevision),
|
|
||||||
).use { cursor ->
|
|
||||||
assertTrue(cursor.moveToFirst())
|
|
||||||
cursor.getInt(0)
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
retained.close()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
source.close()
|
|
||||||
destination.close()
|
|
||||||
context.deleteDatabase(sourceName)
|
|
||||||
context.deleteDatabase(destinationName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun createEditConflictDeleteAndReopen() {
|
@Test fun createEditConflictDeleteAndReopen() {
|
||||||
val name = "sleep-${UUID.randomUUID()}.db"
|
val name = "sleep-${UUID.randomUUID()}.db"
|
||||||
var repository = TrainlogRepository(context, name)
|
var repository = TrainlogRepository(context, name)
|
||||||
|
|
@ -245,7 +103,7 @@ class SleepDiaryRepositoryTest {
|
||||||
SQLiteDatabase.OPEN_READWRITE,
|
SQLiteDatabase.OPEN_READWRITE,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
assertEquals(34, raw.version)
|
assertEquals(33, raw.version)
|
||||||
assertThrows(SQLiteException::class.java) {
|
assertThrows(SQLiteException::class.java) {
|
||||||
raw.execSQL(
|
raw.execSQL(
|
||||||
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
|
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
|
|
@ -55,109 +54,6 @@ class SyncConversationOwnershipTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canonical(value: Any?): String =
|
|
||||||
when (value) {
|
|
||||||
null,
|
|
||||||
JSONObject.NULL -> "null"
|
|
||||||
is JSONObject ->
|
|
||||||
value.keys().asSequence().toList().sorted().joinToString(",", "{", "}") {
|
|
||||||
JSONObject.quote(it) + ":" + canonical(value.get(it))
|
|
||||||
}
|
|
||||||
is JSONArray ->
|
|
||||||
(0 until value.length()).joinToString(",", "[", "]") { canonical(value.get(it)) }
|
|
||||||
is String -> JSONObject.quote(value)
|
|
||||||
is Boolean,
|
|
||||||
is Int,
|
|
||||||
is Long -> value.toString()
|
|
||||||
else -> error("unsupported test ACK value")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rejectedDesktopAcknowledgement(
|
|
||||||
runId: String,
|
|
||||||
generationId: String,
|
|
||||||
producerPeerId: String,
|
|
||||||
consumerPeerId: String,
|
|
||||||
manifestSha256: String,
|
|
||||||
): String {
|
|
||||||
/* This fixture signs the exact wire ACK because the production
|
|
||||||
* validator must persist it before the coordinator may release its
|
|
||||||
* lease; an unsigned rejection would only test malformed-input logic. */
|
|
||||||
val acknowledgement =
|
|
||||||
JSONObject()
|
|
||||||
.put("format", "trainlog-sync-ack")
|
|
||||||
.put("version", 1)
|
|
||||||
.put("ack_id", "ack_${generationId.removePrefix("gen_")}")
|
|
||||||
.put("run_id", runId)
|
|
||||||
.put("generation_id", generationId)
|
|
||||||
.put("producer_peer_id", producerPeerId)
|
|
||||||
.put("consumer_peer_id", consumerPeerId)
|
|
||||||
.put("manifest_sha256", manifestSha256)
|
|
||||||
.put("result", "rejected")
|
|
||||||
.put("durability", "sqlite-commit-rejection")
|
|
||||||
.put("consumed_at", "2026-09-28T12:00:00+02:00")
|
|
||||||
.put("diagnostic", "concurrent sleep diary revision")
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
.digest(canonical(acknowledgement).toByteArray())
|
|
||||||
.joinToString("") { "%02x".format(it) }
|
|
||||||
return acknowledgement.put("payload_sha256", digest).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectedDesktopAcknowledgementEndsConversationAndReleasesBackgroundLease() {
|
|
||||||
val databaseName = "generation-rejection-${UUID.randomUUID()}.db"
|
|
||||||
val root = Files.createTempDirectory("trainlog-generation-rejection-").toFile()
|
|
||||||
val repository = TrainlogRepository(context, databaseName)
|
|
||||||
val arbiter = SyncConversationArbiter()
|
|
||||||
val referencePublished = CountDownLatch(1)
|
|
||||||
val desktopPeer = "peer_${UUID.randomUUID()}"
|
|
||||||
val runId = "sy_${UUID.randomUUID()}"
|
|
||||||
try {
|
|
||||||
val service = SyncGenerationService(repository)
|
|
||||||
request(root, runId, service.peerId(), desktopPeer)
|
|
||||||
archiveAcknowledgements(root, runId, service.peerId(), desktopPeer)
|
|
||||||
val coordinator =
|
|
||||||
SyncGenerationCoordinator(
|
|
||||||
repository,
|
|
||||||
arbiter = arbiter,
|
|
||||||
trace = SyncGenerationTrace { _, phase, _, _, _ ->
|
|
||||||
if (phase == "generation_reference_published") referencePublished.countDown()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
|
||||||
try {
|
|
||||||
val active = executor.submit<ForegroundGenerationResult> {
|
|
||||||
coordinator.run(root, Duration.ofSeconds(5))
|
|
||||||
}
|
|
||||||
assertTrue(referencePublished.await(3, TimeUnit.SECONDS))
|
|
||||||
val reference = JSONObject(File(root, "android-generation-v1.json").readText())
|
|
||||||
File(root, "desktop-consumption-ack-v1.json").writeText(
|
|
||||||
rejectedDesktopAcknowledgement(
|
|
||||||
runId,
|
|
||||||
reference.getString("generation_id"),
|
|
||||||
service.peerId(),
|
|
||||||
desktopPeer,
|
|
||||||
reference.getString("manifest_sha256"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val result = active.get(2, TimeUnit.SECONDS)
|
|
||||||
assertEquals(
|
|
||||||
ForegroundGenerationResult.Failed("concurrent sleep diary revision", runId),
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"The rejected run must release the lease for its next request.",
|
|
||||||
coordinator.run(root, Duration.ofMillis(100)) !is ForegroundGenerationResult.Busy,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
executor.shutdownNow()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(databaseName)
|
|
||||||
root.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() {
|
fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() {
|
||||||
val databaseName = "generation-ownership-${UUID.randomUUID()}.db"
|
val databaseName = "generation-ownership-${UUID.randomUUID()}.db"
|
||||||
|
|
|
||||||
|
|
@ -878,7 +878,7 @@ class SyncGenerationServiceTest {
|
||||||
.use { db ->
|
.use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use {
|
db.rawQuery("PRAGMA user_version", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
assertEquals(34, it.getInt(0))
|
assertEquals(33, it.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -2382,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(34, cursor.getInt(0))
|
assertEquals(33, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -2509,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(34, cursor.getInt(0))
|
assertEquals(33, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ evidence and chronology.
|
||||||
existing-artifact synchronization boundary.
|
existing-artifact synchronization boundary.
|
||||||
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
|
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
|
||||||
history and Android-delivery workflow.
|
history and Android-delivery workflow.
|
||||||
- [Sleep Diary guide](sleep_diary.md) explains capture, agenda row paging,
|
|
||||||
factual sport and heart-rate displays, and independent PDF range selection.
|
|
||||||
- [Program format V1](program_format_v1.md) owns the strict desktop planning
|
- [Program format V1](program_format_v1.md) owns the strict desktop planning
|
||||||
import contract and program lifecycle used by the Sessions Programs subtab.
|
import contract and program lifecycle used by the Sessions Programs subtab.
|
||||||
|
|
||||||
|
|
@ -82,9 +80,6 @@ evidence and chronology.
|
||||||
|
|
||||||
- [Review records](reviews/) retain historical audits, incidents, checkpoints,
|
- [Review records](reviews/) retain historical audits, incidents, checkpoints,
|
||||||
and validation evidence. Their status and paths are historical.
|
and validation evidence. Their status and paths are historical.
|
||||||
- [Documentation deep review V1](reviews/documentation_deep_review_v1.md)
|
|
||||||
records the audited 0.1.7 source baseline, corrected claims, evidence limits,
|
|
||||||
and coverage matrix.
|
|
||||||
- [Programs presentation / Android delete evidence](reviews/programs_presentation_android_delete_v1_evidence.md)
|
- [Programs presentation / Android delete evidence](reviews/programs_presentation_android_delete_v1_evidence.md)
|
||||||
records the implemented, not-deployed validation checkpoint and real-browser
|
records the implemented, not-deployed validation checkpoint and real-browser
|
||||||
screenshot evidence.
|
screenshot evidence.
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,5 @@
|
||||||
# Android application
|
# Android application
|
||||||
|
|
||||||
## Schema v34: read-only Programs V2 projection
|
|
||||||
|
|
||||||
The audited Program rescheduling development line additively stores a projection
|
|
||||||
version and revision sequence on synchronized Programs, plus `current_for` and
|
|
||||||
`planning_state` on synchronized Program sessions. The original `planned_for`
|
|
||||||
and all local completed sessions and drafts remain separate. Existing V1 rows
|
|
||||||
hydrate as active with their original date. Android still has no Program editor.
|
|
||||||
|
|
||||||
Programs V2 replaces one Program snapshot transactionally. A lower revision
|
|
||||||
sequence or a later Programs V1 snapshot cannot overwrite accepted V2 planning.
|
|
||||||
If the singleton active draft refers to a session whose current date or ceded
|
|
||||||
state changes, V2 import rejects the generation; it does not alter the draft.
|
|
||||||
The Program screen uses current dates for unfinished sessions and does not offer
|
|
||||||
Start on a ceded slot. The private Program rollout records a compatible v34
|
|
||||||
Android installation; this source snapshot alone does not prove another device.
|
|
||||||
|
|
||||||
## Sleep Diary V1 and active V2 companion
|
## Sleep Diary V1 and active V2 companion
|
||||||
|
|
||||||
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,12 @@
|
||||||
## Synchronization transport boundary
|
## Synchronization transport boundary
|
||||||
|
|
||||||
Generation capture, validation, causal import, publication and ACK acceptance
|
Generation capture, validation, causal import, publication and ACK acceptance
|
||||||
are transport-neutral. `sync_peer_worker.py` owns that conversation. The paired
|
are transport-neutral. `sync_peer_worker.py` owns that conversation; direct MTP
|
||||||
private automatic path selects authenticated Bluetooth first and direct MTP as
|
and `sync_drive_transport.py` only move its bounded bytes. Automatic selection
|
||||||
wired recovery. The separately configured legacy USB/Drive workflow probes the
|
probes the expected MTP peer before preferring USB. A successful USB run is
|
||||||
expected MTP peer before selecting USB. A successful USB run is committed
|
committed independently, then mirrored to Drive best-effort; Drive failure can
|
||||||
independently, then mirrored to Drive best-effort; Drive failure cannot reopen
|
never reopen its SQLite transactions. If MTP is unavailable, the same worker
|
||||||
its SQLite transactions. If MTP is unavailable in that workflow, the same
|
runs over the Drive byte adapter.
|
||||||
worker runs over the Drive byte adapter. No transport carries a SQLite file.
|
|
||||||
|
|
||||||
Android mirrors that ownership: one `SyncGenerationCoordinator` is called by
|
Android mirrors that ownership: one `SyncGenerationCoordinator` is called by
|
||||||
SyncScreen, the visible USB foreground service, and the private-folder Drive
|
SyncScreen, the visible USB foreground service, and the private-folder Drive
|
||||||
|
|
@ -43,7 +42,7 @@ Android field client
|
||||||
Android shared storage
|
Android shared storage
|
||||||
Documents/Trainlog
|
Documents/Trainlog
|
||||||
|
|
|
|
||||||
| Bluetooth RFCOMM primary; direct MTP recovery
|
| direct MTP
|
||||||
v
|
v
|
||||||
shared desktop sync engine
|
shared desktop sync engine
|
||||||
/ \
|
/ \
|
||||||
|
|
@ -359,16 +358,11 @@ SVG was selected over Canvas because the bounded series has very few elements
|
||||||
and vector sharpness/responsive scaling matter more than high-volume drawing.
|
and vector sharpness/responsive scaling matter more than high-volume drawing.
|
||||||
All colours are read from the live Catppuccin CSS variables.
|
All colours are read from the live Catppuccin CSS variables.
|
||||||
|
|
||||||
The muscle figure is a code-native SVG whose geometry is ported from the
|
The muscle figure is an original code-native SVG. Canonical localized mappings
|
||||||
normalized Compose Canvas in Android `HomeScreen.kt`: `drawOval()` for the
|
are: `chest` to bilateral front chest; `back` to rear upper back and bilateral
|
||||||
head, `neckPath()` for the neck, and `bodyRegions()` for 11 paths on each
|
lats; `shoulders` to bilateral front/rear shoulders; `arms` to bilateral
|
||||||
face. The SVG viewBox uses Android fractions multiplied by 100 and scales to
|
front/rear arms; `core` to the front trunk; `glutes` to bilateral rear glutes;
|
||||||
a portrait ratio based on Android's 236dp tall two-panel Home layout.
|
`thighs` and `calves` to bilateral front/rear legs. Aggregate zones
|
||||||
Canonical localized mappings are: `chest` to
|
|
||||||
bilateral front chest; `back` to the contiguous rear trunk; `shoulders` to
|
|
||||||
bilateral front/rear shoulders; `arms` to bilateral front/rear arms; `core` to
|
|
||||||
the front trunk; `glutes` to bilateral rear glutes; `thighs` and `calves` to
|
|
||||||
bilateral front/rear legs. Aggregate zones
|
|
||||||
`full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and
|
`full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and
|
||||||
remain in the complete text list. Visual levels are a deterministic ratio to
|
remain in the complete text list. Visual levels are a deterministic ratio to
|
||||||
the maximum `session_count` in the current snapshot: zero, up to one third, up
|
the maximum `session_count` in the current snapshot: zero, up to one third, up
|
||||||
|
|
@ -525,14 +519,12 @@ silently rendered as unclassified.
|
||||||
|
|
||||||
### Desktop
|
### Desktop
|
||||||
|
|
||||||
Desktop SQLite schema v37 is canonical long-term history in the audited
|
Desktop SQLite schema v26 is canonical long-term history. Its additive v25 ->
|
||||||
development source. The additive v37 migration stores absolute Program current
|
v26 migration adds terminal Program logical deletion and its durable replay,
|
||||||
dates, ceded slots and revision ordering. The earlier v25 -> v26 migration
|
generation, and acknowledgement ledger without deleting imported definitions,
|
||||||
added terminal Program logical deletion and its durable replay, generation and
|
derived preparations, or workout facts. Android schema v24 maintains the
|
||||||
acknowledgement ledger without deleting imported definitions, derived
|
separate read-only synchronized Programs projection; the optional staged
|
||||||
preparations or workout facts. Android schema v34 extends the read-only
|
`trainlog-programs` V1 companion does not replace mobile V3 or change
|
||||||
Programs projection with capability-selected V2 planning state; v24 introduced
|
|
||||||
its original V1 projection. Neither Programs companion replaces mobile V3 or changes
|
|
||||||
`TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable
|
`TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable
|
||||||
manual-preparation withdrawal identity without deleting revisions, deliveries
|
manual-preparation withdrawal identity without deleting revisions, deliveries
|
||||||
or workout facts. Its additive v18 ->
|
or workout facts. Its additive v18 ->
|
||||||
|
|
@ -592,7 +584,7 @@ exercise_body_zone_sync
|
||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
Android has an independent local SQLite schema, v34 in the audited source. Completed and
|
Android has an independent local SQLite schema, currently v14. Completed and
|
||||||
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
|
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
|
||||||
resuming a completed Test max records its stable source session in the one
|
resuming a completed Test max records its stable source session in the one
|
||||||
durable draft.
|
durable draft.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="52" viewBox="0 0 960 52" role="img" aria-labelledby="title desc">
|
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="52" viewBox="0 0 960 52" role="img" aria-labelledby="title desc">
|
||||||
<title id="title">Trainlog technology badges</title>
|
<title id="title">Trainlog technology badges</title>
|
||||||
<desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.7.</desc>
|
<desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.6.</desc>
|
||||||
<style>
|
<style>
|
||||||
.label { font: 600 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #cdd6f4; dominant-baseline: middle; text-anchor: middle; }
|
.label { font: 600 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #cdd6f4; dominant-baseline: middle; text-anchor: middle; }
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -32,6 +32,6 @@
|
||||||
<g>
|
<g>
|
||||||
<rect x="816" y="4" width="144" height="44" rx="12" fill="#1e1e2e" stroke="#fab387" stroke-width="2"/>
|
<rect x="816" y="4" width="144" height="44" rx="12" fill="#1e1e2e" stroke="#fab387" stroke-width="2"/>
|
||||||
<circle cx="840" cy="26" r="6" fill="#fab387"/>
|
<circle cx="840" cy="26" r="6" fill="#fab387"/>
|
||||||
<text class="label" x="896" y="27">0.1.7 DEV</text>
|
<text class="label" x="896" y="27">v0.1.6</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
|
@ -1,42 +1,5 @@
|
||||||
# Current implementation state
|
# Current implementation state
|
||||||
|
|
||||||
## Audited development line and private installation
|
|
||||||
|
|
||||||
This snapshot audits `trainlog-sleep-single-selector-weeks-v1` at `f4ddcfd`
|
|
||||||
on 2026-10-02. It includes the Program projection fix `8995843`, the body
|
|
||||||
figure Web change `c2935b8`, and the Sleep PDF, sport and paging changes by
|
|
||||||
ancestry. A later documentation-only commit does not imply a new runtime
|
|
||||||
package. The uncommitted Dashboard layout work in the separate
|
|
||||||
`trainlog-dashboard-polish-v1` worktree is outside this snapshot.
|
|
||||||
|
|
||||||
The private daily installation runs the ordered Program rescheduling rollout
|
|
||||||
from `trainlog-program-recalage-v1` at `3a4ec34`, with desktop schema v37,
|
|
||||||
Android schema v34, and the capability-selected `trainlog-programs` V2
|
|
||||||
projection. The observed PC/Web launcher points to the
|
|
||||||
`sleep-single-selector-weeks-v1-f4ddcfd` package; synchronization agents
|
|
||||||
retain the compatible Program
|
|
||||||
rollout bundle. The previous PC/Web bundle from
|
|
||||||
`trainlog-sleep-pdf-treatments-space-v1` at `3758875` remains available for
|
|
||||||
rollback. The served Web uses Android Compose Canvas head, neck, and 22 body
|
|
||||||
paths on Dashboard muscle distribution, Analysis overview, and Analysis body
|
|
||||||
distribution. Firefox captures of all three served views show complete figures
|
|
||||||
at the intended aspect ratio, with the existing color scale, legend, and focus
|
|
||||||
interaction. The candidate geometry was compared with reconstructed Android
|
|
||||||
source paths; no Android runtime screenshot was available because no device or
|
|
||||||
emulator was connected. No Android, schema, domain, or synchronization change
|
|
||||||
accompanied this Web rollout.
|
|
||||||
|
|
||||||
Firefox downloaded a four-page PDF from the served Web. The earlier PDF layout
|
|
||||||
validation rendered and inspected every page: its 65 individual medication
|
|
||||||
lines match its summary, its remarks remain visible, and no extracted word
|
|
||||||
exceeds the page margins. After the body figure rollout, a fresh PDF export
|
|
||||||
again contained the treatments and remarks sections; actual sport markers
|
|
||||||
remained blue in Web, and a recorded start time matched the PDF. The earlier
|
|
||||||
PDF inspection also confirmed black-outlined blue sport bands. A separate
|
|
||||||
synthetic set of 7, 14, 21, and 30 nights in French and English, plus long
|
|
||||||
continuations, was rendered page by page and checked in grayscale. Rollout
|
|
||||||
packaging requires a Meson `-Dweb=enabled` build with embedded frontend tests.
|
|
||||||
|
|
||||||
Full-generation synchronization uses one generation/ACK business engine across
|
Full-generation synchronization uses one generation/ACK business engine across
|
||||||
versioned transports. On the paired 0.1.4 deployment, automatic local selection
|
versioned transports. On the paired 0.1.4 deployment, automatic local selection
|
||||||
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
|
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
|
||||||
|
|
@ -57,25 +20,12 @@ retained. The desktop service installer retires the exact obsolete private
|
||||||
ExecStart override that omitted required runtime paths and installs both
|
ExecStart override that omitted required runtime paths and installs both
|
||||||
`trainlog-syncd` and the BlueZ RFCOMM agent.
|
`trainlog-syncd` and the BlueZ RFCOMM agent.
|
||||||
|
|
||||||
The daemon's durable arrival ledger distinguishes admission from terminal
|
|
||||||
consumption. If RFCOMM disappears after an Android request is admitted but
|
|
||||||
before the generation conversation can complete, an explicitly transient
|
|
||||||
terminal result leaves that same stable request eligible on reconnect. Success
|
|
||||||
and non-transient business failures remain consumed, while every retry receives
|
|
||||||
a fresh run identity and revalidates the existing idempotent generation/ACK
|
|
||||||
evidence.
|
|
||||||
|
|
||||||
Android synchronization paths share the same concurrency-guarded generation
|
Android synchronization paths share the same concurrency-guarded generation
|
||||||
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
||||||
arbiter: explicit foreground intent has priority over automatic background
|
arbiter: explicit foreground intent has priority over automatic background
|
||||||
resumption, while a foreground service may remain alive without owning the
|
resumption, while a foreground service may remain alive without owning the
|
||||||
conversation. An automatic run yields cooperatively at safe boundaries; its
|
conversation. An automatic run yields cooperatively at safe boundaries; its
|
||||||
durable generation is retained for a correlated handoff rather than deleted.
|
durable generation is retained for a correlated handoff rather than deleted.
|
||||||
A correlated desktop `rejected` ACK is also terminal for the Android producer:
|
|
||||||
it is retained as durable rejection evidence, then releases the conversation
|
|
||||||
lease immediately because no reciprocal desktop generation can follow that
|
|
||||||
rejection. This keeps an explicit Web request or a later automatic Bluetooth
|
|
||||||
request from being starved by an impossible post-rejection wait.
|
|
||||||
|
|
||||||
Direct-MTP publication now has an explicit Android visibility boundary.
|
Direct-MTP publication now has an explicit Android visibility boundary.
|
||||||
Durable immutable generation files are submitted to MediaProvider and all scan
|
Durable immutable generation files are submitted to MediaProvider and all scan
|
||||||
|
|
@ -83,7 +33,7 @@ callbacks complete before the mutable generation reference is published; the
|
||||||
reference and Android ACK use the same confirmation. Bounded diagnostic logs
|
reference and Android ACK use the same confirmation. Bounded diagnostic logs
|
||||||
contain only run/generation identities, artifact names, phases and results.
|
contain only run/generation identities, artifact names, phases and results.
|
||||||
The desktop worker performs lightweight local correlation checks continuously
|
The desktop worker performs lightweight local correlation checks continuously
|
||||||
but opens a full MTP pull no more than once every three seconds. A 90-second
|
but opens a full MTP pull no more than once every three seconds. A 30-second
|
||||||
individual adapter budget is distinct from the overall conversation deadline,
|
individual adapter budget is distinct from the overall conversation deadline,
|
||||||
so an absent correlated generation reports a protocol wait timeout while an
|
so an absent correlated generation reports a protocol wait timeout while an
|
||||||
actually blocked adapter reports `transport_timeout`.
|
actually blocked adapter reports `transport_timeout`.
|
||||||
|
|
@ -178,11 +128,11 @@ parallel implementation of its rules.
|
||||||
| Boundary | Current state |
|
| Boundary | Current state |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||||
| Desktop SQLite | source schema v37; v37 adds Program current dates, ceded slots and revision ordering; v36 makes Sleep revision payload append-only; v35 adds intake quantity |
|
| Desktop SQLite | schema v36; v36 makes Sleep revision payload tables append-only; v35 gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
|
||||||
| Android SQLite | source schema v34; v34 adds the read-only Programs V2 projection; v33 makes Sleep revision payload append-only; v32 adds intake quantity and quick publication |
|
| Android SQLite | schema v33; v33 makes Sleep revision payload tables append-only; v32 gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
|
||||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||||
| Desktop terminal backend | Notcurses only |
|
| Desktop terminal backend | Notcurses only |
|
||||||
| Trainlog product version | `0.1.7` bugfix-only development, synchronized across Android and desktop; latest stable release: `v0.1.6` |
|
| Trainlog product version | `0.1.6` stable, synchronized across Android and desktop; latest stable release: `v0.1.6` |
|
||||||
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
|
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
|
||||||
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
|
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
|
||||||
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
|
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
|
||||||
|
|
@ -200,7 +150,7 @@ parallel implementation of its rules.
|
||||||
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||||
| Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. Distinct concurrent V2 edits with comparable user-edit instants retain the complete latest snapshot and both immutable histories; ambiguous ordering and unsafe deletion/envelope changes still reject. |
|
| Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. |
|
||||||
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
||||||
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
|
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
|
||||||
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
||||||
|
|
@ -227,7 +177,7 @@ parallel implementation of its rules.
|
||||||
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
|
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
|
||||||
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
||||||
| 0.1.6 validation | `TRAINLOG_0_1_6_VALIDATION_V1=PASS`: native Meson 114/114 and Clang ASan/UBSan 114/114; Android 317 tests (312 passed, five documented optional skips), debug APK and signed release APK; Web 32 files / 209 tests plus typecheck/build; JSON/import, changed-C formatting and diff checks pass. |
|
| 0.1.6 validation | `TRAINLOG_0_1_6_VALIDATION_V1=PASS`: native Meson 114/114 and Clang ASan/UBSan 114/114; Android 317 tests (312 passed, five documented optional skips), debug APK and signed release APK; Web 32 files / 209 tests plus typecheck/build; JSON/import, changed-C formatting and diff checks pass. |
|
||||||
| Current operational cursor | 0.1.7 stabilization is open after immutable stable v0.1.6. Only observed bug fixes, regression fixes, directly related small UI corrections, documentation corrections, robustness, and associated tests are in scope. |
|
| Current operational cursor | Stable v0.1.6 closes the Web refinement, Sleep timeline, synchronization recovery and factual session-finalization cycle; subsequent stabilization opens as 0.1.7 after the immutable release tag. |
|
||||||
| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. |
|
| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. |
|
||||||
| Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. |
|
| Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. |
|
||||||
| Sleep revision collision recovery | `TRAINLOG_SLEEP_REVISION_COLLISION_RECOVERY_V1=PASS`: one unpublished divergent desktop tip was re-identified without payload loss, the immutable Android identity was restored exactly, and a fresh successor retained the non-conflicting union. The original generation was consumed with a full SQLite ACK; the real 24/09 session, Sleep facts, cardio samples, timeline, feedback, and Program provenance are visible on Desktop/Web. Schema v36/v33 and importer payload checks prevent recurrence. |
|
| Sleep revision collision recovery | `TRAINLOG_SLEEP_REVISION_COLLISION_RECOVERY_V1=PASS`: one unpublished divergent desktop tip was re-identified without payload loss, the immutable Android identity was restored exactly, and a fresh successor retained the non-conflicting union. The original generation was consumed with a full SQLite ACK; the real 24/09 session, Sleep facts, cardio samples, timeline, feedback, and Program provenance are visible on Desktop/Web. Schema v36/v33 and importer payload checks prevent recurrence. |
|
||||||
|
|
@ -243,38 +193,13 @@ parallel implementation of its rules.
|
||||||
Desktop and Android schema numbers are independent. Neither changes the frozen
|
Desktop and Android schema numbers are independent. Neither changes the frozen
|
||||||
Trainlog JSON V1 contract.
|
Trainlog JSON V1 contract.
|
||||||
|
|
||||||
The active Sleep V2 view reads the complete available Sleep history independently
|
The active Sleep V2 delta keeps the Web agenda period-owned: every returned
|
||||||
of the Analysis period used by other sections. Every returned night remains
|
night has one row on the shared 18:00-to-18:00 geometry. Selecting a row is
|
||||||
available on the shared 18:00-to-18:00 geometry. Sleep has one agenda display
|
|
||||||
selector: the newest nights first, seven recorded nights per page by default,
|
|
||||||
with 7, 14, 21, 28, and All choices. These are row counts, not calendar windows.
|
|
||||||
The browser-local choice survives reloads; old 5/10/15 choices migrate to
|
|
||||||
7/14/21. At the server's 3660-night read cap, the Web reports that completeness
|
|
||||||
cannot be established instead of presenting a truncated agenda as All. Paging
|
|
||||||
changes only the visible rows; PDF selection, statistics, and the current editor
|
|
||||||
remain based on the complete loaded history. Selecting a row is
|
|
||||||
presentation-only and atomically drives the detail dates, editor content, and
|
presentation-only and atomically drives the detail dates, editor content, and
|
||||||
full-width heart-rate detail; it performs no database mutation. PDF preview
|
full-width heart-rate detail; it performs no database mutation. PDF preview
|
||||||
and download instead share an explicit inclusive range over night start dates,
|
and download instead share an explicit inclusive range over night start dates,
|
||||||
initialized to the loaded history bounds and independent of detail selection.
|
initialized to the loaded agenda bounds and independent of detail selection. Its
|
||||||
The completed-sport overlay reads factual `sessions.started_at` and `ended_at`
|
rise/fall overlays are descriptive relative measured
|
||||||
from desktop history through a read-only, paginated Web endpoint.
|
|
||||||
Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for
|
|
||||||
Sleep, medication, and sport in both Web and vector PDF. Valid sport intervals
|
|
||||||
intersect `[start,end)` and are clipped only for display; missing or invalid
|
|
||||||
ends are point markers without invented duration. The Web keeps a separate
|
|
||||||
blue lane with accessible labels and tooltips; the redundant textual Web sport
|
|
||||||
list is removed. Preview and download wait for the
|
|
||||||
complete sport read and use the same selected sessions. The PDF uses a 180 pt treatment/remarks column with 7.2 pt text,
|
|
||||||
width-aware wrapping, content-driven row heights, and dated continuation pages
|
|
||||||
for exceptional notes. Each selected intake and remark is printed once without
|
|
||||||
a fixed character limit. The shorter 430 pt timeline shares one coordinate
|
|
||||||
mapping across hour ticks, grid, sleep markers, medication markers, and blue
|
|
||||||
sport bands. The PDF prints blue
|
|
||||||
outlined bands and paginated names, factual times, and durations, marking
|
|
||||||
sessions without a corresponding Sleep row as `sleep not recorded`. Sport
|
|
||||||
creates no Sleep event and changes no sleep total, session, schema, or sync
|
|
||||||
contract. The HR detail's rise/fall overlays are descriptive relative measured
|
|
||||||
variation only (five-minute trailing reference excluding the current 60-second
|
variation only (five-minute trailing reference excluding the current 60-second
|
||||||
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
||||||
are not sleep events, sleep stages, causes, readiness, or medical inference.
|
are not sleep events, sleep stages, causes, readiness, or medical inference.
|
||||||
|
|
@ -511,7 +436,7 @@ catalogue state. Built-in identities cannot be retired, equipment associations
|
||||||
are presented read-only, and profile edits affect future use only: occurrence
|
are presented read-only, and profile edits affect future use only: occurrence
|
||||||
snapshots, performed sets, feedback and MAX history are never rewritten. A
|
snapshots, performed sets, feedback and MAX history are never rewritten. A
|
||||||
successful mutation changes only the canonical desktop database; the existing
|
successful mutation changes only the canonical desktop database; the existing
|
||||||
configured generation synchronization later carries the existing
|
USB-priority, Drive mirror/fallback synchronization later carries the existing
|
||||||
catalogue/profile/BODY ZONES/causal artifacts. No Web mutation invokes sync.
|
catalogue/profile/BODY ZONES/causal artifacts. No Web mutation invokes sync.
|
||||||
|
|
||||||
The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
||||||
|
|
@ -519,21 +444,9 @@ The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
||||||
or execution state. With no active Program it shows an empty state and
|
or execution state. With no active Program it shows an empty state and
|
||||||
navigation to Sessions administration; with one it selects it automatically;
|
navigation to Sessions administration; with one it selects it automatically;
|
||||||
with several it offers a non-persisted selector. Its continuous Monday–Sunday
|
with several it offers a non-persisted selector. Its continuous Monday–Sunday
|
||||||
weeks derive from Program bounds, each unfinished session's current date
|
weeks derive from Program bounds and real `planned_for` dates, do not invent
|
||||||
(`current_for` when present, otherwise imported `planned_for`), and the actual
|
rest-day sessions, and retain undated sessions in a separate section. It shows
|
||||||
`sessions.started_at` instant for linked completed executions, presented on the
|
all five Core execution states; only `todo` offers Prepare. Each Prepare result
|
||||||
same browser-local civil day as Web session history. The Program detail Web API
|
|
||||||
adds the nullable read-only `execution_started_at` projection field; the
|
|
||||||
Programs V2 projection carries the current dates and ceded-slot state to
|
|
||||||
capable Android peers without changing the strict Programs V1 artifact. A completed
|
|
||||||
session moved from its imported date shows both dates without changing history
|
|
||||||
or Program identity. Several cards may share a day; rest days contain no
|
|
||||||
invented session. A completed execution with no valid history timestamp stays
|
|
||||||
undated rather than appearing to have occurred on its planned day. Unfinished
|
|
||||||
sessions show their current dates; ceded unfinished slots remain visible but
|
|
||||||
are excluded from next-session selection and preparation, and are not counted
|
|
||||||
as performed. It shows all five Core execution states; only eligible `todo`
|
|
||||||
offers Prepare. Each Prepare result
|
|
||||||
is followed by an authoritative Core reread and may expose the returned
|
is followed by an authoritative Core reread and may expose the returned
|
||||||
preparation link. This calendar adds no Android delivery, start, or execution
|
preparation link. This calendar adds no Android delivery, start, or execution
|
||||||
behavior.
|
behavior.
|
||||||
|
|
@ -601,7 +514,6 @@ unchanged at ten pre-existing errors: one `NewApi`, one
|
||||||
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
|
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
|
||||||
diagnostics. This UI tranche adds no lint error and changes no lint policy.
|
diagnostics. This UI tranche adds no lint error and changes no lint policy.
|
||||||
The stable Android v0.1.6 tag uses `versionName=0.1.6` and public versionCode 7.
|
The stable Android v0.1.6 tag uses `versionName=0.1.6` and public versionCode 7.
|
||||||
The v0.1.7 stabilization source starts at `versionName=0.1.7` and versionCode 8.
|
|
||||||
Private validation builds may still supply only a strictly increasing
|
Private validation builds may still supply only a strictly increasing
|
||||||
`versionCode` as a build-time override; that does not create a public release
|
`versionCode` as a build-time override; that does not create a public release
|
||||||
or change schemas and protocols.
|
or change schemas and protocols.
|
||||||
|
|
@ -772,12 +684,9 @@ partial, invalid and transport-error states never synthesize values.
|
||||||
`WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` uses a lazy, modular Apache
|
`WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` uses a lazy, modular Apache
|
||||||
ECharts 6.1.0 SVG line chart for medium/large Progression tiles. Every real
|
ECharts 6.1.0 SVG line chart for medium/large Progression tiles. Every real
|
||||||
point remains visible, `smooth=false`, legacy zeroes are preserved, and only
|
point remains visible, `smooth=false`, legacy zeroes are preserved, and only
|
||||||
Core-owned `improved` flags alter markers. A React SVG ports the exact
|
Core-owned `improved` flags alter markers. An original React SVG supplies
|
||||||
normalized head, neck, and 11 front/11 back BODY ZONES paths from Android
|
front/back BODY ZONES regions whose discrete colour level depends solely on
|
||||||
`HomeScreen.kt` `bodyRegions()` and `neckPath()`. Its discrete Catppuccin colour
|
`session_count`; the factual text list remains authoritative and accessible.
|
||||||
level still depends solely on `session_count`; the factual text list remains
|
|
||||||
authoritative and accessible. The figure geometry changes no zone mapping or
|
|
||||||
30-day calculation.
|
|
||||||
|
|
||||||
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
|
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
|
||||||
formatting, layout-invariance and source-derived text-boundary tests, plus
|
formatting, layout-invariance and source-derived text-boundary tests, plus
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,5 @@
|
||||||
# Desktop database
|
# Desktop database
|
||||||
|
|
||||||
## Schema v37: Program planning
|
|
||||||
|
|
||||||
Schema v37 is additive to the Program rescheduling development line. It
|
|
||||||
creates `program_session_planning` for absolute current dates and ceded-slot
|
|
||||||
state, `program_reschedule_operations` for exact request replay, and
|
|
||||||
`program_revision_sequences` for monotonically ordered Program snapshots.
|
|
||||||
A trigger increments the per-Program sequence whenever its revision changes,
|
|
||||||
including archive, reschedule and logical deletion. No date uniqueness or
|
|
||||||
historical session mutation is introduced. Missing planning rows mean the
|
|
||||||
original `planned_for` date and active planning state. The source format and
|
|
||||||
published Programs V1 artifact remain strict and unchanged.
|
|
||||||
|
|
||||||
This schema is present in the audited development commit. Its presence in
|
|
||||||
source does not establish the schema of another installation; a deployment
|
|
||||||
requires a compatible package, backup and migration verification.
|
|
||||||
|
|
||||||
## Causal heart-rate tail correction (schema v36, no migration)
|
## Causal heart-rate tail correction (schema v36, no migration)
|
||||||
|
|
||||||
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
|
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
|
||||||
|
|
@ -256,8 +240,8 @@ is a later, separately versioned migration.
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION=37
|
TRAINLOG_DATABASE_SCHEMA_VERSION=28
|
||||||
DATABASE_SCHEMA_V37=IMPLEMENTED
|
DATABASE_SCHEMA_V28=IMPLEMENTED
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -276,7 +260,7 @@ PRAGMA user_version;
|
||||||
Current value:
|
Current value:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
37
|
27
|
||||||
```
|
```
|
||||||
|
|
||||||
The independent actual-set loads documented in the current desktop, Android
|
The independent actual-set loads documented in the current desktop, Android
|
||||||
|
|
@ -735,7 +719,7 @@ distance km
|
||||||
|
|
||||||
The Android SQLite database is independent.
|
The Android SQLite database is independent.
|
||||||
|
|
||||||
Current Android-local version: **34**. The historical migration chain adds the
|
Current Android-local version: **17**. The explicit migration chain adds the
|
||||||
durable draft in v4, equipment references in v5, per-set load in v6, occurrence
|
durable draft in v4, equipment references in v5, per-set load in v6, occurrence
|
||||||
identity/multi-occurrence support in v7, and the widened custom-equipment
|
identity/multi-occurrence support in v7, and the widened custom-equipment
|
||||||
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw
|
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw
|
||||||
|
|
|
||||||
|
|
@ -68,32 +68,3 @@ source definition and derived preparations. A deleted program cannot be
|
||||||
resurrected or prepared; an archived program cannot be prepared. The optional
|
resurrected or prepared; an archived program cannot be prepared. The optional
|
||||||
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
|
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
|
||||||
projection and does not alter this import format.
|
projection and does not alter this import format.
|
||||||
|
|
||||||
## Explicit ordered rescheduling in the audited development line
|
|
||||||
|
|
||||||
The `trainlog-program-recalage-v1` line adds a separate planning
|
|
||||||
state. `planned_for` remains the imported date. `current_for` is an absolute
|
|
||||||
current target for an active, unfinished session; `ceded` releases one selected
|
|
||||||
future slot without completing or deleting its session identity. Completed
|
|
||||||
cards still use the linked session's actual start timestamp. Positions from the
|
|
||||||
imported Program determine the cascade order; titles never determine priority.
|
|
||||||
|
|
||||||
The Web action selects a first and last unfinished session, a resumption date,
|
|
||||||
and the exact session identities whose slots are ceded. Its available days are
|
|
||||||
the dated original Program slots in that interval on or after resumption.
|
|
||||||
The preview lists original, prior current and proposed dates. It rejects a
|
|
||||||
missing slot, a date beyond the Program end, a completed/in-progress/prepared
|
|
||||||
session, or a stale revision. It does not invent rest-day or post-end slots.
|
|
||||||
An explicitly ceded session keeps its identity and remains visible as ceded.
|
|
||||||
Unchanged sessions in the selected range are displayed in the preview but need
|
|
||||||
no planning row. Other sessions and Programs are not moved. Multiple actual or
|
|
||||||
independent sessions may still share one civil day.
|
|
||||||
|
|
||||||
Confirmation binds the preview fingerprint to the current Program projection
|
|
||||||
and writes absolute dates, ceded states, the Program revision and a durable
|
|
||||||
operation response in one transaction. Exact operation replay returns that
|
|
||||||
response; changed content under the same operation ID conflicts. A failed
|
|
||||||
write rolls back every row. The development implementation requires a recent acknowledged
|
|
||||||
Programs V2 generation for every previously contacted Android peer. A newly
|
|
||||||
started offline Android Program draft remains protected by Android's V2 import
|
|
||||||
rejection; the sync run then reports a conflict for explicit resolution.
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
# Documentation deep review V1 — 2026-10-02
|
|
||||||
|
|
||||||
## Scope and evidence hierarchy
|
|
||||||
|
|
||||||
This review covers the linear development source at
|
|
||||||
`f4ddcfdb05e9627397f7225c05f40212e9de1ea6`
|
|
||||||
(`trainlog-sleep-single-selector-weeks-v1`). Its ancestors include `3a4ec34`
|
|
||||||
(Program rollout), `8995843` (current Program projection), `c2935b8` (Web body
|
|
||||||
figures), and the Sleep sport/PDF/paging changes. It does not integrate or
|
|
||||||
describe as delivered the uncommitted Dashboard layout work in the separate
|
|
||||||
`trainlog-dashboard-polish-v1` worktree. Stable v0.1.6 and the 0.1.7
|
|
||||||
development line are different publication states.
|
|
||||||
|
|
||||||
Normative frozen contracts take priority over source implementation; source and
|
|
||||||
tests establish capabilities of this commit; dated rollout records establish
|
|
||||||
only the installation they observed; design/review files retain history. The
|
|
||||||
observed PC/Web launcher path names the `f4ddcfd` package. That observation
|
|
||||||
alone is not a byte comparison of a running process, an Android APK inspection,
|
|
||||||
or proof of every synchronization service's package. The earlier verified
|
|
||||||
Program rollout records desktop/Android schemas v37/v34. No private health
|
|
||||||
data, PDF, database or device identifier was read for this review.
|
|
||||||
|
|
||||||
## Coverage inventory
|
|
||||||
|
|
||||||
The 105 tracked baseline Markdown files include root `README.md`, `AGENTS.md`,
|
|
||||||
`CHANGELOG.md`, `android/README.md`, `format/README.md`, 17 primary `docs/*.md`
|
|
||||||
owners, domain and design records, release notes and historical reviews. This
|
|
||||||
revision adds an indexed Sleep guide and this review. The primary owners were
|
|
||||||
inspected against code, migration constants, Meson/Gradle settings, tests and
|
|
||||||
retained rollout records. Designs, releases and reviews were mapped as dated
|
|
||||||
evidence rather than rewritten into current status. The tracked Markdown
|
|
||||||
relative-link scan found no missing target before publication. External link
|
|
||||||
availability was not verified.
|
|
||||||
|
|
||||||
| Document/section | Claim | Evidence (commit, path, symbol or test) | Gap | Correction | Status |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| `AGENTS.md` scope | Analyse, Programmes, Sessions and Exercises are placeholders | `web/src/routes/`; `docs/web_sessions.md`; `docs/web_exercises.md` at `f4ddcfd` | Obsolete present tense | Names implemented routes | Corrected |
|
|
||||||
| `AGENTS.md`, database and Android status | Current schemas v26/v24, v28/v17 or v36/v33 | `tui/include/trainlog/database.h` `TRAINLOG_DATABASE_SCHEMA_VERSION=37`; `TrainlogRepository.kt` helper `version=34`; migration tests | Mixed historical and current versions | Current source v37/v34; historical migration steps retained | Corrected |
|
|
||||||
| `current_state.md`, roadmap | `c2935b8`/`3758875` Web package served | Observed launcher target names `sleep-single-selector-weeks-v1-f4ddcfd`; ancestor graph | Package claim stale; running bytes unverified | Reports launcher observation and separate Program rollout | Corrected with limit |
|
|
||||||
| Sleep agenda | Old selector or calendar-week interpretation | `SleepDiaryWorkspace.tsx` `initialAgendaPageSize`, `recentEntries`, `visibleEntries`; Sleep component tests | Seven recorded rows can span more than seven dates | Current 7/14/21/28/All row counts, default seven; old values migration only | Corrected |
|
|
||||||
| Sleep PDF and sport | Page controls could be confused with export range | `SleepDiaryWorkspace.tsx` `pdfStartDate`/`pdfEndDate`; `sleepVisualProjection`; Web PDF tests | Selection and visible page have different scopes | Documents independent inclusive range and factual sport bands | Verified in source |
|
|
||||||
| Programs | Rescheduling labelled future/candidate | `docs/program_format_v1.md`; `docs/sync_exchange.md`; `tui/tests/test_web_programs.c`; Android v34 projection | Development capability mislabelled future | Describes implemented development contract and partial V1-peer convergence | Corrected |
|
|
||||||
| Web package | `npm test` could be mistaken for embedded runtime proof | `meson_options.txt` `web`; `tui/meson.build` frontend target/tests; `tools/package_sync_candidate.py` asset gate | Source tests and package differ | README requires `-Dweb=enabled`, Meson tests, compatible bundle | Corrected |
|
|
||||||
| Synchronization | One ACK might imply universal convergence | `docs/sync_exchange.md` Programs V2 matrix; generation and ACK implementation | V1-only peer omits rescheduled Program while other domains proceed | Added domain/format/selection/ACK matrix | Corrected |
|
|
||||||
| Transport narrative | Current Web edits and architecture described USB as universal priority | `docs/sync_exchange.md` Bluetooth selector; `tools/sync_peer_worker.py`; private rollout record | Conflated paired Bluetooth path with separate USB/Drive workflow | Separated automatic paired choice from legacy configured workflow | Corrected |
|
|
||||||
| Rollout readiness | Historical eight-generation limit could look universal/current | `tools/sync_generation_exchange.py` `MAX_RETAINED_PER_PEER=16`; Android `SyncGenerationService.MAX_RETAINED_PER_PEER=8`; `tools/sync_peer_worker.py` MTP timeout | Desktop/Android ceilings differ; timeout is not success proof | Separated limits and historical bridge instructions | Corrected |
|
|
||||||
| Sleep user path | Capture, agenda and PDF behavior had no short indexed guide | `TrainlogRepository.kt`; `SleepDiaryWorkspace.tsx`; Sleep projection/PDF tests | Readers had to assemble workflow from state and code | Added indexed factual guide with privacy and evidence boundaries | Corrected |
|
|
||||||
| Backup/rollback | Old executable could be mistaken for schema rollback | `tools/backup_trainlog_sqlite.py` SQLite backup API; `docs/sync_rollout_readiness.md` | Needs paired compatibility and retained backups | Existing procedure retained and linked from README | Verified in documentation |
|
|
||||||
| Tests | Largest historical totals could look like current validation | `docs/tests.md` dated checkpoints; `tui/meson.build`; Web scripts | Build configuration and skipped tests were implicit | Added evidence-reading rule | Corrected |
|
|
||||||
| 0.1.8 | Possible training ideas could look implemented | `docs/roadmap.md`; present catalog/feedback/Sleep/Cardio owners | No approved algorithm or medical rule | Proposed-only section with human validation | Corrected |
|
|
||||||
|
|
||||||
## Validation performed in the isolated documentation worktree
|
|
||||||
|
|
||||||
`python3 tools/validate_json.py` and
|
|
||||||
`python3 tools/validate_import_contract.py` passed. With a local
|
|
||||||
`-Dweb=enabled` Meson build, strict compilation and **114/114** registered
|
|
||||||
tests passed, including the embedded frontend and package self-containment
|
|
||||||
checks. A preliminary `-Dweb=disabled` run passed 111/112; its deployment
|
|
||||||
package test rejected the intentionally absent Web assets as designed. The
|
|
||||||
changed C/H format gate passed with no changed C/H file. Relative links across
|
|
||||||
the tracked baseline plus new Markdown files and `git diff --check` passed.
|
|
||||||
These checks used build artifacts in this worktree and synthetic test data;
|
|
||||||
they did not exercise a personal device, database or service.
|
|
||||||
|
|
||||||
## Boundaries and open evidence
|
|
||||||
|
|
||||||
The source can prove format selection, persistence rules and test coverage; it
|
|
||||||
cannot prove that a particular uninspected phone or service is currently
|
|
||||||
running that source. The `f4ddcfd` launcher observation identifies a package
|
|
||||||
path, not a live process hash. The private rollout and Firefox/PDF records are
|
|
||||||
dated evidence for their stated configurations. Android path reconstruction for
|
|
||||||
the Web silhouette is source parity evidence, not an Android runtime
|
|
||||||
screenshot. The local desktop 16-generation and Android eight-generation
|
|
||||||
admission limits, and the 90-second MTP operation budget, are bounded
|
|
||||||
configuration, not a guarantee against interruption or proof that transient
|
|
||||||
storage occupation is always lower than either ceiling.
|
|
||||||
|
|
||||||
No code defect was confirmed by this documentation pass. A broad line-by-line
|
|
||||||
revalidation of every historical review and every external URL is outside the
|
|
||||||
verified coverage; their current status must not be inferred from this matrix.
|
|
||||||
No application, database, service, synchronization or personal-data operation
|
|
||||||
belongs to this documentation branch.
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
The Program cascade is deployed in the private daily installation from
|
|
||||||
`trainlog-program-recalage-v1` at `3a4ec34`. The observed PC/Web launcher points
|
|
||||||
to the `f4ddcfd` package, which includes
|
|
||||||
the verified blue Sleep sport overlay and the readable paginated treatment and
|
|
||||||
remarks PDF. Synchronization services retain their Program rollout paths. No
|
|
||||||
new personal Program rescheduling is implied by this presentation work.
|
|
||||||
|
|
||||||
The controlled private rollout is complete for the paired daily installation:
|
The controlled private rollout is complete for the paired daily installation:
|
||||||
physical libmtp, verified backups, signing continuity, migrations, correlated
|
physical libmtp, verified backups, signing continuity, migrations, correlated
|
||||||
ACKs, restart, and idempotent replay passed. Generation mode remains an
|
ACKs, restart, and idempotent replay passed. Generation mode remains an
|
||||||
|
|
@ -37,9 +30,8 @@ estimates.
|
||||||
|
|
||||||
## Current baseline
|
## Current baseline
|
||||||
|
|
||||||
Stable v0.1.6 is the release baseline for the v0.1.7 stabilization cycle.
|
Stable v0.1.6 is the release baseline for the next stabilization cycle.
|
||||||
The audited development source has desktop schema v37 and Android schema v34.
|
Desktop schema v36, Android schema v33, Notcurses, direct `Documents/Trainlog`
|
||||||
Stable v0.1.6 had desktop schema v36 and Android schema v33. Notcurses, direct `Documents/Trainlog`
|
|
||||||
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
|
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
|
||||||
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
|
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
|
||||||
Generator V1 is hidden pending V2. AI session-draft exchange retains its
|
Generator V1 is hidden pending V2. AI session-draft exchange retains its
|
||||||
|
|
@ -84,43 +76,9 @@ release closes this implementation cycle without reopening its frozen
|
||||||
contracts.
|
contracts.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
CURRENT_OPERATIONAL_CURSOR=0.1.7 bugfix-only stabilization open after immutable stable v0.1.6.
|
CURRENT_OPERATIONAL_CURSOR=Stable v0.1.6 published; open 0.1.7 stabilization after the immutable release tag.
|
||||||
```
|
```
|
||||||
|
|
||||||
### 0.1.7 stabilization scope
|
|
||||||
|
|
||||||
The 0.1.7 line is restricted to observed bug fixes, regression fixes, small UI
|
|
||||||
inconsistencies directly caused by a bug, documentation corrections, robustness,
|
|
||||||
and tests associated with those corrections.
|
|
||||||
|
|
||||||
New features, new modules, architectural refactors not required to fix a bug,
|
|
||||||
protocol evolution, new Android/Web/TUI capabilities, and speculative
|
|
||||||
optimization are out of scope. Work outside this boundary waits for a later
|
|
||||||
feature cycle rather than widening 0.1.7.
|
|
||||||
|
|
||||||
### Program rescheduling in the audited development line
|
|
||||||
|
|
||||||
Ordered Program rescheduling is implemented in the audited development line
|
|
||||||
and deployed in the recorded private Program rollout. One explicit user action
|
|
||||||
must select the starting session, restart date, affected successors, and usable
|
|
||||||
slots; preview the old and new dates; then durably record one decision. Only
|
|
||||||
unfinished sessions move, in Program position order. Explicitly ceded optional
|
|
||||||
slots may absorb required sessions without marking optional sessions completed
|
|
||||||
or removing historical identities. At most one principal Program session per
|
|
||||||
selected slot is a rule for this action, not a limit on other same-day workouts.
|
|
||||||
Rest days and Program bounds cannot be silently overridden. Preparations and
|
|
||||||
in-progress executions require checks before applying the decision.
|
|
||||||
|
|
||||||
The result does not cascade again on display, import, replay, or Bluetooth
|
|
||||||
reconnection. PC and Android must receive the same business state; a Web-only
|
|
||||||
plan is insufficient. Compatibility requires proof in both supported exchange
|
|
||||||
directions and across revisions or reexports, including explicit behavior for
|
|
||||||
older clients unable to represent the decision. Payload acceptance alone does
|
|
||||||
not establish convergence. This development contract does not reopen the
|
|
||||||
Bluetooth transport, Sleep, HR, or general sync invariants. Before any real
|
|
||||||
application, reread executions and preparations, confirm the restart date and
|
|
||||||
slot choices, and preview the resulting plan again.
|
|
||||||
|
|
||||||
Sleep Diary V1 stays frozen/readable while active capable peers require
|
Sleep Diary V1 stays frozen/readable while active capable peers require
|
||||||
`sleep-diary-v2`. The Web’s relative HR rise/fall overlays remain descriptive
|
`sleep-diary-v2`. The Web’s relative HR rise/fall overlays remain descriptive
|
||||||
measured-series presentation only, never inferred sleep states or causes.
|
measured-series presentation only, never inferred sleep states or causes.
|
||||||
|
|
@ -181,8 +139,7 @@ remaining per-installation opt-ins are deployment policy, not the v0.1.3 cursor.
|
||||||
TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
|
TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
|
||||||
system, permanent Header/Footer, empty-data states and health status. The
|
system, permanent Header/Footer, empty-data states and health status. The
|
||||||
Dashboard slices below are complete; this does not authorize implementation of
|
Dashboard slices below are complete; this does not authorize implementation of
|
||||||
the then-remaining route placeholders; those routes are implemented on the
|
the remaining route placeholders.
|
||||||
audited 0.1.7 development line.
|
|
||||||
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid,
|
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid,
|
||||||
explicit edit mode, drag/resize, keyboard alternative and derived responsive
|
explicit edit mode, drag/resize, keyboard alternative and derived responsive
|
||||||
projections without persisting layout state.
|
projections without persisting layout state.
|
||||||
|
|
@ -423,20 +380,6 @@ normal capture.
|
||||||
|
|
||||||
## Later
|
## Later
|
||||||
|
|
||||||
### Proposed 0.1.8 training assistance (not implemented)
|
|
||||||
|
|
||||||
Explore gradual, explained progression or maintaining a load from existing
|
|
||||||
performance and subjective feedback. Compare exercises and equipment only when
|
|
||||||
their context is genuinely comparable; account for effort, discomfort and
|
|
||||||
missing observations. Explore requested or evidenced targeted strengthening,
|
|
||||||
reasoned exercise diversity and dumbbell use without diagnosing weakness.
|
|
||||||
Define cautious cardio reference points before any gradual introduction, with
|
|
||||||
no maximal test or generated prescription from this documentation work. Reuse
|
|
||||||
the existing medication diary, Sleep and Cardio facts as context without a
|
|
||||||
second intake workflow or asserted medical causality. A human must validate
|
|
||||||
any proposed training change before it alters a workout. These are ideas, not
|
|
||||||
an implementation decision, delivery date, recovery score or clinical rule.
|
|
||||||
|
|
||||||
- evolve training analytics only where sufficient real history supports the
|
- evolve training analytics only where sufficient real history supports the
|
||||||
metric, without aggregating incomparable loads;
|
metric, without aggregating incomparable loads;
|
||||||
- add body baseline and trend comparisons while keeping direct measurements
|
- add body baseline and trend comparisons while keeping direct measurements
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
# Sleep Diary: capture, review and PDF
|
|
||||||
|
|
||||||
This guide describes the Web and Android behavior in the audited `f4ddcfd`
|
|
||||||
0.1.7 development source. The [current state](current_state.md) owns rollout
|
|
||||||
and validation status; [synchronization exchange](sync_exchange.md) owns the
|
|
||||||
versioned Sleep companion. Sleep facts and medication intakes are not a medical
|
|
||||||
interpretation or an automatic training decision.
|
|
||||||
|
|
||||||
## Capture and correction
|
|
||||||
|
|
||||||
Android's quick actions record bedtime (`Couché`), a waking interval
|
|
||||||
(`Réveil`), final get-up (`Levé`) and time-stamped medication intakes. An intake
|
|
||||||
may be recorded during the day or before bedtime. A pre-bed intake creates or
|
|
||||||
reuses the same durable night/day entry; `Couché` later adds to that entry.
|
|
||||||
There is no separate medicine log to fill out. Quantity is a count of the
|
|
||||||
separately stored per-unit dose. Each meaningful edit advances the entry's
|
|
||||||
causal revision; correction uses the existing entry rather than inventing
|
|
||||||
another night. The Android owner and draft/quick-publication rules are detailed
|
|
||||||
in [Android](android.md#sleep-diary-v1-and-active-v2-companion).
|
|
||||||
|
|
||||||
The Sleep day is assigned using the local 18:00-to-18:00 boundary. Each Web
|
|
||||||
agenda row and its heart-rate detail use that night's exact offset and a
|
|
||||||
24-hour elapsed timeline. A missing factual sleep interval may receive a
|
|
||||||
clearly labelled presentation estimate when both bedtime and final get-up
|
|
||||||
exist: bedtime plus 45 minutes through final get-up minus 10 minutes, minus
|
|
||||||
structured waking overlap for the displayed duration. It does not write a
|
|
||||||
Sleep fact. No missing heart-rate samples or sleep stages are manufactured.
|
|
||||||
|
|
||||||
## Review the agenda
|
|
||||||
|
|
||||||
In Analyse → Sommeil, the agenda is newest first. Its single display selector
|
|
||||||
shows 7, 14, 21, 28 or All **recorded entries** per page; seven is the first-use
|
|
||||||
default. These are row counts, not complete calendar weeks: seven entries can
|
|
||||||
span more than seven dates. The choice is browser-local and survives reload;
|
|
||||||
previous 5/10/15 choices migrate to 7/14/21. The other Analyse sections keep
|
|
||||||
their own period control. Select a night to inspect and edit that entry and
|
|
||||||
its measured heart-rate timeline. Paging does not discard the selected night,
|
|
||||||
editor state, loaded statistics or PDF range.
|
|
||||||
|
|
||||||
Blue sport bands come from completed session start/end history, not the
|
|
||||||
Program plan. A missing or invalid end is shown as a point marker with no
|
|
||||||
invented duration. The Web agenda omits the redundant sport text list; the PDF
|
|
||||||
retains a dated sport appendix. Sport does not become a Sleep event or change a
|
|
||||||
sleep total. If the full read reaches its 3,660-night cap, the Web reports that
|
|
||||||
completeness cannot be established instead of silently treating a truncated
|
|
||||||
set as All. Missing data and load errors have explicit states.
|
|
||||||
|
|
||||||
## Preview and export
|
|
||||||
|
|
||||||
The vector PDF preview and download share an inclusive range of **night start
|
|
||||||
dates**. That range is initialized from loaded history and is independent of
|
|
||||||
both the visible agenda page and selected detail night. Sport is read completely
|
|
||||||
before preview/export. The PDF uses the same 18:00 geometry as Web, prints
|
|
||||||
factual sport intervals in blue, and lists sessions even when there is no
|
|
||||||
matching Sleep row. Treatments and remarks wrap by available width and continue
|
|
||||||
on dated pages instead of truncating silently. Review the chosen range before
|
|
||||||
export; a PDF is a local copy of potentially sensitive information.
|
|
||||||
|
|
||||||
The descriptive heart-rate rise/fall overlays compare measured BPM with a
|
|
||||||
recent reference. They do not infer sleep stages, causes, readiness or medical
|
|
||||||
conditions. The source-backed projection and evidence limits are summarized in
|
|
||||||
[current state](current_state.md#versions-and-compatibility) and the
|
|
||||||
[documentation review](reviews/documentation_deep_review_v1.md).
|
|
||||||
|
|
@ -32,25 +32,8 @@ offset-bearing timestamps, appreciations, notes, logical deletion, and an
|
||||||
integer `quantity` in `1..99` on every medication intake. `dose_value` and
|
integer `quantity` in `1..99` on every medication intake. `dose_value` and
|
||||||
`dose_unit` remain the per-unit snapshot; quantity is never encoded in notes.
|
`dose_unit` remain the per-unit snapshot; quantity is never encoded in notes.
|
||||||
V1 input omits `quantity` and is normalized to one unit. Exact revision replay
|
V1 input omits `quantity` and is normalized to one unit. Exact revision replay
|
||||||
is unchanged; resurrection after deletion is rejected in the generation
|
is unchanged; sibling revisions and resurrection after deletion are rejected
|
||||||
consumption transaction. For distinct V2 sibling tips of the same entry with
|
in the generation consumption transaction.
|
||||||
an unchanged night envelope and deletion state, the last actual user edit
|
|
||||||
wins, independent of device. Revision edit timestamps are compared as instants;
|
|
||||||
event times, generation publication, import, and ACK times never establish
|
|
||||||
priority. An equal edit instant or inconsistent provenance remains an explicit
|
|
||||||
conflict. This selection uses the complete winning snapshot, not a field or
|
|
||||||
event union, so an intentional removal is not resurrected. The losing revision
|
|
||||||
remains immutable in local history. When the local snapshot wins, a fresh
|
|
||||||
technical successor descends from the received revision and retains the
|
|
||||||
winning user-edit timestamp and exact payload, allowing the other peer to
|
|
||||||
advance causally without rewriting either original revision or generation.
|
|
||||||
|
|
||||||
A durable Sleep rejection keeps its exact ACK and does not advance either
|
|
||||||
causal tip. The full-generation worker carries the consumer's bounded Sleep
|
|
||||||
diagnostic through to the Web run state, where an unresolved concurrent
|
|
||||||
revision is reported as `data_conflict` rather than an opaque `internal_error`.
|
|
||||||
An old rejected generation remains rejected after a later, newly captured
|
|
||||||
generation resolves its distinct conflict.
|
|
||||||
|
|
||||||
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal
|
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal
|
||||||
ownership.
|
ownership.
|
||||||
|
|
@ -261,28 +244,6 @@ does not publish to it or let it override an artifact in the new endpoint.
|
||||||
|
|
||||||
## 3. Artifact table
|
## 3. Artifact table
|
||||||
|
|
||||||
The following is a navigation matrix for the audited `f4ddcfd` source. A
|
|
||||||
capability advertisement selects a companion; file presence alone is not an
|
|
||||||
ACK or proof that every domain converged. The detailed versioned rules below
|
|
||||||
remain authoritative.
|
|
||||||
|
|
||||||
| Domain | Producer → consumer | Format/version | Selection and persistence | ACK/proof and limit |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| Completed sessions | Android ↔ desktop | `trainlog-mobile-export` V3; V1/V2 legacy input | Default automatic snapshot; stable IDs in separate SQLite stores | V3 import/replay is idempotent; this is not a full-generation ACK |
|
|
||||||
| Manual preparations | Desktop → Android | `trainlog-session-preparations` V2 | Full generation on capable peer; revisions, deliveries and withdrawals retained | Correlated generation ACK closes delivery/withdrawal; an active draft is preserved |
|
|
||||||
| Programs | Desktop → Android | `trainlog-programs` V1 or V2 | Exactly one selected by peer capability; V2 carries current dates, ceded state and revision sequence; desktop owns plan | Correlated ACK; V1-only peer cannot represent a rescheduled live plan, so Program convergence may remain incomplete while other domains proceed |
|
|
||||||
| Program executions | Android → desktop | `trainlog-program-executions` V1 | Stable in-progress/completed provenance, separate from `trainlog-program` V1 desktop import | Generation validation and ACK; no invented completed session |
|
|
||||||
| Sleep diary | Android ↔ desktop | `sleep-diary-v2`; frozen V1 readable | Current causal revision and immutable history; v2 carries intake quantity | Generation/ACK plus revision validation; concurrent ambiguous edits can reject |
|
|
||||||
| Heart rate and Cardio | Android → desktop | Heart Rate V1, session timeline V1, cardio sessions/calibration/guidance V1; separate heart-rate corrections V1 | Factual captures and versioned companions, selected by capabilities | Correlated generation/ACK; no sensor or medical inference on desktop |
|
|
||||||
| Exercise/equipment context | Both directions | Catalog, aliases, BODY ZONES and equipment companions below | Stable IDs, revision and causal rules apply per domain | A generation ACK does not override a per-domain conflict |
|
|
||||||
|
|
||||||
`trainlog-program` V1 is a strict desktop file import defined in
|
|
||||||
[Program format V1](program_format_v1.md); it is never the synchronized
|
|
||||||
`trainlog-programs` companion. `/api/v1` is the local Web API, not a JSON
|
|
||||||
exchange format. Bluetooth, MTP and private Drive move bytes for the same
|
|
||||||
full-generation business protocol; default V3 compatibility and installation
|
|
||||||
opt-in remain separate from format capability.
|
|
||||||
|
|
||||||
| Direction | File | Format |
|
| Direction | File | Format |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
|
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
|
||||||
|
|
@ -299,7 +260,6 @@ opt-in remain separate from format capability.
|
||||||
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
|
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
|
||||||
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
|
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
|
||||||
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
|
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
|
||||||
| PC -> Android | `programs-v2.json` | capability-selected `trainlog-programs` v2 companion: original and current dates, ceded state, ordered revision sequence, and the same deletion tombstones |
|
|
||||||
| Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance |
|
| Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance |
|
||||||
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||||
| Android -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 |
|
| Android -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 |
|
||||||
|
|
@ -322,33 +282,6 @@ V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does
|
||||||
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
|
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
|
||||||
semantics.
|
semantics.
|
||||||
|
|
||||||
### Programs V2 capability matrix in the audited development line
|
|
||||||
|
|
||||||
`trainlog-programs` V2 is a separate strict artifact selected only for a peer
|
|
||||||
advertising `programs-v2`. Its session adds `position`, nullable `current_for`
|
|
||||||
and `planning_state=active|ceded`; its Program adds `revision_sequence`.
|
|
||||||
`planned_for`, stable IDs, source entries and tombstones retain their V1
|
|
||||||
meaning. One generation contains exactly one Programs version. Android v34
|
|
||||||
stores the V2 projection transactionally and rejects a lower sequence, a
|
|
||||||
same-sequence different revision, a later V1 downgrade, or a changed plan for
|
|
||||||
its currently active Program draft. Execution facts return through the
|
|
||||||
unchanged Program-executions V1 companion and retain their actual timestamps.
|
|
||||||
|
|
||||||
| Desktop state | Android capability | Program result | Other domains |
|
|
||||||
|---|---|---|---|
|
|
||||||
| No reschedule | `programs-v1` only | V1 original plan | Normal generation |
|
|
||||||
| No reschedule | `programs-v2` | V2 projection; V1 is omitted | Normal generation |
|
|
||||||
| Rescheduled live Program | `programs-v2` | V2 absolute current plan | Normal generation |
|
|
||||||
| Rescheduled live Program | V1 only, absent or stale V2 | Program artifact omitted; run reports incomplete Program convergence after ACK | Other artifacts still synchronize |
|
|
||||||
| Program draft started offline after the last V2 ACK | `programs-v2` | Android rejects a changed plan without rewriting its draft; explicit conflict resolution is required | Generation rejection is durable |
|
|
||||||
|
|
||||||
The desktop commit requires that the latest generation to each previously
|
|
||||||
contacted Android peer was an acknowledged V2 Program generation no older than
|
|
||||||
five minutes. This bounds, but cannot eliminate, a phone-side race: Android's
|
|
||||||
active-draft check is the final guard. A failed or downgraded peer cannot be
|
|
||||||
reported as current for the Program domain. The existing generation, request,
|
|
||||||
ACK, tombstone, transport, Sleep and HR wire contracts are unchanged.
|
|
||||||
|
|
||||||
## Optional Program executions companion V1
|
## Optional Program executions companion V1
|
||||||
|
|
||||||
`trainlog-program-executions` V1 is an Android-to-desktop full-generation
|
`trainlog-program-executions` V1 is an Android-to-desktop full-generation
|
||||||
|
|
@ -1134,7 +1067,7 @@ named by that validated reference. It does not recursively mirror retained
|
||||||
Android generations, desktop publications, or unrelated exchange history.
|
Android generations, desktop publications, or unrelated exchange history.
|
||||||
Local correlation is checked every 50 ms without USB work; complete MTP pulls
|
Local correlation is checked every 50 ms without USB work; complete MTP pulls
|
||||||
are throttled to a three-second cadence. Each MTP operation has its own
|
are throttled to a three-second cadence. Each MTP operation has its own
|
||||||
90-second ceiling while the conversation retains its configured global
|
30-second ceiling while the conversation retains its configured global
|
||||||
deadline. Exhausting the global deadline while waiting for a correlated
|
deadline. Exhausting the global deadline while waiting for a correlated
|
||||||
reference reports `timeout waiting for correlated android-generation-v1.json`;
|
reference reports `timeout waiting for correlated android-generation-v1.json`;
|
||||||
only an adapter that exceeds its independent operation budget reports
|
only an adapter that exceeds its independent operation budget reports
|
||||||
|
|
|
||||||
|
|
@ -51,29 +51,26 @@ generalizes pairing to another phone or authorizes an unconfigured desktop.
|
||||||
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
|
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
|
||||||
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
|
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
|
||||||
foreign-key checks.
|
foreign-key checks.
|
||||||
4. V3 is not an Android backup. At the original September 2026 rollout, the
|
4. V3 is not an Android backup. Current Android has a complete verified backup
|
||||||
installed schema-v17 phone required a pinned bridge candidate, verified
|
container. For the installed schema-v17 release, build the pinned bridge
|
||||||
signing/version continuity, and a complete retained user backup before the
|
candidate, verify signing/version continuity separately, install only with
|
||||||
newer Android package. That is historical procedure evidence, not a claim
|
explicit approval, create and verify the user backup, and retain it before
|
||||||
about the currently installed phone schema or an instruction to repeat the
|
installing current Android. None of those device/user operations occurred in
|
||||||
bridge. A new rollout must inspect its actual versions and backups first.
|
this software mission.
|
||||||
5. Replacing a binary does not roll back a schema. Before any exchange, a
|
5. Replacing a binary does not roll back a schema. Before any exchange, a
|
||||||
mutually compatible captured pair may be restored. After causal writes or
|
mutually compatible captured pair may be restored. After causal writes or
|
||||||
ACKs, never restore only one peer: stop synchronization, preserve both stores
|
ACKs, never restore only one peer: stop synchronization, preserve both stores
|
||||||
and all artifacts, and use an explicit paired recovery plan.
|
and all artifacts, and use an explicit paired recovery plan.
|
||||||
6. Per-peer retained-generation ceilings are admission gates: the audited
|
6. The eight-generation-per-peer ceiling is an admission gate. Exhaustion must
|
||||||
desktop `sync_generation_exchange.py` permits 16 active outgoing entries;
|
preserve pending generations, tombstones, finalizations and ACK ledgers; it
|
||||||
Android `SyncGenerationService` permits eight. Exhaustion preserves pending
|
must never trigger hidden eviction.
|
||||||
generations, tombstones, finalizations and ACK ledgers; it never triggers
|
|
||||||
hidden eviction. These counts are not a bound on all transient staging or
|
|
||||||
transport objects.
|
|
||||||
|
|
||||||
The grouped rollout approval covered the signed APK, desktop bundle, verified
|
The grouped rollout approval covered the signed APK, desktop bundle, verified
|
||||||
user backups, coordinated service quiescence, pairing, synthetic smoke and
|
user backups, coordinated service quiescence, pairing, synthetic smoke and
|
||||||
non-destructive hardware smoke. Those steps are complete for the recorded
|
non-destructive hardware smoke. Those steps are complete for the recorded
|
||||||
private installation. Drive remains separate and untested.
|
private installation. Drive remains separate and untested.
|
||||||
|
|
||||||
The software-only candidate recorded for the original rollout was rebuilt under the private root
|
The latest software-only candidate set was rebuilt under the private root
|
||||||
`/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit
|
`/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit
|
||||||
`dd8c91a946a9c836965818d8cc35b8f8bce74658`. Its desktop inventory records every
|
`dd8c91a946a9c836965818d8cc35b8f8bce74658`. Its desktop inventory records every
|
||||||
packaged file digest plus schemas, protocols, entry points and runtime
|
packaged file digest plus schemas, protocols, entry points and runtime
|
||||||
|
|
@ -87,7 +84,7 @@ synthetic schema-v17 backup SHA-256 is
|
||||||
`06eb3999c6f3ce2fd8f551b38727a0a67fe6b48dde4c3244b3d50357afa7e6a3`.
|
`06eb3999c6f3ce2fd8f551b38727a0a67fe6b48dde4c3244b3d50357afa7e6a3`.
|
||||||
These are unsigned development evidence, not deployment authorization.
|
These are unsigned development evidence, not deployment authorization.
|
||||||
|
|
||||||
## Compatible set for the original rollout
|
## Compatible set
|
||||||
|
|
||||||
- desktop binary containing schema v21, the Web API and the generation worker;
|
- desktop binary containing schema v21, the Web API and the generation worker;
|
||||||
- `sync_generation_exchange.py` and all matching transaction-neutral codecs;
|
- `sync_generation_exchange.py` and all matching transaction-neutral codecs;
|
||||||
|
|
@ -113,16 +110,11 @@ These are unsigned development evidence, not deployment authorization.
|
||||||
|
|
||||||
Do not delete an unacknowledged generation, the last acknowledged generation,
|
Do not delete an unacknowledged generation, the last acknowledged generation,
|
||||||
its previous valid generation, or causal evidence. An ACK timeout is unresolved,
|
its previous valid generation, or causal evidence. An ACK timeout is unresolved,
|
||||||
not rollback. Sixteen retained outgoing generations exhaust admission and require
|
not rollback. Eight retained outgoing generations exhaust admission and require
|
||||||
operator diagnosis; this tranche adds no tombstone or evidence garbage
|
operator diagnosis; this tranche adds no tombstone or evidence garbage
|
||||||
collection. A migrated database is recovered from its verified backup, never by
|
collection. A migrated database is recovered from its verified backup, never by
|
||||||
assuming an older binary reverses its migration.
|
assuming an older binary reverses its migration.
|
||||||
|
|
||||||
The audited MTP peer worker has a 90-second per-operation budget, further
|
|
||||||
limited by the remaining conversation deadline. It is a timeout bound, not a
|
|
||||||
success guarantee or proof that an interruption cause has disappeared.
|
|
||||||
|
|
||||||
The recorded private installation completed physical MTP, service coordination
|
The recorded private installation completed physical MTP, service coordination
|
||||||
and APK/desktop deployment. Actual Drive configuration remains untested; the
|
and APK/desktop deployment. Actual Drive configuration remains untested; the
|
||||||
Android eight-generation admission bound and foreground-phone requirement
|
eight-generation admission bound and foreground-phone requirement remain.
|
||||||
remain; the audited desktop limit is 16.
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,5 @@
|
||||||
# Tests and validation
|
# Tests and validation
|
||||||
|
|
||||||
## Reading the evidence
|
|
||||||
|
|
||||||
Counts below belong to the stated checkpoint, source revision and build
|
|
||||||
configuration. They are historical evidence, not a claim that the present
|
|
||||||
documentation checkout ran those tests. A native build with `-Dweb=enabled`
|
|
||||||
registers embedded Web asset tests in Meson; `npm test` separately runs Vitest
|
|
||||||
against Web source. A default `web=auto` build can omit embedded assets, so a
|
|
||||||
green source test alone does not validate the packaged Web application.
|
|
||||||
Skipped Android fixture tests, unrun instrumented tests and physical Bluetooth,
|
|
||||||
MTP or Drive evidence are distinct outcomes. The primary personal phone is
|
|
||||||
reserved for authorized manual smoke checks, not host automation.
|
|
||||||
|
|
||||||
## Sleep V2 concurrent-edit selection in 0.1.7
|
|
||||||
|
|
||||||
Synthetic Desktop and Android regressions cover both edit-time orderings,
|
|
||||||
including a later user edit that records an earlier sleep event. They check
|
|
||||||
the complete winning snapshot, retained sibling revisions, no event/intake
|
|
||||||
union or duplication, causal successor import on the other peer, exact replay,
|
|
||||||
and rejection when edit instants are equal. The real paired-device validation
|
|
||||||
completed separate automatic, Web-with-automatic-service, and post-reconnection
|
|
||||||
Bluetooth generation/ACK cycles without changing an old rejected ACK.
|
|
||||||
|
|
||||||
## Sleep revision collision recovery
|
## Sleep revision collision recovery
|
||||||
|
|
||||||
Collision regressions distinguish exact replay from same-ID parent or payload
|
Collision regressions distinguish exact replay from same-ID parent or payload
|
||||||
|
|
@ -410,23 +388,6 @@ Firefox scenario exports a seven-night French PDF from the embedded production
|
||||||
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
|
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
|
||||||
with `pdftoppm` for retained visual inspection.
|
with `pdftoppm` for retained visual inspection.
|
||||||
|
|
||||||
The completed-sport Sleep overlay is covered by C read-only history
|
|
||||||
intersection/pagination checks, Web pagination and failure-state tests, and
|
|
||||||
pure geometry tests for 05:00 placement, midnight, 18:00 clipping, missing and
|
|
||||||
invalid ends, and the Europe/Paris fall offset. PDF tests assert vector blue
|
|
||||||
bands, black outlines, factual labels, and unchanged Sleep totals. Synthetic
|
|
||||||
Firefox validation uses an isolated schema-v37 database and the embedded
|
|
||||||
`-Dweb=enabled` binary, then inspects a downloaded PDF rendered by `pdftoppm`.
|
|
||||||
Synthetic French and English reports of 7, 14, 21, and 30 nights are rasterized
|
|
||||||
for page, margin, and monochrome inspection. No personal health artifact is
|
|
||||||
stored in the repository.
|
|
||||||
The private PC/Web deployment was checked separately after the rollout switch:
|
|
||||||
the running executable and the served JavaScript/CSS hashes matched the
|
|
||||||
`e2c07f1` bundle. Read-only Firefox inspection found 9 blue segments across
|
|
||||||
11 existing Sleep rows; a downloaded three-page PDF contained the same sampled
|
|
||||||
factual start time and a printable sport listing. Its private images and PDF
|
|
||||||
remain outside the repository.
|
|
||||||
|
|
||||||
`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
|
`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
|
||||||
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
||||||
activity map and direct totals, all compact/medium/large disclosure levels,
|
activity map and direct totals, all compact/medium/large disclosure levels,
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,5 @@ Create, update and retirement first commit only to the desktop database. They
|
||||||
do not call synchronization and do not require a phone. The next user-triggered
|
do not call synchronization and do not require a phone. The next user-triggered
|
||||||
normal synchronization reuses the existing PC catalogue, exercise profile
|
normal synchronization reuses the existing PC catalogue, exercise profile
|
||||||
state, BODY ZONES, equipment companions and causal deletion artifacts through
|
state, BODY ZONES, equipment companions and causal deletion artifacts through
|
||||||
the configured generation engine (Bluetooth primary with direct MTP recovery
|
the existing USB-priority engine with Drive as mirror/fallback. No Web
|
||||||
on the paired installation, or the separate USB/Drive workflow). Without that
|
|
||||||
opt-in, the default V3 path retains its documented compatibility limits. No Web
|
|
||||||
Exercises artifact or protocol version was introduced.
|
Exercises artifact or protocol version was introduced.
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,7 @@ Inactive execution drafts and completed sessions use the existing causal
|
||||||
operation/state boundary. Active or stale drafts conflict, and completed-session
|
operation/state boundary. Active or stale drafts conflict, and completed-session
|
||||||
deletion records a finalization so older snapshots cannot recreate the session.
|
deletion records a finalization so older snapshots cannot recreate the session.
|
||||||
|
|
||||||
Programs are planning data owned by desktop SQLite (introduced in v25 and
|
Programs are planning data owned by desktop schema v26. The Programs subtab
|
||||||
extended through v37). The Programs subtab
|
|
||||||
uses responsive cards and a timeline detail view backed by real imported
|
uses responsive cards and a timeline detail view backed by real imported
|
||||||
definitions, sessions, usage, and provenance data. Import first runs the
|
definitions, sessions, usage, and provenance data. Import first runs the
|
||||||
same strict Core validator in preview mode, then commits only after explicit
|
same strict Core validator in preview mode, then commits only after explicit
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
project(
|
project(
|
||||||
'trainlog',
|
'trainlog',
|
||||||
'c',
|
'c',
|
||||||
version: '0.1.7',
|
version: '0.1.6',
|
||||||
meson_version: '>=1.3.0',
|
meson_version: '>=1.3.0',
|
||||||
default_options: [
|
default_options: [
|
||||||
'c_std=c17',
|
'c_std=c17',
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from pathlib import Path
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
import export_programs
|
import export_programs
|
||||||
import export_programs_v2
|
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
|
|
@ -80,41 +79,6 @@ class ProgramsExchangeTest(unittest.TestCase):
|
||||||
"requested_at": "2026-09-18T12:00:00Z",
|
"requested_at": "2026-09-18T12:00:00Z",
|
||||||
}], payload["deletions"])
|
}], payload["deletions"])
|
||||||
|
|
||||||
def test_v2_carries_current_date_and_ceded_state_without_changing_v1(self):
|
|
||||||
with closing(sqlite3.connect(self.database)) as db:
|
|
||||||
db.executescript("""
|
|
||||||
CREATE TABLE program_session_planning(
|
|
||||||
program_session_id TEXT PRIMARY KEY,current_for TEXT,
|
|
||||||
state TEXT,operation_id TEXT,updated_at TEXT);
|
|
||||||
CREATE TABLE program_revision_sequences(
|
|
||||||
program_id TEXT PRIMARY KEY,revision_sequence INTEGER NOT NULL);
|
|
||||||
INSERT INTO program_revision_sequences VALUES('pg_live',1);
|
|
||||||
INSERT INTO program_session_planning VALUES(
|
|
||||||
'pgs_live','2026-10-01','active','op_fixture','2026-09-30T12:00:00Z');
|
|
||||||
PRAGMA user_version=37;
|
|
||||||
""")
|
|
||||||
db.commit()
|
|
||||||
initial = export_programs.export_programs(self.database)
|
|
||||||
current = export_programs_v2.export_programs_v2(self.database)
|
|
||||||
self.assertEqual(1, initial["version"])
|
|
||||||
self.assertEqual(2, current["version"])
|
|
||||||
original = initial["programs"][0]["sessions"][0]
|
|
||||||
planned = current["programs"][0]["sessions"][0]
|
|
||||||
self.assertIsNone(original["planned_for"])
|
|
||||||
self.assertEqual(set(original), {
|
|
||||||
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences"
|
|
||||||
})
|
|
||||||
self.assertEqual("2026-10-01", planned["current_for"])
|
|
||||||
self.assertEqual(1, current["programs"][0]["revision_sequence"])
|
|
||||||
self.assertEqual("active", planned["planning_state"])
|
|
||||||
self.assertEqual(0, planned["position"])
|
|
||||||
with closing(sqlite3.connect(self.database)) as db:
|
|
||||||
db.execute("UPDATE program_session_planning SET current_for=NULL,state='ceded'")
|
|
||||||
db.commit()
|
|
||||||
ceded = export_programs_v2.export_programs_v2(self.database)
|
|
||||||
self.assertIsNone(ceded["programs"][0]["sessions"][0]["current_for"])
|
|
||||||
self.assertEqual("ceded", ceded["programs"][0]["sessions"][0]["planning_state"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -246,131 +246,6 @@ class SleepExchangeTest(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(ValueError, "positive"):
|
with self.assertRaisesRegex(ValueError, "positive"):
|
||||||
exchange.validate(self.document(invalid))
|
exchange.validate(self.document(invalid))
|
||||||
|
|
||||||
def test_contradictory_final_get_up_siblings_remain_unresolved(self):
|
|
||||||
base = "slr_20000000-0000-4000-8000-000000000002"
|
|
||||||
desktop_tip = "slr_40000000-0000-4000-8000-000000000004"
|
|
||||||
android_parent = "slr_50000000-0000-4000-8000-000000000005"
|
|
||||||
android_tip = "slr_60000000-0000-4000-8000-000000000006"
|
|
||||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(base))))
|
|
||||||
|
|
||||||
desktop = entry(desktop_tip, base)
|
|
||||||
desktop["events"].append({
|
|
||||||
"event_id": "sle_70000000-0000-4000-8000-000000000007",
|
|
||||||
"type": "final_get_up", "start_at": "2026-10-25T06:30:00+01:00",
|
|
||||||
"end_at": None,
|
|
||||||
})
|
|
||||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(desktop)))
|
|
||||||
before = self.db.execute(
|
|
||||||
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions "
|
|
||||||
"ORDER BY revision_id"
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
android = entry(android_tip, android_parent)
|
|
||||||
android["ancestry"] = [android_tip, android_parent, base]
|
|
||||||
android["events"][0]["end_at"] = "2026-10-25T06:50:00+01:00"
|
|
||||||
android["events"].append({
|
|
||||||
"event_id": "sle_80000000-0000-4000-8000-000000000008",
|
|
||||||
"type": "final_get_up", "start_at": "2026-10-25T06:50:00+01:00",
|
|
||||||
"end_at": None,
|
|
||||||
})
|
|
||||||
with self.assertRaisesRegex(ValueError, "concurrent sleep diary revision"):
|
|
||||||
exchange.apply(self.db, self.document(android))
|
|
||||||
|
|
||||||
self.assertEqual(before, self.db.execute(
|
|
||||||
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions "
|
|
||||||
"ORDER BY revision_id"
|
|
||||||
).fetchall())
|
|
||||||
self.assertEqual(desktop_tip, self.db.execute(
|
|
||||||
"SELECT current_revision_id FROM sleep_diary_entries"
|
|
||||||
).fetchone()[0])
|
|
||||||
self.assertEqual(1, self.db.execute(
|
|
||||||
"SELECT COUNT(*) FROM sleep_diary_events WHERE revision_id=? "
|
|
||||||
"AND event_type='final_get_up'", (desktop_tip,)
|
|
||||||
).fetchone()[0])
|
|
||||||
|
|
||||||
def test_v2_newer_local_edit_converges_without_using_event_time(self):
|
|
||||||
base = "slr_20000000-0000-4000-8000-000000000002"
|
|
||||||
local_id = "slr_40000000-0000-4000-8000-000000000004"
|
|
||||||
remote_id = "slr_50000000-0000-4000-8000-000000000005"
|
|
||||||
|
|
||||||
def v2(item):
|
|
||||||
document = self.document(item)
|
|
||||||
document["version"] = 2
|
|
||||||
document["entries"][0]["intakes"][0]["quantity"] = 1
|
|
||||||
return document
|
|
||||||
|
|
||||||
self.assertEqual((1, 0), exchange.apply(self.db, v2(entry(base))))
|
|
||||||
self.db.commit()
|
|
||||||
peer = sqlite3.connect(":memory:")
|
|
||||||
self.db.backup(peer)
|
|
||||||
try:
|
|
||||||
local = entry(local_id, base)
|
|
||||||
local["updated_at"] = "2026-10-25T20:00:00+01:00"
|
|
||||||
local["events"][0]["end_at"] = "2026-10-25T06:00:00+01:00"
|
|
||||||
remote = entry(remote_id, base)
|
|
||||||
remote["updated_at"] = "2026-10-25T19:00:00+01:00"
|
|
||||||
remote["events"][0]["end_at"] = "2026-10-25T07:00:00+01:00"
|
|
||||||
remote["events"].append({
|
|
||||||
"event_id": "sle_60000000-0000-4000-8000-000000000006",
|
|
||||||
"type": "final_get_up",
|
|
||||||
"start_at": "2026-10-25T07:00:00+01:00",
|
|
||||||
"end_at": None,
|
|
||||||
})
|
|
||||||
extra_intake = dict(remote["intakes"][0])
|
|
||||||
extra_intake["intake_id"] = "mdi_60000000-0000-4000-8000-000000000006"
|
|
||||||
extra_intake["quantity"] = 2
|
|
||||||
remote["intakes"].append(extra_intake)
|
|
||||||
self.assertEqual((1, 0), exchange.apply(self.db, v2(local)))
|
|
||||||
self.assertEqual((1, 0), exchange.apply(peer, v2(remote)))
|
|
||||||
|
|
||||||
self.assertEqual((1, 0), exchange.apply(self.db, exchange.build(peer)))
|
|
||||||
successor = exchange.build(self.db)["entries"][0]
|
|
||||||
self.assertEqual(remote_id, successor["parent_revision_id"])
|
|
||||||
self.assertEqual(local["events"], successor["events"])
|
|
||||||
self.assertEqual(1, len(successor["events"]))
|
|
||||||
self.assertEqual(local["updated_at"], successor["updated_at"])
|
|
||||||
self.assertEqual(1, len(successor["intakes"]))
|
|
||||||
self.assertEqual(1, successor["intakes"][0]["quantity"])
|
|
||||||
self.assertEqual((0, 1), exchange.apply(self.db, exchange.build(peer)))
|
|
||||||
self.assertEqual((1, 0), exchange.apply(peer, exchange.build(self.db)))
|
|
||||||
self.assertEqual((0, 1), exchange.apply(peer, exchange.build(self.db)))
|
|
||||||
self.assertEqual(
|
|
||||||
successor["revision_id"],
|
|
||||||
exchange.build(peer)["entries"][0]["revision_id"],
|
|
||||||
)
|
|
||||||
self.assertEqual(4, self.db.execute(
|
|
||||||
"SELECT COUNT(*) FROM sleep_diary_revisions"
|
|
||||||
).fetchone()[0])
|
|
||||||
self.assertEqual("ok", self.db.execute("PRAGMA integrity_check").fetchone()[0])
|
|
||||||
finally:
|
|
||||||
peer.close()
|
|
||||||
|
|
||||||
def test_v2_newer_remote_edit_wins_and_equal_time_rejects(self):
|
|
||||||
base = "slr_20000000-0000-4000-8000-000000000002"
|
|
||||||
local = entry("slr_40000000-0000-4000-8000-000000000004", base)
|
|
||||||
remote = entry("slr_50000000-0000-4000-8000-000000000005", base)
|
|
||||||
|
|
||||||
def v2(item):
|
|
||||||
document = self.document(item)
|
|
||||||
document["version"] = 2
|
|
||||||
document["entries"][0]["intakes"][0]["quantity"] = 1
|
|
||||||
return document
|
|
||||||
|
|
||||||
exchange.apply(self.db, v2(entry(base)))
|
|
||||||
local["updated_at"] = "2026-10-25T19:00:00+01:00"
|
|
||||||
remote["updated_at"] = "2026-10-25T20:00:00+01:00"
|
|
||||||
local["events"][0]["end_at"] = "2026-10-25T07:00:00+01:00"
|
|
||||||
remote["events"][0]["end_at"] = "2026-10-25T06:00:00+01:00"
|
|
||||||
exchange.apply(self.db, v2(local))
|
|
||||||
tied = entry("slr_60000000-0000-4000-8000-000000000006", base)
|
|
||||||
tied["updated_at"] = local["updated_at"]
|
|
||||||
with self.assertRaisesRegex(ValueError, "indeterminate edit order"):
|
|
||||||
exchange.apply(self.db, v2(tied))
|
|
||||||
self.assertEqual((1, 0), exchange.apply(self.db, v2(remote)))
|
|
||||||
self.assertEqual(remote["revision_id"], exchange.build(self.db)["entries"][0]["revision_id"])
|
|
||||||
self.assertEqual(remote["events"], exchange.build(self.db)["entries"][0]["events"])
|
|
||||||
self.assertEqual((0, 1), exchange.apply(self.db, v2(local)))
|
|
||||||
|
|
||||||
def test_draft_is_durable_but_not_exported_until_validated(self):
|
def test_draft_is_durable_but_not_exported_until_validated(self):
|
||||||
revision = "slr_20000000-0000-4000-8000-000000000002"
|
revision = "slr_20000000-0000-4000-8000-000000000002"
|
||||||
item = entry(revision)
|
item = entry(revision)
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,11 @@ class DeploymentToolsTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||||
self.assertEqual(inventory["product_version"], "0.1.7")
|
self.assertEqual(inventory["product_version"], "0.1.6")
|
||||||
self.assertEqual(inventory["desktop_schema"], 37)
|
self.assertEqual(inventory["desktop_schema"], 36)
|
||||||
self.assertEqual(inventory["android_schema"], 34)
|
self.assertEqual(inventory["android_schema"], 32)
|
||||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||||
self.assertIn("programs-v1", inventory["protocols"])
|
self.assertIn("programs-v1", inventory["protocols"])
|
||||||
self.assertIn("programs-v2", inventory["protocols"])
|
|
||||||
self.assertIn("program-executions-v1", inventory["protocols"])
|
self.assertIn("program-executions-v1", inventory["protocols"])
|
||||||
self.assertIn("sleep-diary-v2", inventory["protocols"])
|
self.assertIn("sleep-diary-v2", inventory["protocols"])
|
||||||
self.assertIn("cardio-sessions-v1", inventory["protocols"])
|
self.assertIn("cardio-sessions-v1", inventory["protocols"])
|
||||||
|
|
@ -115,7 +114,7 @@ class DeploymentToolsTest(unittest.TestCase):
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
self.assertEqual(linked.returncode, 0, linked.stderr)
|
self.assertEqual(linked.returncode, 0, linked.stderr)
|
||||||
self.assertIn("trainlog 0.1.7", linked.stdout)
|
self.assertIn("trainlog 0.1.6", linked.stdout)
|
||||||
imported = subprocess.run(
|
imported = subprocess.run(
|
||||||
[
|
[
|
||||||
"python3",
|
"python3",
|
||||||
|
|
|
||||||
|
|
@ -128,25 +128,21 @@ class SyncErrorClassificationTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
|
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
|
||||||
operation_budget = sync_peer_worker.MTP_OPERATION_TIMEOUT_SECONDS
|
timeout = subprocess.TimeoutExpired(["adapter", "push"], 30)
|
||||||
timeout = subprocess.TimeoutExpired(
|
|
||||||
["adapter", "push"], operation_budget,
|
|
||||||
)
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
transport = Path(directory) / "transport"
|
transport = Path(directory) / "transport"
|
||||||
transport.mkdir()
|
transport.mkdir()
|
||||||
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
|
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
|
||||||
with self.assertRaisesRegex(
|
with self.assertRaisesRegex(
|
||||||
RuntimeError,
|
RuntimeError,
|
||||||
rf"^transport_timeout: MTP push did not finish within "
|
r"^transport_timeout: MTP push did not finish within 30 seconds$",
|
||||||
rf"{operation_budget:g} seconds$",
|
|
||||||
) as caught:
|
) as caught:
|
||||||
sync_peer_worker.run_adapter(
|
sync_peer_worker.run_adapter(
|
||||||
Path("/adapter"),
|
Path("/adapter"),
|
||||||
"push",
|
"push",
|
||||||
"peer_00000000-0000-4000-8000-000000000001",
|
"peer_00000000-0000-4000-8000-000000000001",
|
||||||
transport,
|
transport,
|
||||||
sync_peer_worker.time.monotonic() + operation_budget + 10,
|
sync_peer_worker.time.monotonic() + 60,
|
||||||
)
|
)
|
||||||
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")
|
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")
|
||||||
|
|
||||||
|
|
@ -163,12 +159,6 @@ class SyncErrorClassificationTest(unittest.TestCase):
|
||||||
sync_orchestrator.failure_code(RuntimeError("revision conflict")),
|
sync_orchestrator.failure_code(RuntimeError("revision conflict")),
|
||||||
"data_conflict",
|
"data_conflict",
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
|
||||||
sync_orchestrator.failure_code(RuntimeError(
|
|
||||||
"desktop rejected Android generation: concurrent sleep diary revision"
|
|
||||||
)),
|
|
||||||
"data_conflict",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self):
|
def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|
|
||||||
|
|
@ -71,58 +71,6 @@ class GenerationTest(unittest.TestCase):
|
||||||
with closing(sqlite3.connect(destination)) as reopened:
|
with closing(sqlite3.connect(destination)) as reopened:
|
||||||
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
|
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
|
||||||
|
|
||||||
def test_rescheduled_program_requires_v2_capability_before_publication(self):
|
|
||||||
with closing(sqlite3.connect(self.database)) as db:
|
|
||||||
db.executescript("""
|
|
||||||
CREATE TABLE program_session_planning(
|
|
||||||
program_session_id TEXT PRIMARY KEY,current_for TEXT,state TEXT);
|
|
||||||
CREATE TABLE programs(program_id TEXT PRIMARY KEY,deleted_at TEXT);
|
|
||||||
CREATE TABLE program_sessions(
|
|
||||||
program_session_id TEXT PRIMARY KEY,program_id TEXT);
|
|
||||||
INSERT INTO programs VALUES('pg_fixture',NULL);
|
|
||||||
INSERT INTO program_sessions VALUES('pgs_fixture','pg_fixture');
|
|
||||||
INSERT INTO program_session_planning VALUES('pgs_fixture','2026-10-01','active');
|
|
||||||
PRAGMA user_version=37;
|
|
||||||
""")
|
|
||||||
db.commit()
|
|
||||||
artifacts = (
|
|
||||||
("history", "trainlog-mobile-export", 4, "history.json", True,
|
|
||||||
"controlled.py", ()),
|
|
||||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
|
||||||
"export_programs.py", ()),
|
|
||||||
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
|
|
||||||
"export_programs_v2.py", ()),
|
|
||||||
)
|
|
||||||
|
|
||||||
def exporter(tool, _extra, output, _snapshot):
|
|
||||||
if tool == "controlled.py":
|
|
||||||
output.write_text(json.dumps({"format": "trainlog-mobile-export", "version": 4}))
|
|
||||||
return
|
|
||||||
output.write_text(json.dumps({"format": "trainlog-programs",
|
|
||||||
"version": 2 if tool.endswith("_v2.py") else 1,
|
|
||||||
"programs": [], "deletions": []}))
|
|
||||||
|
|
||||||
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
|
|
||||||
mock.patch.object(generation, "run_export", exporter):
|
|
||||||
with self.assertRaisesRegex(generation.GenerationError, "Programs V2 capability"):
|
|
||||||
generation.capture_desktop(self.database, self.root / "legacy", PEER_B, RUN, GEN)
|
|
||||||
self.assertFalse((self.root / "legacy" / "staging" / GEN).exists())
|
|
||||||
legacy_stage, legacy_manifest, _ = generation.capture_desktop(
|
|
||||||
self.database, self.root / "legacy-domain-only", PEER_B, RUN, GEN,
|
|
||||||
set(), True,
|
|
||||||
)
|
|
||||||
self.assertFalse((legacy_stage / "programs-v1.json").exists())
|
|
||||||
self.assertEqual(["history"], [a["logical_name"] for a in legacy_manifest["artifacts"]])
|
|
||||||
stage, manifest, _ = generation.capture_desktop(
|
|
||||||
self.database, self.root / "modern", PEER_B, RUN,
|
|
||||||
"gen_55555555-5555-4555-8555-555555555555",
|
|
||||||
{"programs-v2"},
|
|
||||||
)
|
|
||||||
self.assertTrue((stage / "programs-v2.json").is_file())
|
|
||||||
self.assertFalse((stage / "programs-v1.json").exists())
|
|
||||||
self.assertEqual(["history", "programs-v2"],
|
|
||||||
[a["logical_name"] for a in manifest["artifacts"]])
|
|
||||||
|
|
||||||
def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
|
def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
|
||||||
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||||
|
|
|
||||||
|
|
@ -26,19 +26,6 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
|
|
||||||
def test_rejected_android_generation_retains_consumer_diagnostic(self):
|
|
||||||
error = worker.rejected_android_generation_error(
|
|
||||||
{"diagnostic": "concurrent sleep diary revision"}
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
str(error),
|
|
||||||
"desktop rejected Android generation: concurrent sleep diary revision",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
str(worker.rejected_android_generation_error({"diagnostic": ""})),
|
|
||||||
"desktop rejected Android generation",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_drive_adapter_uses_remaining_conversation_deadline(self):
|
def test_drive_adapter_uses_remaining_conversation_deadline(self):
|
||||||
completed = subprocess.CompletedProcess([], 0, b"", b"")
|
completed = subprocess.CompletedProcess([], 0, b"", b"")
|
||||||
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
||||||
|
|
@ -58,8 +45,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
||||||
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
||||||
worker.run_adapter(
|
worker.run_adapter(
|
||||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||||
self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0,
|
self.transport, 100.0, clock=lambda: 50.0,
|
||||||
clock=lambda: 50.0,
|
|
||||||
)
|
)
|
||||||
self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"])
|
self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"])
|
||||||
|
|
||||||
|
|
@ -113,8 +99,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
|
with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
|
||||||
worker.run_adapter(
|
worker.run_adapter(
|
||||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||||
self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0,
|
self.transport, 100.0, clock=lambda: 50.0,
|
||||||
clock=lambda: 50.0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_delayed_correlated_generation_survives_multiple_polls(self):
|
def test_delayed_correlated_generation_survives_multiple_polls(self):
|
||||||
|
|
|
||||||
|
|
@ -126,73 +126,6 @@ class SyncdRoutingTest(unittest.TestCase):
|
||||||
self.assertEqual(1, len(commands))
|
self.assertEqual(1, len(commands))
|
||||||
self.assertEqual("sy_old", json.loads(state.read_text())["run_id"])
|
self.assertEqual("sy_old", json.loads(state.read_text())["run_id"])
|
||||||
|
|
||||||
def test_transient_terminal_request_remains_eligible_after_reconnect(self):
|
|
||||||
terminal_states = (
|
|
||||||
("failed", "device_unavailable"),
|
|
||||||
("failed", "sync_in_progress"),
|
|
||||||
("failed", "transport_timeout"),
|
|
||||||
("interrupted", "interrupted"),
|
|
||||||
)
|
|
||||||
for phase, error_code in terminal_states:
|
|
||||||
with (
|
|
||||||
self.subTest(phase=phase, error_code=error_code),
|
|
||||||
tempfile.TemporaryDirectory() as directory,
|
|
||||||
):
|
|
||||||
root = Path(directory)
|
|
||||||
request = "sr_22222222-2222-4222-8222-222222222222"
|
|
||||||
state = root / "trainlog.db.sync-run.json"
|
|
||||||
state.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"phase": phase,
|
|
||||||
"result": phase,
|
|
||||||
"error_code": error_code,
|
|
||||||
"request_id": request,
|
|
||||||
"seen_request_ids": [request],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._write_request(
|
|
||||||
root / syncd.FULL_GENERATION_REQUEST_NAME,
|
|
||||||
request,
|
|
||||||
"2026-09-25T07:49:01Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
_, seen = syncd.load_request_state(state)
|
|
||||||
|
|
||||||
self.assertNotIn(request, seen)
|
|
||||||
self.assertEqual(
|
|
||||||
(request, "full_generation", []),
|
|
||||||
syncd.select_request(root, seen),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_non_transient_terminal_request_remains_consumed(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
root = Path(directory)
|
|
||||||
request = "sr_22222222-2222-4222-8222-222222222222"
|
|
||||||
state = root / "trainlog.db.sync-run.json"
|
|
||||||
state.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"phase": "failed",
|
|
||||||
"result": "failed",
|
|
||||||
"error_code": "data_conflict",
|
|
||||||
"request_id": request,
|
|
||||||
"seen_request_ids": [request],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._write_request(
|
|
||||||
root / syncd.FULL_GENERATION_REQUEST_NAME,
|
|
||||||
request,
|
|
||||||
"2026-09-25T07:49:01Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
_, seen = syncd.load_request_state(state)
|
|
||||||
|
|
||||||
self.assertIn(request, seen)
|
|
||||||
self.assertIsNone(syncd.select_request(root, seen))
|
|
||||||
|
|
||||||
def test_daemon_skips_mtp_probe_while_transport_lock_is_busy(self):
|
def test_daemon_skips_mtp_probe_while_transport_lock_is_busy(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|
|
||||||
|
|
@ -271,8 +271,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36, 37):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36):
|
||||||
fail("desktop schema v33-v37 required")
|
fail("desktop schema v33-v36 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -395,8 +395,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36, 37):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36):
|
||||||
fail("desktop schema v34-v37 required")
|
fail("desktop schema v34-v36 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||||
raise CausalError(f"desktop schema v20-v37 required, found v{version}")
|
raise CausalError(f"desktop schema v20-v36 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
||||||
|
|
||||||
def require_schema(connection):
|
def require_schema(connection):
|
||||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||||
raise LifecycleError(f"desktop schema v19-v37 required, found v{version}")
|
raise LifecycleError(f"desktop schema v19-v36 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def import_document(connection, document, own_transaction=True):
|
def import_document(connection, document, own_transaction=True):
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
||||||
from trainlog_sqlite import configure_connection
|
from trainlog_sqlite import configure_connection
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||||
MEASUREMENT_FIELDS = (
|
MEASUREMENT_FIELDS = (
|
||||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||||
|
|
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
|
||||||
"""Build the raw export using only explicitly ordered SELECT statements."""
|
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||||
raise ValueError("schema desktop v15-v37 requis")
|
raise ValueError("schema desktop v15-v36 requis")
|
||||||
|
|
||||||
root = {
|
root = {
|
||||||
"format": "TRAINLOG_AI_EXPORT",
|
"format": "TRAINLOG_AI_EXPORT",
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
||||||
def ensure_publication_state(connection):
|
def ensure_publication_state(connection):
|
||||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||||
owned_transaction = not connection.in_transaction
|
owned_transaction = not connection.in_transaction
|
||||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v18-v37 requis")
|
raise ValueError("schema desktop v18-v36 requis")
|
||||||
columns = {row[1] for row in connection.execute(
|
columns = {row[1] for row in connection.execute(
|
||||||
"PRAGMA table_info(ai_session_drafts)")}
|
"PRAGMA table_info(ai_session_drafts)")}
|
||||||
if "published_at" not in columns:
|
if "published_at" not in columns:
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(8, 38))
|
supported_versions = tuple(range(8, 37))
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v8 à v37 requis")
|
raise ValueError("schema desktop v8 à v36 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(8, 38))
|
supported_versions = tuple(range(8, 37))
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v8 à v37 requis")
|
raise ValueError("schema desktop v8 à v36 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal equipment protection requires the staged artifact")
|
raise ValueError("causal equipment protection requires the staged artifact")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ def main():
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||||
raise ValueError("schema desktop v12-v37 requis")
|
raise ValueError("schema desktop v12-v36 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(11, 38))
|
supported_versions = tuple(range(11, 37))
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v11-v37 requis")
|
raise ValueError("schema desktop v11-v36 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||||
exercises = []
|
exercises = []
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||||
rows = con.execute("""
|
rows = con.execute("""
|
||||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ def main():
|
||||||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||||
if version not in range(8, 38):
|
if version not in range(8, 37):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||||
supported_versions = range(11, 38)
|
supported_versions = range(11, 37)
|
||||||
legacy_v2_schema = args.version == 2 and schema_version == 10
|
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
if schema_version not in supported_versions and not legacy_v2_schema:
|
||||||
raise ValueError("schema desktop v11-v37 requis (v10 accepté pour export V2 explicite)")
|
raise ValueError("schema desktop v11-v36 requis (v10 accepté pour export V2 explicite)")
|
||||||
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
||||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ def nullable(row, index):
|
||||||
|
|
||||||
def export_programs(database: Path) -> dict:
|
def export_programs(database: Path) -> dict:
|
||||||
with closing(connect_database(database)) as db:
|
with closing(connect_database(database)) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||||
raise ValueError("desktop schema v26 through v32 required")
|
raise ValueError("desktop schema v26 through v32 required")
|
||||||
programs = []
|
programs = []
|
||||||
for row in db.execute(
|
for row in db.execute(
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Export the versioned desktop-owned Program planning projection."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
from contextlib import closing
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from export_programs import export_programs
|
|
||||||
from trainlog_sqlite import connect_database
|
|
||||||
|
|
||||||
|
|
||||||
def export_programs_v2(database: Path) -> dict:
|
|
||||||
# WHY: V1's strict session fields are frozen. V2 carries absolute current
|
|
||||||
# dates and ceded-slot state without changing the imported source date.
|
|
||||||
value = export_programs(database)
|
|
||||||
with closing(connect_database(database)) as db:
|
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] != 37:
|
|
||||||
raise ValueError("desktop schema v37 required for Programs V2")
|
|
||||||
for program in value["programs"]:
|
|
||||||
sequence = db.execute(
|
|
||||||
"SELECT revision_sequence FROM program_revision_sequences WHERE program_id=?",
|
|
||||||
(program["program_id"],),
|
|
||||||
).fetchone()
|
|
||||||
program["revision_sequence"] = sequence[0] if sequence is not None else 0
|
|
||||||
planning = {
|
|
||||||
row[0]: (row[1], row[2])
|
|
||||||
for row in db.execute(
|
|
||||||
"SELECT p.program_session_id,p.current_for,p.state "
|
|
||||||
"FROM program_session_planning p JOIN program_sessions s "
|
|
||||||
"ON s.program_session_id=p.program_session_id WHERE s.program_id=?",
|
|
||||||
(program["program_id"],),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
for position, session in enumerate(program["sessions"]):
|
|
||||||
current, state = planning.get(
|
|
||||||
session["program_session_id"], (session["planned_for"], "active")
|
|
||||||
)
|
|
||||||
session["position"] = position
|
|
||||||
session["current_for"] = current
|
|
||||||
session["planning_state"] = state
|
|
||||||
value["version"] = 2
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("output", type=Path)
|
|
||||||
parser.add_argument("--database", type=Path, required=True)
|
|
||||||
args = parser.parse_args()
|
|
||||||
args.output.write_text(
|
|
||||||
json.dumps(export_programs_v2(args.database), ensure_ascii=False,
|
|
||||||
sort_keys=True, separators=(",", ":")),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
|
||||||
|
|
||||||
|
|
||||||
def build_export(connection: sqlite3.Connection) -> dict:
|
def build_export(connection: sqlite3.Connection) -> dict:
|
||||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("desktop schema v24, v25, v26 through v32 required")
|
raise ValueError("desktop schema v24, v25, v26 through v32 required")
|
||||||
deliveries = []
|
deliveries = []
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
|
||||||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||||
db=connect_database(args.database)
|
db=connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = range(15, 38)
|
supported_versions = range(15, 37)
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v15-v37 requis")
|
raise ValueError("schema desktop v15-v36 requis")
|
||||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,8 @@ def split_target(value: str) -> tuple[str, str]:
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in range(27, 38):
|
if version not in range(27, 37):
|
||||||
raise CorrectionError(f"heart-rate correction requires schema v27-v37, found v{version}")
|
raise CorrectionError(f"heart-rate correction requires schema v27-v36, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
|
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -282,8 +282,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
|
||||||
fail("desktop schema v30-v37 required")
|
fail("desktop schema v30-v36 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
|
||||||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||||
any failure leaves no draft, child, or import-identity row behind.
|
any failure leaves no draft, child, or import-identity row behind.
|
||||||
"""
|
"""
|
||||||
supported_versions = range(18, 38)
|
supported_versions = range(18, 37)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ImportFailure("schema desktop v18-v37 requis")
|
raise ImportFailure("schema desktop v18-v36 requis")
|
||||||
draft, entries = validate(payload, connection)
|
draft, entries = validate(payload, connection)
|
||||||
digest = canonical_digest(payload)
|
digest = canonical_digest(payload)
|
||||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
|
|
||||||
|
|
@ -206,9 +206,9 @@ def main():
|
||||||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = range(8, 38)
|
supported_versions = range(8, 37)
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v8 à v37 requis")
|
fail("schema desktop v8 à v36 requis")
|
||||||
proof_path = args.mobile_export
|
proof_path = args.mobile_export
|
||||||
if proof_path is None:
|
if proof_path is None:
|
||||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,9 @@ def validate(payload, reserved):
|
||||||
|
|
||||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||||
"""Apply validated definitions without owning the surrounding transaction."""
|
"""Apply validated definitions without owning the surrounding transaction."""
|
||||||
supported_versions = range(8, 38)
|
supported_versions = range(8, 37)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v8 à v37 requis")
|
fail("schema desktop v8 à v36 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||||
fail("causal equipment protection requires the staged artifact")
|
fail("causal equipment protection requires the staged artifact")
|
||||||
imported = skipped = 0
|
imported = skipped = 0
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ def load(path):
|
||||||
|
|
||||||
def apply_aliases(con, aliases):
|
def apply_aliases(con, aliases):
|
||||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||||
supported_versions = range(12, 38)
|
supported_versions = range(12, 37)
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v12-v37 requis")
|
fail("schema desktop v12-v36 requis")
|
||||||
for source, canonical in aliases:
|
for source, canonical in aliases:
|
||||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||||
if target is None: fail(f"cible canonique absente: {canonical}")
|
if target is None: fail(f"cible canonique absente: {canonical}")
|
||||||
|
|
|
||||||
|
|
@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
|
||||||
|
|
||||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||||
"""Apply validated zone rows without owning transaction boundaries."""
|
"""Apply validated zone rows without owning transaction boundaries."""
|
||||||
supported_versions = range(11, 38)
|
supported_versions = range(11, 37)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ImportFailure("schema desktop v11-v37 requis")
|
raise ImportFailure("schema desktop v11-v36 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
|
||||||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||||
fail("schema desktop v17-v37 requis")
|
fail("schema desktop v17-v36 requis")
|
||||||
applied=equal=ancestor=pending=0;seen=set()
|
applied=equal=ancestor=pending=0;seen=set()
|
||||||
for item in root["exercises"]:
|
for item in root["exercises"]:
|
||||||
validate(item);eid=item["exercise_id"]
|
validate(item);eid=item["exercise_id"]
|
||||||
|
|
|
||||||
|
|
@ -847,9 +847,9 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# readable for legacy artifacts and are never made to fake that table.
|
||||||
if version not in range(5, 38):
|
if version not in range(5, 37):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"base desktop schema v5 à v37 attendue, version trouvée: {version}"
|
f"base desktop schema v5 à v36 attendue, version trouvée: {version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
|
||||||
require_supported_schema(connection)
|
require_supported_schema(connection)
|
||||||
|
|
||||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v37")
|
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36")
|
||||||
if schema_version >= 20:
|
if schema_version >= 20:
|
||||||
protected = {(row[0], row[1]) for row in connection.execute(
|
protected = {(row[0], row[1]) for row in connection.execute(
|
||||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
||||||
def apply_feedback(db,root,complete_causal_envelope=False):
|
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
db.execute("PRAGMA foreign_keys=ON")
|
||||||
supported_versions = range(15, 38)
|
supported_versions = range(15, 37)
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v15-v37 requis")
|
fail("schema desktop v15-v36 requis")
|
||||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
||||||
seen=set()
|
seen=set()
|
||||||
for index,item in enumerate(root["exercise_feedback"]):
|
for index,item in enumerate(root["exercise_feedback"]):
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ RUNTIME_TOOLS = [
|
||||||
"post_sync_ai_drive.py",
|
"post_sync_ai_drive.py",
|
||||||
"export_session_preparations.py",
|
"export_session_preparations.py",
|
||||||
"export_programs.py",
|
"export_programs.py",
|
||||||
"export_programs_v2.py",
|
|
||||||
"program_execution_exchange.py",
|
"program_execution_exchange.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -82,14 +81,6 @@ def main() -> int:
|
||||||
raise RuntimeError("compiled generation MTP adapter is missing")
|
raise RuntimeError("compiled generation MTP adapter is missing")
|
||||||
if not sync_once.is_file():
|
if not sync_once.is_file():
|
||||||
raise RuntimeError("compiled legacy synchronization helper is missing")
|
raise RuntimeError("compiled legacy synchronization helper is missing")
|
||||||
# WHY: web=auto can build a native executable without any embedded UI.
|
|
||||||
# CONTRACT: a rollout bundle must contain the frontend it will serve.
|
|
||||||
build_options = json.loads(subprocess.check_output(
|
|
||||||
["meson", "introspect", "--buildoptions", str(args.build)], text=True
|
|
||||||
))
|
|
||||||
web = next((option["value"] for option in build_options if option["name"] == "web"), None)
|
|
||||||
if web != "enabled":
|
|
||||||
raise RuntimeError("candidate requires an explicitly embedded Web frontend")
|
|
||||||
(args.output / "bin").mkdir(parents=True)
|
(args.output / "bin").mkdir(parents=True)
|
||||||
(args.output / "libexec").mkdir(parents=True)
|
(args.output / "libexec").mkdir(parents=True)
|
||||||
(args.output / "tools").mkdir(parents=True)
|
(args.output / "tools").mkdir(parents=True)
|
||||||
|
|
@ -128,9 +119,9 @@ def main() -> int:
|
||||||
"source_commit": subprocess.check_output(
|
"source_commit": subprocess.check_output(
|
||||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||||
).strip(),
|
).strip(),
|
||||||
"product_version": "0.1.7",
|
"product_version": "0.1.6",
|
||||||
"desktop_schema": 37,
|
"desktop_schema": 36,
|
||||||
"android_schema": 34,
|
"android_schema": 32,
|
||||||
"protocols": [
|
"protocols": [
|
||||||
"mobile-export-v3",
|
"mobile-export-v3",
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
|
|
@ -144,7 +135,6 @@ def main() -> int:
|
||||||
"session-preparations-v2",
|
"session-preparations-v2",
|
||||||
"ai-session-drafts-v2",
|
"ai-session-drafts-v2",
|
||||||
"programs-v1",
|
"programs-v1",
|
||||||
"programs-v2",
|
|
||||||
"program-executions-v1",
|
"program-executions-v1",
|
||||||
"sleep-diary-v2",
|
"sleep-diary-v2",
|
||||||
"cardio-sessions-v1",
|
"cardio-sessions-v1",
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ def load(path: Path) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||||
fail("desktop schema v27 through v32 required")
|
fail("desktop schema v27 through v32 required")
|
||||||
added = 0
|
added = 0
|
||||||
advanced = 0
|
advanced = 0
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36, 37):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36):
|
||||||
fail("desktop schema v31-v37 required")
|
fail("desktop schema v31-v36 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -379,42 +378,6 @@ def revision_is_ancestor(db: sqlite3.Connection, parent_sql: str, owner_id: str,
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def concurrent_entry_winner(db: sqlite3.Connection, item: dict, local_revision: str,
|
|
||||||
wire_version: int) -> dict | None:
|
|
||||||
"""Choose a complete Sleep snapshot only when user-edit instants are ordered.
|
|
||||||
|
|
||||||
WHY: an import or ACK is not a new user edit. Revision creation times are
|
|
||||||
recorded by the editing clients, whereas generation times are transport
|
|
||||||
times. CONTRACT: only V2 sibling tips with the same entry envelope and no
|
|
||||||
deletion transition admit last-user-edit-wins. Equal or inconsistent
|
|
||||||
timestamps remain conflicts, never a peer-order tie break.
|
|
||||||
"""
|
|
||||||
if wire_version != 2:
|
|
||||||
fail("concurrent sleep diary revision")
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted,"
|
|
||||||
"r.created_at FROM sleep_diary_entries e JOIN sleep_diary_revisions r "
|
|
||||||
"ON r.revision_id=e.current_revision_id WHERE e.entry_id=? AND r.revision_id=?",
|
|
||||||
(item["entry_id"], local_revision),
|
|
||||||
).fetchone()
|
|
||||||
if (row is None or tuple(row[:3]) !=
|
|
||||||
(item["night_start_date"], item["night_end_date"], item["created_at"]) or
|
|
||||||
bool(row[4]) != item["deleted"] or row[3] != row[5]):
|
|
||||||
fail("concurrent sleep diary revision")
|
|
||||||
local_at = datetime.fromisoformat(timestamp(row[5]).replace("Z", "+00:00"))
|
|
||||||
remote_at = datetime.fromisoformat(timestamp(item["updated_at"]).replace("Z", "+00:00"))
|
|
||||||
if local_at == remote_at:
|
|
||||||
fail("concurrent sleep diary revision has indeterminate edit order")
|
|
||||||
if local_at < remote_at:
|
|
||||||
return None
|
|
||||||
current = db.execute(
|
|
||||||
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,"
|
|
||||||
"current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
|
||||||
(item["entry_id"],),
|
|
||||||
).fetchone()
|
|
||||||
return _persisted_entry(db, current)
|
|
||||||
|
|
||||||
|
|
||||||
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||||
validate(root)
|
validate(root)
|
||||||
wire_version = root["version"]
|
wire_version = root["version"]
|
||||||
|
|
@ -521,14 +484,8 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||||
unchanged += 1
|
unchanged += 1
|
||||||
continue
|
continue
|
||||||
remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:]
|
remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:]
|
||||||
winning_local = None
|
|
||||||
if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]:
|
if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]:
|
||||||
winning_local = concurrent_entry_winner(db, item, local[0], wire_version)
|
fail("concurrent sleep diary revision")
|
||||||
if known_owner is not None and winning_local is not None:
|
|
||||||
# A previously retained losing branch cannot cause a fresh
|
|
||||||
# reconciliation revision on every immutable replay.
|
|
||||||
unchanged += 1
|
|
||||||
continue
|
|
||||||
# WHY: one-tap Sleep creates several local revisions before its first
|
# WHY: one-tap Sleep creates several local revisions before its first
|
||||||
# synchronization. The current snapshot carries their bounded lineage
|
# synchronization. The current snapshot carries their bounded lineage
|
||||||
# by identity, so a new desktop must be able to seed the tip without
|
# by identity, so a new desktop must be able to seed the tip without
|
||||||
|
|
@ -548,30 +505,17 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||||
"current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
|
"current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
|
||||||
(item["night_start_date"], item["night_end_date"], item["updated_at"],
|
(item["night_start_date"], item["night_end_date"], item["updated_at"],
|
||||||
item["revision_id"], int(item["deleted"]), item["entry_id"], local[0]))
|
item["revision_id"], int(item["deleted"]), item["entry_id"], local[0]))
|
||||||
if known_owner is None:
|
db.execute("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
|
||||||
db.execute(
|
(item["revision_id"], item["entry_id"], item["parent_revision_id"], item["updated_at"],
|
||||||
"INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,"
|
item["sleep_quality"], item["wake_quality"], item["day_form"], item["treatment_and_notes"]))
|
||||||
"created_at,sleep_quality,wake_quality,day_form,treatment_and_notes) "
|
db.executemany("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
|
||||||
"VALUES(?,?,?,?,?,?,?,?)",
|
[(item["revision_id"], event["event_id"], event["type"], event["start_at"], event["end_at"])
|
||||||
(item["revision_id"], item["entry_id"], item["parent_revision_id"],
|
for event in item["events"]])
|
||||||
item["updated_at"], item["sleep_quality"], item["wake_quality"],
|
db.executemany("INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||||
item["day_form"], item["treatment_and_notes"]),
|
[(item["revision_id"], intake["intake_id"], intake["medication_id"],
|
||||||
)
|
intake["medication_name"], intake["taken_at"], intake["dose_value"],
|
||||||
db.executemany(
|
intake["dose_unit"], intake.get("quantity", 1), intake["note"], intake["created_at"])
|
||||||
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) "
|
for intake in item["intakes"]])
|
||||||
"VALUES(?,?,?,?,?)",
|
|
||||||
[(item["revision_id"], event["event_id"], event["type"],
|
|
||||||
event["start_at"], event["end_at"]) for event in item["events"]],
|
|
||||||
)
|
|
||||||
db.executemany(
|
|
||||||
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,"
|
|
||||||
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) "
|
|
||||||
"VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
[(item["revision_id"], intake["intake_id"], intake["medication_id"],
|
|
||||||
intake["medication_name"], intake["taken_at"], intake["dose_value"],
|
|
||||||
intake["dose_unit"], intake.get("quantity", 1), intake["note"],
|
|
||||||
intake["created_at"]) for intake in item["intakes"]],
|
|
||||||
)
|
|
||||||
# An imported revision has already crossed the synchronization
|
# An imported revision has already crossed the synchronization
|
||||||
# boundary; recording both markers prevents it appearing as a draft.
|
# boundary; recording both markers prevents it appearing as a draft.
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -583,45 +527,6 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||||
(item["entry_id"], item["revision_id"], root["generated_at"],
|
(item["entry_id"], item["revision_id"], root["generated_at"],
|
||||||
item["revision_id"], root["generated_at"]),
|
item["revision_id"], root["generated_at"]),
|
||||||
)
|
)
|
||||||
if winning_local is not None:
|
|
||||||
successor = "slr_" + str(uuid.uuid4())
|
|
||||||
# INVARIANT: the successor's edit timestamp remains the winning
|
|
||||||
# user edit, not the later technical reconciliation time. Its
|
|
||||||
# complete payload is copied exactly; the losing branch stays
|
|
||||||
# immutable and addressable by its original revision identity.
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,"
|
|
||||||
"created_at,sleep_quality,wake_quality,day_form,treatment_and_notes) "
|
|
||||||
"VALUES(?,?,?,?,?,?,?,?)",
|
|
||||||
(successor, item["entry_id"], item["revision_id"],
|
|
||||||
winning_local["updated_at"], winning_local["sleep_quality"],
|
|
||||||
winning_local["wake_quality"], winning_local["day_form"],
|
|
||||||
winning_local["treatment_and_notes"]),
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) "
|
|
||||||
"SELECT ?,event_id,event_type,start_at,end_at "
|
|
||||||
"FROM sleep_diary_events WHERE revision_id=?",
|
|
||||||
(successor, winning_local["revision_id"]),
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,"
|
|
||||||
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) "
|
|
||||||
"SELECT ?,intake_id,medication_id,"
|
|
||||||
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at "
|
|
||||||
"FROM sleep_medication_intakes WHERE revision_id=?",
|
|
||||||
(successor, winning_local["revision_id"]),
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
"UPDATE sleep_diary_entries SET updated_at=?,current_revision_id=? "
|
|
||||||
"WHERE entry_id=? AND current_revision_id=?",
|
|
||||||
(winning_local["updated_at"], successor, item["entry_id"], item["revision_id"]),
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
"UPDATE sleep_diary_publication_state SET validated_revision_id=?,"
|
|
||||||
"validated_at=? WHERE entry_id=?",
|
|
||||||
(successor, root["generated_at"], item["entry_id"]),
|
|
||||||
)
|
|
||||||
applied += 1
|
applied += 1
|
||||||
return applied, unchanged
|
return applied, unchanged
|
||||||
|
|
||||||
|
|
@ -637,8 +542,8 @@ def main() -> None:
|
||||||
import_command.add_argument("--database", required=True, type=Path)
|
import_command.add_argument("--database", required=True, type=Path)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
|
||||||
fail("desktop schema v30-v37 required")
|
fail("desktop schema v30-v36 required")
|
||||||
if args.command == "export":
|
if args.command == "export":
|
||||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||||
separators=(",", ":")), encoding="utf-8")
|
separators=(",", ":")), encoding="utf-8")
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,7 @@ MAX_GENERATION = 256 * 1024 * 1024
|
||||||
MAX_NAME = 64
|
MAX_NAME = 64
|
||||||
MAX_PATH = 240
|
MAX_PATH = 240
|
||||||
MAX_DIAGNOSTIC = 1024
|
MAX_DIAGNOSTIC = 1024
|
||||||
# WHY: interrupted MTP publications can leave immutable generations awaiting
|
MAX_RETAINED_PER_PEER = 8
|
||||||
# an ACK alongside the two acknowledged lineage members kept for recovery.
|
|
||||||
# CONTRACT: admission remains bounded and never erases unresolved generations.
|
|
||||||
# INVARIANT: captured, published and waiting generations still consume a slot.
|
|
||||||
MAX_RETAINED_PER_PEER = 16
|
|
||||||
ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||||
HEX = re.compile(r"^[0-9a-f]{64}$")
|
HEX = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
@ -86,8 +82,6 @@ ARTIFACTS = (
|
||||||
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
||||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||||
"export_programs.py", ()),
|
"export_programs.py", ()),
|
||||||
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
|
|
||||||
"export_programs_v2.py", ()),
|
|
||||||
("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False,
|
("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False,
|
||||||
"sleep_diary_exchange.py", ("export",)),
|
"sleep_diary_exchange.py", ("export",)),
|
||||||
)
|
)
|
||||||
|
|
@ -227,9 +221,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
||||||
|
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise GenerationError("desktop schema v24 through v37 required")
|
raise GenerationError("desktop schema v24 through v36 required")
|
||||||
|
|
||||||
|
|
||||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||||
|
|
@ -372,9 +366,7 @@ def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path)
|
||||||
|
|
||||||
|
|
||||||
def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
||||||
run_id: str | None = None, generation_id: str | None = None,
|
run_id: str | None = None, generation_id: str | None = None):
|
||||||
peer_capabilities: set[str] | None = None,
|
|
||||||
allow_program_skip: bool = False):
|
|
||||||
if not valid_id(consumer, "peer"):
|
if not valid_id(consumer, "peer"):
|
||||||
raise GenerationError("invalid expected consumer")
|
raise GenerationError("invalid expected consumer")
|
||||||
generation = generation_id or "gen_" + str(uuid.uuid4())
|
generation = generation_id or "gen_" + str(uuid.uuid4())
|
||||||
|
|
@ -413,35 +405,9 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
||||||
producer = peer_identity(source, "desktop")
|
producer = peer_identity(source, "desktop")
|
||||||
source.commit()
|
source.commit()
|
||||||
source.backup(target)
|
source.backup(target)
|
||||||
with closing(connect_database(snapshot)) as planning_db:
|
|
||||||
has_planning_table = planning_db.execute(
|
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
|
||||||
"AND name='program_session_planning'"
|
|
||||||
).fetchone() is not None
|
|
||||||
has_reschedule = has_planning_table and planning_db.execute(
|
|
||||||
"SELECT 1 FROM program_session_planning plan "
|
|
||||||
"JOIN program_sessions session ON session.program_session_id=plan.program_session_id "
|
|
||||||
"JOIN programs program ON program.program_id=session.program_id "
|
|
||||||
"WHERE program.deleted_at IS NULL LIMIT 1"
|
|
||||||
).fetchone() is not None
|
|
||||||
supports_v2 = peer_capabilities is not None and "programs-v2" in peer_capabilities
|
|
||||||
# CONTRACT: one generation contains exactly one Program revision per
|
|
||||||
# identity. A V1-only peer cannot preserve a changed current schedule.
|
|
||||||
# INVARIANT: reject before publication rather than claiming convergence.
|
|
||||||
skip_programs = has_reschedule and not supports_v2
|
|
||||||
if skip_programs and not allow_program_skip:
|
|
||||||
raise GenerationError("Programs V2 capability required for rescheduled programs")
|
|
||||||
if supports_v2 and not has_planning_table:
|
|
||||||
raise GenerationError("Programs V2 requires desktop schema v37")
|
|
||||||
descriptors = []
|
descriptors = []
|
||||||
total = 0
|
total = 0
|
||||||
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
|
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
|
||||||
if skip_programs and logical in ("programs-v1", "programs-v2"):
|
|
||||||
continue
|
|
||||||
if logical == "programs-v1" and supports_v2:
|
|
||||||
continue
|
|
||||||
if logical == "programs-v2" and not supports_v2:
|
|
||||||
continue
|
|
||||||
output = stage / filename
|
output = stage / filename
|
||||||
run_export(tool, extra, output, snapshot)
|
run_export(tool, extra, output, snapshot)
|
||||||
size, checksum = digest_file(output)
|
size, checksum = digest_file(output)
|
||||||
|
|
@ -555,7 +521,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
||||||
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
|
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
|
||||||
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
|
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
|
||||||
(operation["operation_id"], generation, 0 if existed else 1))
|
(operation["operation_id"], generation, 0 if existed else 1))
|
||||||
programs_path = stage / ("programs-v2.json" if supports_v2 else "programs-v1.json")
|
programs_path = stage / "programs-v1.json"
|
||||||
if programs_path.is_file():
|
if programs_path.is_file():
|
||||||
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
|
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
|
||||||
for deletion in captured_programs["deletions"]:
|
for deletion in captured_programs["deletions"]:
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,7 @@ def failure_code(error: BaseException) -> str:
|
||||||
return "device_unavailable"
|
return "device_unavailable"
|
||||||
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
|
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
|
||||||
return "capacity_exhausted"
|
return "capacity_exhausted"
|
||||||
if ("conflict" in diagnostic or "wrong consumer" in diagnostic or
|
if "conflict" in diagnostic or "wrong consumer" in diagnostic:
|
||||||
"concurrent sleep diary revision" in diagnostic):
|
|
||||||
return "data_conflict"
|
return "data_conflict"
|
||||||
return "internal_error"
|
return "internal_error"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,7 @@ CAPS = {
|
||||||
|
|
||||||
ACK_RECOVERY_CAP = "generation-ack-recovery-v1"
|
ACK_RECOVERY_CAP = "generation-ack-recovery-v1"
|
||||||
|
|
||||||
# WHY: a verified personal phone can need more than 30 seconds to publish an
|
MTP_OPERATION_TIMEOUT_SECONDS = 30.0
|
||||||
# immutable Programs generation over MTP. CONTRACT: this bounds each adapter
|
|
||||||
# call independently of the configured overall conversation deadline.
|
|
||||||
# INVARIANT: an adapter overrun remains an ambiguous transport failure.
|
|
||||||
MTP_OPERATION_TIMEOUT_SECONDS = 90.0
|
|
||||||
MTP_POLL_INTERVAL_SECONDS = 3.0
|
MTP_POLL_INTERVAL_SECONDS = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,15 +72,6 @@ def emit(run_id: str, phase: str, **values: object) -> None:
|
||||||
print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True)
|
print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True)
|
||||||
|
|
||||||
|
|
||||||
def rejected_android_generation_error(ack: dict) -> RuntimeError:
|
|
||||||
"""Keep the durable peer rejection visible without changing its ACK."""
|
|
||||||
# WHY: the browser cannot classify a known causal conflict when the worker
|
|
||||||
# drops the consumer's bounded diagnostic at this process boundary.
|
|
||||||
diagnostic = ack.get("diagnostic", "")
|
|
||||||
detail = f": {diagnostic}" if diagnostic else ""
|
|
||||||
return RuntimeError(f"desktop rejected Android generation{detail}")
|
|
||||||
|
|
||||||
|
|
||||||
def bounded(path: Path, limit: int = 65536) -> bytes:
|
def bounded(path: Path, limit: int = 65536) -> bytes:
|
||||||
with path.open("rb") as stream:
|
with path.open("rb") as stream:
|
||||||
raw = stream.read(limit + 1)
|
raw = stream.read(limit + 1)
|
||||||
|
|
@ -818,7 +805,7 @@ def main() -> int:
|
||||||
deadline,
|
deadline,
|
||||||
)
|
)
|
||||||
if ack["result"] != "consumed":
|
if ack["result"] != "consumed":
|
||||||
raise rejected_android_generation_error(ack)
|
raise RuntimeError("desktop rejected Android generation")
|
||||||
emit(
|
emit(
|
||||||
args.run_id,
|
args.run_id,
|
||||||
"local_import_committed",
|
"local_import_committed",
|
||||||
|
|
@ -831,13 +818,8 @@ def main() -> int:
|
||||||
# diagnostics to the separately bounded stderr channel.
|
# diagnostics to the separately bounded stderr channel.
|
||||||
with redirect_stdout(sys.stderr):
|
with redirect_stdout(sys.stderr):
|
||||||
stage, manifest, digest = generation.capture_desktop(
|
stage, manifest, digest = generation.capture_desktop(
|
||||||
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id,
|
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id
|
||||||
set(peer["capabilities"]), True,
|
|
||||||
)
|
)
|
||||||
program_domain_incompatible = not any(
|
|
||||||
artifact["logical_name"] in ("programs-v1", "programs-v2")
|
|
||||||
for artifact in manifest["artifacts"]
|
|
||||||
)
|
|
||||||
del stage
|
del stage
|
||||||
published = generation.publish(
|
published = generation.publish(
|
||||||
args.database, outgoing_id, args.transport_root / "desktop-objects"
|
args.database, outgoing_id, args.transport_root / "desktop-objects"
|
||||||
|
|
@ -935,14 +917,6 @@ def main() -> int:
|
||||||
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
|
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
|
||||||
raise RuntimeError("generation evidence did not corroborate completion")
|
raise RuntimeError("generation evidence did not corroborate completion")
|
||||||
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
|
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
|
||||||
if program_domain_incompatible:
|
|
||||||
# WHY: the old peer cannot retain the new absolute schedule, but its
|
|
||||||
# unrelated domains can still converge through the same generation.
|
|
||||||
# CONTRACT: never report whole-run success while its Program view is stale.
|
|
||||||
raise RuntimeError(
|
|
||||||
"Programs V2 capability required: other domains synchronized, "
|
|
||||||
"Program planning not published to this peer"
|
|
||||||
)
|
|
||||||
ai_post_sync = publish_ai_export_to_android(args, deadline)
|
ai_post_sync = publish_ai_export_to_android(args, deadline)
|
||||||
report = {
|
report = {
|
||||||
"format": "trainlog-sync-worker-report",
|
"format": "trainlog-sync-worker-report",
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,6 @@ REQUEST_NAME = "trainlog-sync-request-v1.json"
|
||||||
FULL_GENERATION_REQUEST_NAME = "trainlog-sync-full-generation-request-v1.json"
|
FULL_GENERATION_REQUEST_NAME = "trainlog-sync-full-generation-request-v1.json"
|
||||||
SEEN_REQUEST_LIMIT = 64
|
SEEN_REQUEST_LIMIT = 64
|
||||||
REQUEST_ID = re.compile(r"sr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
REQUEST_ID = re.compile(r"sr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
||||||
RETRYABLE_REQUEST_ERROR_CODES = {
|
|
||||||
"device_unavailable",
|
|
||||||
"sync_in_progress",
|
|
||||||
"transport_timeout",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def request_stop(
|
def request_stop(
|
||||||
|
|
@ -137,26 +132,8 @@ def load_request_state(path: Path) -> tuple[dict, list[str]]:
|
||||||
seen = [item for item in state.get("seen_request_ids", [])
|
seen = [item for item in state.get("seen_request_ids", [])
|
||||||
if isinstance(item, str) and REQUEST_ID.fullmatch(item)]
|
if isinstance(item, str) and REQUEST_ID.fullmatch(item)]
|
||||||
historical = state.get("request_id")
|
historical = state.get("request_id")
|
||||||
retryable = (
|
if isinstance(historical, str) and REQUEST_ID.fullmatch(historical) and historical not in seen:
|
||||||
state.get("phase") == "interrupted"
|
seen.append(historical)
|
||||||
or (
|
|
||||||
state.get("phase") == "failed"
|
|
||||||
and state.get("error_code") in RETRYABLE_REQUEST_ERROR_CODES
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if isinstance(historical, str) and REQUEST_ID.fullmatch(historical):
|
|
||||||
# WHY: the daemon records admission before starting the bounded
|
|
||||||
# generation conversation. A transient disconnect can therefore leave
|
|
||||||
# the only Android arrival request in the seen ledger even though no
|
|
||||||
# terminal exchange was possible. CONTRACT: retry only explicit
|
|
||||||
# transient terminal states; completed and business-rejected requests
|
|
||||||
# remain exactly-once admissions. INVARIANT: retry keeps the stable
|
|
||||||
# request_id while the generation engine creates a fresh, idempotent
|
|
||||||
# run_id and revalidates all durable generation/ACK evidence.
|
|
||||||
if retryable:
|
|
||||||
seen = [item for item in seen if item != historical]
|
|
||||||
elif historical not in seen:
|
|
||||||
seen.append(historical)
|
|
||||||
return state, seen[-SEEN_REQUEST_LIMIT:]
|
return state, seen[-SEEN_REQUEST_LIMIT:]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -166,8 +143,7 @@ def select_request(root: Path, seen: list[str]) -> tuple[str, str, list[str]] |
|
||||||
WHY: modern Android may publish the same intent on full-generation and
|
WHY: modern Android may publish the same intent on full-generation and
|
||||||
legacy channels, while stale legacy files remain durable on MTP. CONTRACT:
|
legacy channels, while stale legacy files remain durable on MTP. CONTRACT:
|
||||||
full-generation wins and an older alternate intent is retired. INVARIANT:
|
full-generation wins and an older alternate intent is retired. INVARIANT:
|
||||||
one request_id owns at most one active daemon conversation; only an
|
one request_id starts at most one daemon conversation.
|
||||||
explicitly transient terminal result permits its later readmission.
|
|
||||||
"""
|
"""
|
||||||
full = request(root / FULL_GENERATION_REQUEST_NAME)
|
full = request(root / FULL_GENERATION_REQUEST_NAME)
|
||||||
legacy = request(root / REQUEST_NAME)
|
legacy = request(root / REQUEST_NAME)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 37
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 36
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,17 +63,4 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
||||||
char **output_json,
|
char **output_json,
|
||||||
size_t *output_size);
|
size_t *output_size);
|
||||||
|
|
||||||
/* WHY: original Program dates are immutable source evidence. CONTRACT: the
|
|
||||||
* preview is read only; commit requires its exact fact/choice fingerprint,
|
|
||||||
* current Program revision and a stable operation ID. INVARIANT: one commit
|
|
||||||
* changes absolute planning dates in one transaction, never history rows. */
|
|
||||||
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
|
|
||||||
const char *program_id,
|
|
||||||
const char *operation_id,
|
|
||||||
const char *body,
|
|
||||||
size_t body_size,
|
|
||||||
bool commit,
|
|
||||||
char **output_json,
|
|
||||||
size_t *output_size);
|
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -41,19 +41,6 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
|
||||||
char **output_json,
|
char **output_json,
|
||||||
size_t *output_size);
|
size_t *output_size);
|
||||||
|
|
||||||
/* WHY: the Sleep agenda needs factual completed sessions without fetching one
|
|
||||||
* detail per row. CONTRACT: start/end are bounded ISO instants; intervals
|
|
||||||
* intersect [start,end), and a session with no valid end is a point at start.
|
|
||||||
* Pages are ordered by factual start and stable ID. No state is mutated.
|
|
||||||
* INVARIANT: the caller owns output_json and must paginate until more=false. */
|
|
||||||
TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database,
|
|
||||||
const char *start,
|
|
||||||
const char *end,
|
|
||||||
size_t offset,
|
|
||||||
size_t limit,
|
|
||||||
char **output_json,
|
|
||||||
size_t *output_size);
|
|
||||||
|
|
||||||
/* CONTRACT: kind is one of preparation, proposal, draft, or history. The
|
/* CONTRACT: kind is one of preparation, proposal, draft, or history. The
|
||||||
* returned detail is a factual snapshot and performs no lifecycle transition. */
|
* returned detail is a factual snapshot and performs no lifecycle transition. */
|
||||||
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,
|
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,6 @@ trainlog_core_sources = files(
|
||||||
'src/web_sessions.c',
|
'src/web_sessions.c',
|
||||||
'src/web_session_deletions.c',
|
'src/web_session_deletions.c',
|
||||||
'src/web_programs.c',
|
'src/web_programs.c',
|
||||||
'src/web_program_reschedule.c',
|
|
||||||
'src/web_sleep.c',
|
'src/web_sleep.c',
|
||||||
'src/web_exercises.c',
|
'src/web_exercises.c',
|
||||||
'src/web_equipment.c',
|
'src/web_equipment.c',
|
||||||
|
|
|
||||||
|
|
@ -1578,39 +1578,6 @@ static const char *const MIGRATE_V35_TO_V36_SQL =
|
||||||
"immutable');END;"
|
"immutable');END;"
|
||||||
"PRAGMA user_version=36;COMMIT;";
|
"PRAGMA user_version=36;COMMIT;";
|
||||||
|
|
||||||
/* WHY: a Program's imported date is evidence, while an explicit reschedule
|
|
||||||
* owns its current target and an optional relinquishment of a future slot.
|
|
||||||
* CONTRACT: absent planning rows retain the original date and active state;
|
|
||||||
* operation identities bind replay to an exact request digest. INVARIANT:
|
|
||||||
* migration changes no execution, preparation, source date, or history row. */
|
|
||||||
static const char *const MIGRATE_V36_TO_V37_SQL =
|
|
||||||
"BEGIN IMMEDIATE;"
|
|
||||||
"CREATE TABLE IF NOT EXISTS program_session_planning("
|
|
||||||
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
|
|
||||||
"ON DELETE RESTRICT,"
|
|
||||||
"current_for TEXT,"
|
|
||||||
"state TEXT NOT NULL CHECK(state IN('active','ceded')),"
|
|
||||||
"operation_id TEXT NOT NULL,updated_at TEXT NOT NULL,"
|
|
||||||
"CHECK((state='active' AND current_for IS NOT NULL) OR "
|
|
||||||
"(state='ceded' AND current_for IS NULL)));"
|
|
||||||
"CREATE TABLE IF NOT EXISTS program_reschedule_operations("
|
|
||||||
"operation_id TEXT PRIMARY KEY,"
|
|
||||||
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
|
||||||
"request_sha256 TEXT NOT NULL CHECK(length(request_sha256)=64),"
|
|
||||||
"expected_revision TEXT NOT NULL,"
|
|
||||||
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
|
|
||||||
"CREATE INDEX IF NOT EXISTS program_reschedule_operations_program "
|
|
||||||
"ON program_reschedule_operations(program_id,created_at);"
|
|
||||||
"CREATE TABLE IF NOT EXISTS program_revision_sequences("
|
|
||||||
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
|
||||||
"revision_sequence INTEGER NOT NULL CHECK(revision_sequence>=1));"
|
|
||||||
"CREATE TRIGGER IF NOT EXISTS programs_revision_sequence AFTER UPDATE OF revision_id "
|
|
||||||
"ON programs WHEN OLD.revision_id<>NEW.revision_id BEGIN "
|
|
||||||
"INSERT INTO program_revision_sequences(program_id,revision_sequence) "
|
|
||||||
"VALUES(NEW.program_id,1) ON CONFLICT(program_id) DO UPDATE SET "
|
|
||||||
"revision_sequence=revision_sequence+1;END;"
|
|
||||||
"PRAGMA user_version=37;COMMIT;";
|
|
||||||
|
|
||||||
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
|
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
|
||||||
* before medication capture joined the same unreleased migration. CONTRACT:
|
* before medication capture joined the same unreleased migration. CONTRACT:
|
||||||
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
||||||
|
|
@ -2446,7 +2413,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
||||||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
||||||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
||||||
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
|
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
|
||||||
version == 36 || version == 37) {
|
version == 36) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -2618,9 +2585,6 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
||||||
if (status == TRAINLOG_STATUS_OK && version < 36) {
|
if (status == TRAINLOG_STATUS_OK && version < 36) {
|
||||||
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
|
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
|
||||||
}
|
}
|
||||||
if (status == TRAINLOG_STATUS_OK && version < 37) {
|
|
||||||
status = execute_sql(database, MIGRATE_V36_TO_V37_SQL);
|
|
||||||
}
|
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v18_ai_draft_publication_state(database);
|
status = ensure_v18_ai_draft_publication_state(database);
|
||||||
}
|
}
|
||||||
|
|
@ -2640,7 +2604,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
set_open_diagnostic(output_diagnostic,
|
set_open_diagnostic(output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
output_diagnostic_capacity,
|
||||||
version == 0 ? "create schema v37" : "migrate database to schema v37",
|
version == 0 ? "create schema v36" : "migrate database to schema v36",
|
||||||
database->connection,
|
database->connection,
|
||||||
SQLITE_ERROR);
|
SQLITE_ERROR);
|
||||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||||
|
|
|
||||||
|
|
@ -646,26 +646,26 @@ static bool add_measurements(TrainlogDatabase *database,
|
||||||
static bool
|
static bool
|
||||||
add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) {
|
add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) {
|
||||||
static const char SQL[] =
|
static const char SQL[] =
|
||||||
/* WHY: imported dates are historical after Program rescheduling.
|
|
||||||
* CONTRACT: select one pending, non-ceded session with its current date.
|
|
||||||
* INVARIANT: a present planning row with NULL current_for stays undated. */
|
|
||||||
"WITH eligible AS (SELECT ps2.program_id,ps2.program_session_id,ps2.title,ps2.position,"
|
|
||||||
"CASE WHEN pp.program_session_id IS NULL THEN ps2.planned_for ELSE pp.current_for END "
|
|
||||||
"AS current_for FROM program_sessions ps2 LEFT JOIN program_session_planning pp ON "
|
|
||||||
"pp.program_session_id=ps2.program_session_id LEFT JOIN program_session_executions pe2 "
|
|
||||||
"ON pe2.program_session_id=ps2.program_session_id WHERE COALESCE(pp.state,'active')"
|
|
||||||
"!='ceded' AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted')),"
|
|
||||||
"ranked AS (SELECT eligible.*,ROW_NUMBER() OVER (PARTITION BY program_id ORDER BY "
|
|
||||||
"CASE WHEN current_for IS NULL THEN 1 ELSE 0 END,current_for,position,"
|
|
||||||
"program_session_id) AS rank FROM eligible) "
|
|
||||||
"SELECT p.program_id,p.title,COUNT(ps.program_session_id),"
|
"SELECT p.program_id,p.title,COUNT(ps.program_session_id),"
|
||||||
"SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END),"
|
"SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END),"
|
||||||
"next.title,next.program_session_id,next.current_for "
|
"(SELECT ps2.title FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 ON "
|
||||||
|
"pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
|
||||||
|
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
|
||||||
|
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1),"
|
||||||
|
"(SELECT ps2.program_session_id FROM program_sessions ps2 LEFT JOIN "
|
||||||
|
"program_session_executions pe2 ON pe2.program_session_id=ps2.program_session_id WHERE "
|
||||||
|
"ps2.program_id=p.program_id AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted') "
|
||||||
|
"ORDER BY CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position "
|
||||||
|
"LIMIT 1),"
|
||||||
|
"(SELECT ps2.planned_for FROM program_sessions ps2 LEFT JOIN program_session_executions "
|
||||||
|
"pe2 "
|
||||||
|
"ON pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
|
||||||
|
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
|
||||||
|
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1) "
|
||||||
"FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN "
|
"FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN "
|
||||||
"program_session_executions pe ON pe.program_session_id=ps.program_session_id LEFT JOIN "
|
"program_session_executions pe ON pe.program_session_id=ps.program_session_id WHERE "
|
||||||
"ranked next ON next.program_id=p.program_id AND next.rank=1 WHERE "
|
|
||||||
"p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at "
|
"p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at "
|
||||||
"DESC,p.program_id "
|
"DESC "
|
||||||
"LIMIT 1";
|
"LIMIT 1";
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
yyjson_mut_val *program = yyjson_mut_obj(document);
|
yyjson_mut_val *program = yyjson_mut_obj(document);
|
||||||
|
|
|
||||||
|
|
@ -1,645 +0,0 @@
|
||||||
#include "trainlog/web_programs.h"
|
|
||||||
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <time.h>
|
|
||||||
|
|
||||||
#include <openssl/evp.h>
|
|
||||||
#include <sqlite3.h>
|
|
||||||
#include <yyjson.h>
|
|
||||||
|
|
||||||
#include "database_internal.h"
|
|
||||||
#include "trainlog/id.h"
|
|
||||||
|
|
||||||
typedef struct PlanningRow {
|
|
||||||
const char *id;
|
|
||||||
const char *original;
|
|
||||||
const char *current;
|
|
||||||
const char *execution;
|
|
||||||
const char *planning;
|
|
||||||
const char *assigned;
|
|
||||||
bool cede;
|
|
||||||
} PlanningRow;
|
|
||||||
|
|
||||||
static TrainlogStatus
|
|
||||||
planning_error(const char *reason, TrainlogStatus status, char **output, size_t *size) {
|
|
||||||
size_t length = strlen(reason) + 16U;
|
|
||||||
*output = malloc(length);
|
|
||||||
if (*output == NULL) {
|
|
||||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
||||||
}
|
|
||||||
*size = (size_t)snprintf(*output, length, "{\"error\":\"%s\"}", reason);
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool planning_digest(
|
|
||||||
const char *first, size_t first_size, const char *second, size_t second_size, char output[65]) {
|
|
||||||
EVP_MD_CTX *context = EVP_MD_CTX_new();
|
|
||||||
unsigned char digest[32];
|
|
||||||
unsigned int length = 0U;
|
|
||||||
bool okay = context != NULL && EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
|
|
||||||
EVP_DigestUpdate(context, first, first_size) == 1 &&
|
|
||||||
EVP_DigestUpdate(context, second, second_size) == 1 &&
|
|
||||||
EVP_DigestFinal_ex(context, digest, &length) == 1 && length == 32U;
|
|
||||||
EVP_MD_CTX_free(context);
|
|
||||||
if (!okay) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (size_t index = 0U; index < 32U; ++index) {
|
|
||||||
(void)snprintf(output + index * 2U, 3U, "%02x", digest[index]);
|
|
||||||
}
|
|
||||||
output[64] = '\0';
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool valid_civil_date(const char *value) {
|
|
||||||
unsigned year, month, day;
|
|
||||||
int days;
|
|
||||||
if (value == NULL || strlen(value) != 10U || value[4] != '-' || value[7] != '-' ||
|
|
||||||
sscanf(value, "%4u-%2u-%2u", &year, &month, &day) != 3 || year < 1900U || year > 9999U ||
|
|
||||||
month < 1U || month > 12U) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (size_t index = 0U; index < 10U; ++index) {
|
|
||||||
if (index != 4U && index != 7U && (value[index] < '0' || value[index] > '9')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static const int MONTH_DAYS[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
|
|
||||||
days = MONTH_DAYS[month - 1U];
|
|
||||||
if (month == 2U && (year % 4U == 0U && (year % 100U != 0U || year % 400U == 0U))) {
|
|
||||||
days = 29;
|
|
||||||
}
|
|
||||||
return day >= 1U && day <= (unsigned)days;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool
|
|
||||||
add_nullable(yyjson_mut_doc *document, yyjson_mut_val *object, const char *key, const char *value) {
|
|
||||||
return value == NULL ? yyjson_mut_obj_add_null(document, object, key)
|
|
||||||
: yyjson_mut_obj_add_strcpy(document, object, key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool request_valid(yyjson_val *root, bool commit) {
|
|
||||||
yyjson_obj_iter iterator;
|
|
||||||
yyjson_val *key;
|
|
||||||
size_t count = 0U;
|
|
||||||
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != (commit ? 6U : 5U)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
iterator = yyjson_obj_iter_with(root);
|
|
||||||
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
|
|
||||||
const char *name = yyjson_get_str(key);
|
|
||||||
if (strcmp(name, "start_session_id") != 0 && strcmp(name, "through_session_id") != 0 &&
|
|
||||||
strcmp(name, "start_date") != 0 && strcmp(name, "ceded_session_ids") != 0 &&
|
|
||||||
strcmp(name, "expected_revision") != 0 &&
|
|
||||||
!(commit && strcmp(name, "preview_sha256") == 0)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
++count;
|
|
||||||
}
|
|
||||||
return count == (commit ? 6U : 5U) && yyjson_is_str(yyjson_obj_get(root, "start_session_id")) &&
|
|
||||||
yyjson_is_str(yyjson_obj_get(root, "through_session_id")) &&
|
|
||||||
yyjson_is_str(yyjson_obj_get(root, "expected_revision")) &&
|
|
||||||
yyjson_get_len(yyjson_obj_get(root, "start_session_id")) <= 128U &&
|
|
||||||
yyjson_get_len(yyjson_obj_get(root, "through_session_id")) <= 128U &&
|
|
||||||
yyjson_get_len(yyjson_obj_get(root, "expected_revision")) <= 128U &&
|
|
||||||
valid_civil_date(yyjson_get_str(yyjson_obj_get(root, "start_date"))) &&
|
|
||||||
yyjson_is_arr(yyjson_obj_get(root, "ceded_session_ids")) &&
|
|
||||||
yyjson_arr_size(yyjson_obj_get(root, "ceded_session_ids")) <= 64U &&
|
|
||||||
(!commit || (yyjson_is_str(yyjson_obj_get(root, "preview_sha256")) &&
|
|
||||||
yyjson_get_len(yyjson_obj_get(root, "preview_sha256")) == 64U));
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool has_preparation(TrainlogDatabase *database, const char *session_id) {
|
|
||||||
sqlite3_stmt *statement = NULL;
|
|
||||||
bool present = true;
|
|
||||||
if (sqlite3_prepare_v2(database->connection,
|
|
||||||
"SELECT 1 FROM session_preparations WHERE "
|
|
||||||
"source_program_session_id=? AND withdrawn_at IS NULL LIMIT 1",
|
|
||||||
-1,
|
|
||||||
&statement,
|
|
||||||
NULL) == SQLITE_OK &&
|
|
||||||
sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) == SQLITE_OK) {
|
|
||||||
present = sqlite3_step(statement) != SQLITE_DONE;
|
|
||||||
}
|
|
||||||
(void)sqlite3_finalize(statement);
|
|
||||||
return present;
|
|
||||||
}
|
|
||||||
|
|
||||||
static TrainlogStatus peer_planning_fresh(TrainlogDatabase *database) {
|
|
||||||
static const char SQL[] =
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM "
|
|
||||||
"(SELECT DISTINCT consumer_peer_id FROM sync_generations "
|
|
||||||
"WHERE producer_kind='desktop') peer WHERE "
|
|
||||||
"NOT EXISTS(SELECT 1 FROM sync_generations generation "
|
|
||||||
"JOIN sync_generation_artifacts artifact "
|
|
||||||
"ON artifact.generation_id=generation.generation_id "
|
|
||||||
"WHERE generation.generation_id=(SELECT latest.generation_id "
|
|
||||||
"FROM sync_generations latest WHERE latest.consumer_peer_id=peer.consumer_peer_id "
|
|
||||||
"AND latest.producer_kind='desktop' "
|
|
||||||
"ORDER BY julianday(latest.generated_at) DESC,latest.generation_id DESC LIMIT 1) "
|
|
||||||
"AND generation.status='acknowledged' AND artifact.logical_name='programs-v2' "
|
|
||||||
"AND julianday(generation.acknowledged_at)>=julianday('now','-5 minutes')))";
|
|
||||||
sqlite3_stmt *statement = NULL;
|
|
||||||
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) == SQLITE_OK &&
|
|
||||||
sqlite3_step(statement) == SQLITE_ROW) {
|
|
||||||
status =
|
|
||||||
sqlite3_column_int(statement, 0) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT;
|
|
||||||
}
|
|
||||||
(void)sqlite3_finalize(statement);
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
static TrainlogStatus build_preview(TrainlogDatabase *database,
|
|
||||||
const char *program_id,
|
|
||||||
yyjson_val *request,
|
|
||||||
char **output,
|
|
||||||
size_t *output_size) {
|
|
||||||
char *detail = NULL;
|
|
||||||
size_t detail_size = 0U;
|
|
||||||
yyjson_doc *source = NULL;
|
|
||||||
yyjson_mut_doc *response = NULL;
|
|
||||||
yyjson_val *program;
|
|
||||||
yyjson_val *sessions;
|
|
||||||
yyjson_val *ceded;
|
|
||||||
yyjson_mut_val *root;
|
|
||||||
yyjson_mut_val *moves;
|
|
||||||
PlanningRow rows[64] = {0};
|
|
||||||
const char *slots[64] = {0};
|
|
||||||
size_t first = 64U;
|
|
||||||
size_t last = 64U;
|
|
||||||
size_t count;
|
|
||||||
size_t slot_count = 0U;
|
|
||||||
size_t next_slot = 0U;
|
|
||||||
char fingerprint[65];
|
|
||||||
char *choices = NULL;
|
|
||||||
size_t choices_size = 0U;
|
|
||||||
TrainlogStatus status =
|
|
||||||
trainlog_web_programs_detail_json(database, program_id, &detail, &detail_size);
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
source = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
|
|
||||||
program = source == NULL ? NULL : yyjson_doc_get_root(source);
|
|
||||||
sessions = program == NULL ? NULL : yyjson_obj_get(program, "sessions");
|
|
||||||
ceded = yyjson_obj_get(request, "ceded_session_ids");
|
|
||||||
count = yyjson_arr_size(sessions);
|
|
||||||
if (program == NULL || count > 64U ||
|
|
||||||
!yyjson_equals_str(yyjson_obj_get(program, "state"), "active") ||
|
|
||||||
!yyjson_equals_str(yyjson_obj_get(program, "revision_id"),
|
|
||||||
yyjson_get_str(yyjson_obj_get(request, "expected_revision"))) ||
|
|
||||||
!yyjson_is_str(yyjson_obj_get(program, "end_date"))) {
|
|
||||||
status = planning_error(
|
|
||||||
"stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
for (size_t index = 0U; index < count; ++index) {
|
|
||||||
yyjson_val *session = yyjson_arr_get(sessions, index);
|
|
||||||
rows[index].id = yyjson_get_str(yyjson_obj_get(session, "program_session_id"));
|
|
||||||
rows[index].original = yyjson_get_str(yyjson_obj_get(session, "planned_for"));
|
|
||||||
rows[index].current = yyjson_get_str(yyjson_obj_get(session, "current_for"));
|
|
||||||
rows[index].execution = yyjson_get_str(yyjson_obj_get(session, "execution_state"));
|
|
||||||
rows[index].planning = yyjson_get_str(yyjson_obj_get(session, "planning_state"));
|
|
||||||
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "start_session_id"))) ==
|
|
||||||
0) {
|
|
||||||
first = index;
|
|
||||||
}
|
|
||||||
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "through_session_id"))) ==
|
|
||||||
0) {
|
|
||||||
last = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (first >= count || last >= count || first > last ||
|
|
||||||
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
|
|
||||||
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
|
|
||||||
status = planning_error(
|
|
||||||
"invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
|
|
||||||
yyjson_val *id = yyjson_arr_get(ceded, index);
|
|
||||||
bool matched = false;
|
|
||||||
if (!yyjson_is_str(id) || yyjson_get_len(id) > 128U) {
|
|
||||||
status = planning_error(
|
|
||||||
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
for (size_t candidate = first; candidate <= last; ++candidate) {
|
|
||||||
if (strcmp(rows[candidate].id, yyjson_get_str(id)) == 0 && !rows[candidate].cede) {
|
|
||||||
rows[candidate].cede = true;
|
|
||||||
matched = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!matched) {
|
|
||||||
status = planning_error(
|
|
||||||
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (size_t index = first; index <= last; ++index) {
|
|
||||||
if (strcmp(rows[index].execution, "todo") != 0 ||
|
|
||||||
has_preparation(database, rows[index].id) || rows[index].original == NULL ||
|
|
||||||
!valid_civil_date(rows[index].original)) {
|
|
||||||
status = planning_error(
|
|
||||||
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
if (strcmp(rows[index].planning, "ceded") == 0 && !rows[index].cede) {
|
|
||||||
status = planning_error(
|
|
||||||
"ceded_session_requires_selection", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
// WHY: a second explicit decision starts from the durable current
|
|
||||||
// schedule. A ceded identity has no current date, so its original slot
|
|
||||||
// is available only when the user selects that identity again.
|
|
||||||
const char *slot = rows[index].current == NULL ? rows[index].original : rows[index].current;
|
|
||||||
if (strcmp(slot, yyjson_get_str(yyjson_obj_get(request, "start_date"))) >= 0) {
|
|
||||||
slots[slot_count++] = slot;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Selected Program slots define permitted days; no rest day is synthesized.
|
|
||||||
// Position order may differ from dates, so sort candidate dates explicitly.
|
|
||||||
for (size_t outer = 0U; outer < slot_count; ++outer) {
|
|
||||||
for (size_t inner = outer + 1U; inner < slot_count; ++inner) {
|
|
||||||
if (strcmp(slots[inner], slots[outer]) < 0) {
|
|
||||||
const char *swap = slots[outer];
|
|
||||||
slots[outer] = slots[inner];
|
|
||||||
slots[inner] = swap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A previously ceded original day may now be occupied by an active
|
|
||||||
// session. It remains one calendar slot for this operation.
|
|
||||||
size_t unique_slots = 0U;
|
|
||||||
for (size_t index = 0U; index < slot_count; ++index) {
|
|
||||||
if (unique_slots == 0U || strcmp(slots[index], slots[unique_slots - 1U]) != 0) {
|
|
||||||
slots[unique_slots++] = slots[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slot_count = unique_slots;
|
|
||||||
for (size_t index = first; index <= last; ++index) {
|
|
||||||
if (rows[index].cede) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (next_slot >= slot_count ||
|
|
||||||
strcmp(slots[next_slot], yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
|
|
||||||
status = planning_error("insufficient_or_duplicate_slots",
|
|
||||||
TRAINLOG_STATUS_INVALID_ARGUMENT,
|
|
||||||
output,
|
|
||||||
output_size);
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
rows[index].assigned = slots[next_slot++];
|
|
||||||
}
|
|
||||||
// CONTRACT: the choice digest includes stable identities in their supplied
|
|
||||||
// order. The fact digest includes the complete current Program projection.
|
|
||||||
{
|
|
||||||
yyjson_mut_doc *choice_document = yyjson_mut_doc_new(NULL);
|
|
||||||
yyjson_mut_val *choice_root =
|
|
||||||
choice_document == NULL ? NULL : yyjson_mut_obj(choice_document);
|
|
||||||
if (choice_document == NULL || choice_root == NULL) {
|
|
||||||
yyjson_mut_doc_free(choice_document);
|
|
||||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
yyjson_mut_doc_set_root(choice_document, choice_root);
|
|
||||||
(void)yyjson_mut_obj_add_strcpy(
|
|
||||||
choice_document,
|
|
||||||
choice_root,
|
|
||||||
"start_session_id",
|
|
||||||
yyjson_get_str(yyjson_obj_get(request, "start_session_id")));
|
|
||||||
(void)yyjson_mut_obj_add_strcpy(
|
|
||||||
choice_document,
|
|
||||||
choice_root,
|
|
||||||
"through_session_id",
|
|
||||||
yyjson_get_str(yyjson_obj_get(request, "through_session_id")));
|
|
||||||
(void)yyjson_mut_obj_add_strcpy(choice_document,
|
|
||||||
choice_root,
|
|
||||||
"start_date",
|
|
||||||
yyjson_get_str(yyjson_obj_get(request, "start_date")));
|
|
||||||
yyjson_mut_val *ids = yyjson_mut_arr(choice_document);
|
|
||||||
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
|
|
||||||
(void)yyjson_mut_arr_add_strcpy(
|
|
||||||
choice_document, ids, yyjson_get_str(yyjson_arr_get(ceded, index)));
|
|
||||||
}
|
|
||||||
(void)yyjson_mut_obj_add_val(choice_document, choice_root, "ceded_session_ids", ids);
|
|
||||||
choices = yyjson_mut_write(choice_document, YYJSON_WRITE_NOFLAG, &choices_size);
|
|
||||||
yyjson_mut_doc_free(choice_document);
|
|
||||||
}
|
|
||||||
if (choices == NULL ||
|
|
||||||
!planning_digest(detail, detail_size, choices, choices_size, fingerprint)) {
|
|
||||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
response = yyjson_mut_doc_new(NULL);
|
|
||||||
root = response == NULL ? NULL : yyjson_mut_obj(response);
|
|
||||||
moves = response == NULL ? NULL : yyjson_mut_arr(response);
|
|
||||||
if (response == NULL || root == NULL || moves == NULL) {
|
|
||||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
yyjson_mut_doc_set_root(response, root);
|
|
||||||
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
|
|
||||||
(void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
|
|
||||||
(void)yyjson_mut_obj_add_strcpy(
|
|
||||||
response, root, "revision_id", yyjson_get_str(yyjson_obj_get(program, "revision_id")));
|
|
||||||
(void)yyjson_mut_obj_add_strcpy(response, root, "preview_sha256", fingerprint);
|
|
||||||
(void)yyjson_mut_obj_add_val(response, root, "moves", moves);
|
|
||||||
for (size_t index = first; index <= last; ++index) {
|
|
||||||
yyjson_mut_val *move = yyjson_mut_obj(response);
|
|
||||||
(void)yyjson_mut_obj_add_strcpy(response, move, "program_session_id", rows[index].id);
|
|
||||||
(void)add_nullable(response, move, "original_for", rows[index].original);
|
|
||||||
(void)add_nullable(response, move, "old_for", rows[index].current);
|
|
||||||
(void)add_nullable(response, move, "new_for", rows[index].assigned);
|
|
||||||
const char *change =
|
|
||||||
rows[index].cede ? "ceded"
|
|
||||||
: rows[index].current != NULL && strcmp(rows[index].current, rows[index].assigned) == 0
|
|
||||||
? "unchanged"
|
|
||||||
: "rescheduled";
|
|
||||||
(void)yyjson_mut_obj_add_strcpy(response, move, "change", change);
|
|
||||||
(void)yyjson_mut_arr_add_val(moves, move);
|
|
||||||
}
|
|
||||||
*output = yyjson_mut_write(response, YYJSON_WRITE_NOFLAG, output_size);
|
|
||||||
status = *output == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
|
||||||
done:
|
|
||||||
free(choices);
|
|
||||||
yyjson_mut_doc_free(response);
|
|
||||||
yyjson_doc_free(source);
|
|
||||||
free(detail);
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
static TrainlogStatus apply_preview(TrainlogDatabase *database,
|
|
||||||
const char *operation_id,
|
|
||||||
const char *program_id,
|
|
||||||
const char *expected_revision,
|
|
||||||
const char *request_digest,
|
|
||||||
const char *preview,
|
|
||||||
char **output,
|
|
||||||
size_t *output_size) {
|
|
||||||
yyjson_doc *document = yyjson_read(preview, strlen(preview), YYJSON_READ_NOFLAG);
|
|
||||||
yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document);
|
|
||||||
yyjson_val *moves = root == NULL ? NULL : yyjson_obj_get(root, "moves");
|
|
||||||
sqlite3_stmt *statement = NULL;
|
|
||||||
char revision[80];
|
|
||||||
char now[32];
|
|
||||||
time_t clock_now = time(NULL);
|
|
||||||
struct tm utc;
|
|
||||||
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
if (moves == NULL || clock_now == (time_t)-1 || gmtime_r(&clock_now, &utc) == NULL ||
|
|
||||||
strftime(now, sizeof(now), "%Y-%m-%dT%H:%M:%SZ", &utc) == 0U ||
|
|
||||||
trainlog_id_generate("pgr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK) {
|
|
||||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
if (sqlite3_prepare_v2(database->connection,
|
|
||||||
"UPDATE programs SET revision_id=?,updated_at=? WHERE program_id=? "
|
|
||||||
"AND revision_id=? AND deleted_at IS NULL AND state='active'",
|
|
||||||
-1,
|
|
||||||
&statement,
|
|
||||||
NULL) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 3, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
|
||||||
status = TRAINLOG_STATUS_CONFLICT;
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
(void)sqlite3_finalize(statement);
|
|
||||||
statement = NULL;
|
|
||||||
if (sqlite3_prepare_v2(database->connection,
|
|
||||||
"INSERT INTO program_session_planning "
|
|
||||||
"(program_session_id,current_for,state,operation_id,updated_at) "
|
|
||||||
"VALUES(?,?,?,?,?) ON CONFLICT(program_session_id) DO UPDATE SET "
|
|
||||||
"current_for=excluded.current_for,state=excluded.state,"
|
|
||||||
"operation_id=excluded.operation_id,updated_at=excluded.updated_at",
|
|
||||||
-1,
|
|
||||||
&statement,
|
|
||||||
NULL) != SQLITE_OK) {
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
for (size_t index = 0U; index < yyjson_arr_size(moves); ++index) {
|
|
||||||
yyjson_val *move = yyjson_arr_get(moves, index);
|
|
||||||
yyjson_val *new_for = yyjson_obj_get(move, "new_for");
|
|
||||||
if (yyjson_equals_str(yyjson_obj_get(move, "change"), "unchanged")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
sqlite3_reset(statement);
|
|
||||||
sqlite3_clear_bindings(statement);
|
|
||||||
if (sqlite3_bind_text(statement,
|
|
||||||
1,
|
|
||||||
yyjson_get_str(yyjson_obj_get(move, "program_session_id")),
|
|
||||||
-1,
|
|
||||||
SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
(yyjson_is_null(new_for)
|
|
||||||
? sqlite3_bind_null(statement, 2)
|
|
||||||
: sqlite3_bind_text(
|
|
||||||
statement, 2, yyjson_get_str(new_for), -1, SQLITE_TRANSIENT)) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement,
|
|
||||||
3,
|
|
||||||
yyjson_equals_str(yyjson_obj_get(move, "change"), "ceded") ? "ceded"
|
|
||||||
: "active",
|
|
||||||
-1,
|
|
||||||
SQLITE_STATIC) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 4, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_step(statement) != SQLITE_DONE) {
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(void)sqlite3_finalize(statement);
|
|
||||||
statement = NULL;
|
|
||||||
{
|
|
||||||
size_t length = strlen(preview) + strlen(revision) + 52U;
|
|
||||||
*output = malloc(length);
|
|
||||||
if (*output == NULL) {
|
|
||||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
*output_size = (size_t)snprintf(*output,
|
|
||||||
length,
|
|
||||||
"%.*s,\"applied\":true,\"new_revision_id\":\"%s\"}",
|
|
||||||
(int)strlen(preview) - 1,
|
|
||||||
preview,
|
|
||||||
revision);
|
|
||||||
}
|
|
||||||
if (sqlite3_prepare_v2(database->connection,
|
|
||||||
"INSERT INTO program_reschedule_operations VALUES(?,?,?,?,?,?)",
|
|
||||||
-1,
|
|
||||||
&statement,
|
|
||||||
NULL) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 2, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 3, request_digest, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 5, *output, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 6, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_step(statement) != SQLITE_DONE) {
|
|
||||||
goto done;
|
|
||||||
}
|
|
||||||
status = TRAINLOG_STATUS_OK;
|
|
||||||
done:
|
|
||||||
(void)sqlite3_finalize(statement);
|
|
||||||
yyjson_doc_free(document);
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
|
|
||||||
const char *program_id,
|
|
||||||
const char *operation_id,
|
|
||||||
const char *body,
|
|
||||||
size_t body_size,
|
|
||||||
bool commit,
|
|
||||||
char **output_json,
|
|
||||||
size_t *output_size) {
|
|
||||||
yyjson_doc *request_doc = NULL;
|
|
||||||
yyjson_val *request;
|
|
||||||
sqlite3_stmt *statement = NULL;
|
|
||||||
char request_digest[65];
|
|
||||||
char *preview = NULL;
|
|
||||||
size_t preview_size = 0U;
|
|
||||||
TrainlogStatus status;
|
|
||||||
if (database == NULL || program_id == NULL || body == NULL || output_json == NULL ||
|
|
||||||
output_size == NULL || (commit && (operation_id == NULL || operation_id[0] == '\0')) ||
|
|
||||||
body_size > 16384U || (operation_id != NULL && strlen(operation_id) > 128U)) {
|
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
||||||
}
|
|
||||||
*output_json = NULL;
|
|
||||||
*output_size = 0U;
|
|
||||||
request_doc = yyjson_read(body, body_size, YYJSON_READ_NOFLAG);
|
|
||||||
request = request_doc == NULL ? NULL : yyjson_doc_get_root(request_doc);
|
|
||||||
if (!request_valid(request, commit) ||
|
|
||||||
!planning_digest(body, body_size, "", 0U, request_digest)) {
|
|
||||||
yyjson_doc_free(request_doc);
|
|
||||||
return planning_error("invalid_reschedule_request",
|
|
||||||
TRAINLOG_STATUS_INVALID_ARGUMENT,
|
|
||||||
output_json,
|
|
||||||
output_size);
|
|
||||||
}
|
|
||||||
if (sqlite3_exec(
|
|
||||||
database->connection, commit ? "BEGIN IMMEDIATE" : "BEGIN", NULL, NULL, NULL) !=
|
|
||||||
SQLITE_OK) {
|
|
||||||
yyjson_doc_free(request_doc);
|
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
}
|
|
||||||
if (commit) {
|
|
||||||
if (sqlite3_prepare_v2(database->connection,
|
|
||||||
"SELECT program_id,request_sha256,response_json FROM "
|
|
||||||
"program_reschedule_operations WHERE operation_id=?",
|
|
||||||
-1,
|
|
||||||
&statement,
|
|
||||||
NULL) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
||||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
goto finish;
|
|
||||||
}
|
|
||||||
if (sqlite3_step(statement) == SQLITE_ROW) {
|
|
||||||
const char *known_program = (const char *)sqlite3_column_text(statement, 0);
|
|
||||||
const char *known_digest = (const char *)sqlite3_column_text(statement, 1);
|
|
||||||
const char *saved = (const char *)sqlite3_column_text(statement, 2);
|
|
||||||
if (known_program != NULL && known_digest != NULL && saved != NULL &&
|
|
||||||
strcmp(known_program, program_id) == 0 &&
|
|
||||||
strcmp(known_digest, request_digest) == 0) {
|
|
||||||
*output_json = strdup(saved);
|
|
||||||
*output_size = *output_json == NULL ? 0U : strlen(*output_json);
|
|
||||||
status = *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
|
||||||
} else {
|
|
||||||
status = planning_error("operation_identity_conflict",
|
|
||||||
TRAINLOG_STATUS_CONFLICT,
|
|
||||||
output_json,
|
|
||||||
output_size);
|
|
||||||
}
|
|
||||||
goto finish;
|
|
||||||
}
|
|
||||||
(void)sqlite3_finalize(statement);
|
|
||||||
statement = NULL;
|
|
||||||
if (sqlite3_prepare_v2(database->connection,
|
|
||||||
"SELECT 1 FROM program_requests WHERE request_id=? "
|
|
||||||
"UNION ALL SELECT 1 FROM program_deletions WHERE request_id=?",
|
|
||||||
-1,
|
|
||||||
&statement,
|
|
||||||
NULL) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 2, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
||||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
goto finish;
|
|
||||||
}
|
|
||||||
int cross_command = sqlite3_step(statement);
|
|
||||||
(void)sqlite3_finalize(statement);
|
|
||||||
statement = NULL;
|
|
||||||
if (cross_command != SQLITE_DONE) {
|
|
||||||
status = cross_command == SQLITE_ROW ? planning_error("operation_identity_conflict",
|
|
||||||
TRAINLOG_STATUS_CONFLICT,
|
|
||||||
output_json,
|
|
||||||
output_size)
|
|
||||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
goto finish;
|
|
||||||
}
|
|
||||||
// WHY: the desktop's last imported draft snapshot cannot prove that
|
|
||||||
// Android stayed idle while offline. A recent acknowledged V2 Program
|
|
||||||
// generation is required; Android independently rejects a changed
|
|
||||||
// schedule if a Program draft starts after that acknowledgement.
|
|
||||||
status = peer_planning_fresh(database);
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
|
||||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
|
||||||
status = planning_error("program_peer_not_fresh", status, output_json, output_size);
|
|
||||||
}
|
|
||||||
goto finish;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
status = build_preview(database, program_id, request, &preview, &preview_size);
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
|
||||||
if (preview != NULL) {
|
|
||||||
*output_json = preview;
|
|
||||||
*output_size = preview_size;
|
|
||||||
preview = NULL;
|
|
||||||
}
|
|
||||||
goto finish;
|
|
||||||
}
|
|
||||||
if (!commit) {
|
|
||||||
*output_json = preview;
|
|
||||||
*output_size = preview_size;
|
|
||||||
preview = NULL;
|
|
||||||
goto finish;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
yyjson_doc *preview_doc = yyjson_read(preview, preview_size, YYJSON_READ_NOFLAG);
|
|
||||||
yyjson_val *preview_root = preview_doc == NULL ? NULL : yyjson_doc_get_root(preview_doc);
|
|
||||||
const char *fingerprint = yyjson_get_str(yyjson_obj_get(preview_root, "preview_sha256"));
|
|
||||||
if (fingerprint == NULL ||
|
|
||||||
strcmp(fingerprint, yyjson_get_str(yyjson_obj_get(request, "preview_sha256"))) != 0) {
|
|
||||||
status = planning_error(
|
|
||||||
"stale_reschedule_preview", TRAINLOG_STATUS_CONFLICT, output_json, output_size);
|
|
||||||
} else {
|
|
||||||
status = apply_preview(database,
|
|
||||||
operation_id,
|
|
||||||
program_id,
|
|
||||||
yyjson_get_str(yyjson_obj_get(request, "expected_revision")),
|
|
||||||
request_digest,
|
|
||||||
preview,
|
|
||||||
output_json,
|
|
||||||
output_size);
|
|
||||||
}
|
|
||||||
yyjson_doc_free(preview_doc);
|
|
||||||
}
|
|
||||||
finish:
|
|
||||||
(void)sqlite3_finalize(statement);
|
|
||||||
free(preview);
|
|
||||||
yyjson_doc_free(request_doc);
|
|
||||||
if (status == TRAINLOG_STATUS_OK && commit) {
|
|
||||||
if (sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
}
|
|
||||||
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
|
||||||
if (status != TRAINLOG_STATUS_OK && *output_json == NULL) {
|
|
||||||
return planning_error("reschedule_unavailable", status, output_json, output_size);
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
@ -365,12 +365,9 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database,
|
||||||
!add_column_text(document, session, "title", statement, 2) ||
|
!add_column_text(document, session, "title", statement, 2) ||
|
||||||
!add_column_text(document, session, "session_type", statement, 3) ||
|
!add_column_text(document, session, "session_type", statement, 3) ||
|
||||||
!add_column_text(document, session, "planned_for", statement, 4) ||
|
!add_column_text(document, session, "planned_for", statement, 4) ||
|
||||||
!add_column_text(document, session, "current_for", statement, 5) ||
|
!add_column_text(document, session, "note", statement, 5) ||
|
||||||
!add_column_text(document, session, "planning_state", statement, 6) ||
|
!add_column_text(document, session, "execution_state", statement, 6) ||
|
||||||
!add_column_text(document, session, "note", statement, 7) ||
|
!add_column_text(document, session, "execution_session_id", statement, 7) ||
|
||||||
!add_column_text(document, session, "execution_state", statement, 8) ||
|
|
||||||
!add_column_text(document, session, "execution_session_id", statement, 9) ||
|
|
||||||
!add_column_text(document, session, "execution_started_at", statement, 10) ||
|
|
||||||
!yyjson_mut_obj_add_val(document, session, "occurrences", entries) ||
|
!yyjson_mut_obj_add_val(document, session, "occurrences", entries) ||
|
||||||
!yyjson_mut_arr_add_val(sessions, session)) {
|
!yyjson_mut_arr_add_val(sessions, session)) {
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
|
@ -385,24 +382,15 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
|
||||||
yyjson_mut_val *sessions) {
|
yyjson_mut_val *sessions) {
|
||||||
static const char SESSION_SQL[] =
|
static const char SESSION_SQL[] =
|
||||||
"SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
|
"SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
|
||||||
"CASE WHEN pp.state='ceded' THEN NULL ELSE COALESCE(pp.current_for,ps.planned_for) END,"
|
"ps.note,CASE WHEN pe.state='completed' THEN 'completed' "
|
||||||
"COALESCE(pp.state,'active'),ps.note,"
|
|
||||||
"CASE WHEN pe.state='completed' THEN 'completed' "
|
|
||||||
"WHEN pe.state='in_progress' THEN 'in_progress' "
|
"WHEN pe.state='in_progress' THEN 'in_progress' "
|
||||||
"WHEN pe.state='deleted' THEN 'deleted' "
|
"WHEN pe.state='deleted' THEN 'deleted' "
|
||||||
"WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE "
|
"WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE "
|
||||||
"sp.source_program_session_id=ps.program_session_id AND sp.withdrawn_at IS NULL) "
|
"sp.source_program_session_id=ps.program_session_id AND sp.withdrawn_at IS NULL) "
|
||||||
"THEN 'prepared' ELSE 'todo' END,"
|
"THEN 'prepared' ELSE 'todo' END,"
|
||||||
"CASE WHEN pe.state='deleted' THEN NULL ELSE pe.session_id END,"
|
"CASE WHEN pe.state='deleted' THEN NULL ELSE pe.session_id END "
|
||||||
/* WHY: completion belongs to the linked session's actual start date.
|
"FROM program_sessions ps LEFT JOIN program_session_executions pe "
|
||||||
* CONTRACT: expose that timestamp only for completed executions.
|
|
||||||
* INVARIANT: the imported planned_for date and history are read only. */
|
|
||||||
"CASE WHEN pe.state='completed' THEN actual.started_at ELSE NULL END "
|
|
||||||
"FROM program_sessions ps LEFT JOIN program_session_planning pp "
|
|
||||||
"ON pp.program_session_id=ps.program_session_id "
|
|
||||||
"LEFT JOIN program_session_executions pe "
|
|
||||||
"ON pe.program_session_id=ps.program_session_id "
|
"ON pe.program_session_id=ps.program_session_id "
|
||||||
"LEFT JOIN sessions actual ON actual.session_id=pe.session_id "
|
|
||||||
"WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id";
|
"WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id";
|
||||||
sqlite3_stmt *session_statement = NULL;
|
sqlite3_stmt *session_statement = NULL;
|
||||||
int step;
|
int step;
|
||||||
|
|
@ -1171,25 +1159,7 @@ static TrainlogStatus program_replay_archive(TrainlogDatabase *database,
|
||||||
result = SQLITE_OK;
|
result = SQLITE_OK;
|
||||||
}
|
}
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
if (result != SQLITE_OK || *found) {
|
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
}
|
|
||||||
statement = NULL;
|
|
||||||
result = sqlite3_prepare_v2(database->connection,
|
|
||||||
"SELECT 1 FROM program_reschedule_operations WHERE operation_id=?",
|
|
||||||
-1,
|
|
||||||
&statement,
|
|
||||||
NULL);
|
|
||||||
if (result == SQLITE_OK) {
|
|
||||||
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
|
|
||||||
}
|
|
||||||
if (result == SQLITE_OK) {
|
|
||||||
result = sqlite3_step(statement);
|
|
||||||
}
|
|
||||||
(void)sqlite3_finalize(statement);
|
|
||||||
return result == SQLITE_ROW ? TRAINLOG_STATUS_CONFLICT
|
|
||||||
: result == SQLITE_DONE ? TRAINLOG_STATUS_OK
|
|
||||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
|
static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
|
||||||
|
|
@ -1381,9 +1351,7 @@ static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database,
|
||||||
}
|
}
|
||||||
|
|
||||||
result = sqlite3_prepare_v2(database->connection,
|
result = sqlite3_prepare_v2(database->connection,
|
||||||
"SELECT 1 FROM program_requests WHERE request_id=?1 "
|
"SELECT 1 FROM program_requests WHERE request_id=?1",
|
||||||
"UNION ALL SELECT 1 FROM program_reschedule_operations "
|
|
||||||
"WHERE operation_id=?1",
|
|
||||||
-1,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
NULL);
|
NULL);
|
||||||
|
|
@ -1635,7 +1603,7 @@ static char *program_build_preparation_body(yyjson_val *session,
|
||||||
command,
|
command,
|
||||||
root,
|
root,
|
||||||
"planned_for",
|
"planned_for",
|
||||||
yyjson_val_mut_copy(command, yyjson_obj_get(session, "current_for")));
|
yyjson_val_mut_copy(command, yyjson_obj_get(session, "planned_for")));
|
||||||
(void)yyjson_mut_obj_add_val(
|
(void)yyjson_mut_obj_add_val(
|
||||||
command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note")));
|
command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note")));
|
||||||
(void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft");
|
(void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft");
|
||||||
|
|
@ -1678,10 +1646,6 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
||||||
yyjson_doc_free(document);
|
yyjson_doc_free(document);
|
||||||
return TRAINLOG_STATUS_NOT_FOUND;
|
return TRAINLOG_STATUS_NOT_FOUND;
|
||||||
}
|
}
|
||||||
if (!yyjson_equals_str(yyjson_obj_get(session, "planning_state"), "active")) {
|
|
||||||
yyjson_doc_free(document);
|
|
||||||
return TRAINLOG_STATUS_CONFLICT;
|
|
||||||
}
|
|
||||||
body = program_build_preparation_body(session, program_id, program_session_id, &body_size);
|
body = program_build_preparation_body(session, program_id, program_session_id, &body_size);
|
||||||
yyjson_doc_free(document);
|
yyjson_doc_free(document);
|
||||||
if (body == NULL) {
|
if (body == NULL) {
|
||||||
|
|
|
||||||
|
|
@ -705,22 +705,14 @@ static enum MHD_Result queue_owned_programs_json(struct MHD_Connection *connecti
|
||||||
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||||
}
|
}
|
||||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||||
enum MHD_Result result =
|
|
||||||
queue_json(connection,
|
|
||||||
MHD_HTTP_CONFLICT,
|
|
||||||
json == NULL ? "{\"error\":\"program_conflict\"}\n" : json,
|
|
||||||
NULL);
|
|
||||||
free(json);
|
free(json);
|
||||||
return result;
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL);
|
||||||
}
|
}
|
||||||
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
||||||
enum MHD_Result result =
|
|
||||||
queue_json(connection,
|
|
||||||
MHD_HTTP_UNPROCESSABLE_CONTENT,
|
|
||||||
json == NULL ? "{\"error\":\"invalid_program\"}\n" : json,
|
|
||||||
NULL);
|
|
||||||
free(json);
|
free(json);
|
||||||
return result;
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL);
|
||||||
}
|
}
|
||||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||||
}
|
}
|
||||||
|
|
@ -938,8 +930,6 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
||||||
static const char PREFIX[] = "/api/v1/sessions/program/";
|
static const char PREFIX[] = "/api/v1/sessions/program/";
|
||||||
static const char ARCHIVE_SUFFIX[] = "/archive";
|
static const char ARCHIVE_SUFFIX[] = "/archive";
|
||||||
static const char DELETE_SUFFIX[] = "/delete";
|
static const char DELETE_SUFFIX[] = "/delete";
|
||||||
static const char PREVIEW_SUFFIX[] = "/reschedule/preview";
|
|
||||||
static const char APPLY_SUFFIX[] = "/reschedule/apply";
|
|
||||||
const char *identity;
|
const char *identity;
|
||||||
const char *sessions_marker;
|
const char *sessions_marker;
|
||||||
char *json = NULL;
|
char *json = NULL;
|
||||||
|
|
@ -960,38 +950,6 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
||||||
identity = url + strlen(PREFIX);
|
identity = url + strlen(PREFIX);
|
||||||
identity_length = strlen(identity);
|
identity_length = strlen(identity);
|
||||||
sessions_marker = strstr(identity, "/sessions/");
|
sessions_marker = strstr(identity, "/sessions/");
|
||||||
if ((identity_length > strlen(PREVIEW_SUFFIX) &&
|
|
||||||
strcmp(identity + identity_length - strlen(PREVIEW_SUFFIX), PREVIEW_SUFFIX) == 0) ||
|
|
||||||
(identity_length > strlen(APPLY_SUFFIX) &&
|
|
||||||
strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0)) {
|
|
||||||
bool commit = strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0;
|
|
||||||
size_t suffix_length = commit ? strlen(APPLY_SUFFIX) : strlen(PREVIEW_SUFFIX);
|
|
||||||
char program_id[TRAINLOG_ID_MAX + 1U];
|
|
||||||
const char *operation_id =
|
|
||||||
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
|
|
||||||
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
|
|
||||||
return queue_json(connection,
|
|
||||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
|
||||||
"{\"error\":\"method_not_allowed\"}\n",
|
|
||||||
"POST");
|
|
||||||
}
|
|
||||||
if (!program_mutation_allowed(context, connection) ||
|
|
||||||
!copy_path_component(
|
|
||||||
program_id, sizeof(program_id), identity, identity_length - suffix_length) ||
|
|
||||||
(commit && (operation_id == NULL || operation_id[0] == '\0'))) {
|
|
||||||
return queue_json(
|
|
||||||
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
|
||||||
}
|
|
||||||
status = trainlog_web_programs_reschedule_json(context->database,
|
|
||||||
program_id,
|
|
||||||
operation_id,
|
|
||||||
request->body,
|
|
||||||
request->body_size,
|
|
||||||
commit,
|
|
||||||
&json,
|
|
||||||
&json_size);
|
|
||||||
return queue_owned_programs_json(connection, status, json, json_size);
|
|
||||||
}
|
|
||||||
if (identity_length > strlen(ARCHIVE_SUFFIX) &&
|
if (identity_length > strlen(ARCHIVE_SUFFIX) &&
|
||||||
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
|
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
|
||||||
return handle_program_archive(context, connection, identity, identity_length, method);
|
return handle_program_archive(context, connection, identity, identity_length, method);
|
||||||
|
|
@ -1935,14 +1893,6 @@ static enum MHD_Result handle_request(void *closure,
|
||||||
context->database, search, offset, limit, &json, &json_size);
|
context->database, search, offset, limit, &json, &json_size);
|
||||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||||
}
|
}
|
||||||
if (strcmp(url, "/api/v1/sessions/sport") == 0) {
|
|
||||||
const char *start =
|
|
||||||
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start");
|
|
||||||
const char *end = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "end");
|
|
||||||
TrainlogStatus status = trainlog_web_sessions_sport_json(
|
|
||||||
context->database, start, end, offset, limit, &json, &json_size);
|
|
||||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
|
||||||
}
|
|
||||||
for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) {
|
for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) {
|
||||||
if (strcmp(url, collections[index].path) == 0) {
|
if (strcmp(url, collections[index].path) == 0) {
|
||||||
TrainlogWebSessionsPageQuery query = {
|
TrainlogWebSessionsPageQuery query = {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
#include <yyjson.h>
|
#include <yyjson.h>
|
||||||
|
|
||||||
#include "database_internal.h"
|
#include "database_internal.h"
|
||||||
#include "timestamp.h"
|
|
||||||
#include "trainlog/id.h"
|
#include "trainlog/id.h"
|
||||||
|
|
||||||
typedef struct ListDefinition {
|
typedef struct ListDefinition {
|
||||||
|
|
@ -96,117 +95,6 @@ write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database,
|
|
||||||
const char *start,
|
|
||||||
const char *end,
|
|
||||||
size_t offset,
|
|
||||||
size_t limit,
|
|
||||||
char **output_json,
|
|
||||||
size_t *output_size) {
|
|
||||||
static const char SQL[] =
|
|
||||||
"SELECT s.session_id,s.session_type,s.started_at,s.ended_at,"
|
|
||||||
"COALESCE((SELECT group_concat(name, ', ') FROM (SELECT DISTINCT x.name AS name FROM "
|
|
||||||
"session_exercises se JOIN exercises x ON x.id=se.exercise_row_id WHERE "
|
|
||||||
"se.session_row_id=s.id ORDER BY se.position LIMIT 8)),s.session_type) "
|
|
||||||
"FROM sessions s WHERE unixepoch(s.started_at) IS NOT NULL AND "
|
|
||||||
"unixepoch(s.started_at)<unixepoch(?2) AND "
|
|
||||||
"(CASE WHEN unixepoch(s.ended_at)>unixepoch(s.started_at) "
|
|
||||||
"THEN unixepoch(s.ended_at)>unixepoch(?1) ELSE unixepoch(s.started_at)>=unixepoch(?1) "
|
|
||||||
"END) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
|
|
||||||
"c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=1) "
|
|
||||||
"ORDER BY unixepoch(s.started_at),s.session_id LIMIT ?3 OFFSET ?4";
|
|
||||||
sqlite3_stmt *statement = NULL;
|
|
||||||
yyjson_mut_doc *document = NULL;
|
|
||||||
yyjson_mut_val *root;
|
|
||||||
yyjson_mut_val *items;
|
|
||||||
TrainlogStatus status;
|
|
||||||
TrainlogStatus end_status;
|
|
||||||
size_t count = 0U;
|
|
||||||
int step = SQLITE_DONE;
|
|
||||||
TrainlogTimestampKey start_key;
|
|
||||||
TrainlogTimestampKey end_key;
|
|
||||||
|
|
||||||
if (database == NULL || start == NULL || end == NULL || output_json == NULL ||
|
|
||||||
output_size == NULL || limit == 0U || limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX ||
|
|
||||||
offset > INT64_MAX || strlen(start) > 40U || strlen(end) > 40U ||
|
|
||||||
!trainlog_timestamp_parse(start, strlen(start), &start_key) ||
|
|
||||||
!trainlog_timestamp_parse(end, strlen(end), &end_key) ||
|
|
||||||
trainlog_timestamp_compare(&start_key, &end_key) >= 0) {
|
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
||||||
}
|
|
||||||
*output_json = NULL;
|
|
||||||
*output_size = 0U;
|
|
||||||
status = trainlog_database_read_snapshot_begin(database);
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 1, start, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_text(statement, 2, end, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)(limit + 1U)) != SQLITE_OK ||
|
|
||||||
sqlite3_bind_int64(statement, 4, (sqlite3_int64)offset) != SQLITE_OK) {
|
|
||||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
goto finish_sport;
|
|
||||||
}
|
|
||||||
document = yyjson_mut_doc_new(NULL);
|
|
||||||
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
|
||||||
items = document == NULL ? NULL : yyjson_mut_arr(document);
|
|
||||||
if (root == NULL || items == NULL) {
|
|
||||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
||||||
goto finish_sport;
|
|
||||||
}
|
|
||||||
yyjson_mut_doc_set_root(document, root);
|
|
||||||
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
|
||||||
!yyjson_mut_obj_add_uint(document, root, "offset", offset) ||
|
|
||||||
!yyjson_mut_obj_add_val(document, root, "items", items)) {
|
|
||||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
||||||
goto finish_sport;
|
|
||||||
}
|
|
||||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
||||||
yyjson_mut_val *item;
|
|
||||||
if (count == limit) {
|
|
||||||
++count;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
item = yyjson_mut_obj(document);
|
|
||||||
if (item == NULL || !add_nullable_text(document, item, "identity", statement, 0) ||
|
|
||||||
!add_nullable_text(document, item, "session_type", statement, 1) ||
|
|
||||||
!add_nullable_text(document, item, "started_at", statement, 2) ||
|
|
||||||
!add_nullable_text(document, item, "ended_at", statement, 3) ||
|
|
||||||
!add_nullable_text(document, item, "label", statement, 4) ||
|
|
||||||
!yyjson_mut_arr_add_val(items, item)) {
|
|
||||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
goto finish_sport;
|
|
||||||
}
|
|
||||||
++count;
|
|
||||||
}
|
|
||||||
if (step != SQLITE_DONE && count <= limit) {
|
|
||||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
goto finish_sport;
|
|
||||||
}
|
|
||||||
if (!yyjson_mut_obj_add_bool(document, root, "more", count > limit) ||
|
|
||||||
!yyjson_mut_obj_add_uint(document, root, "next_offset", offset + limit)) {
|
|
||||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
||||||
goto finish_sport;
|
|
||||||
}
|
|
||||||
status = TRAINLOG_STATUS_OK;
|
|
||||||
|
|
||||||
finish_sport:
|
|
||||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
|
||||||
status == TRAINLOG_STATUS_OK) {
|
|
||||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
||||||
}
|
|
||||||
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
|
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
|
||||||
status = end_status;
|
|
||||||
}
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
|
||||||
yyjson_mut_doc_free(document);
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
return write_document(document, output_json, output_size);
|
|
||||||
}
|
|
||||||
|
|
||||||
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
|
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
|
||||||
const TrainlogWebSessionsPageQuery *query,
|
const TrainlogWebSessionsPageQuery *query,
|
||||||
char **output_json,
|
char **output_json,
|
||||||
|
|
|
||||||
|
|
@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic,
|
CHECK(strncmp(diagnostic,
|
||||||
"migrate database to schema v37: SQLite rc=",
|
"migrate database to schema v36: SQLite rc=",
|
||||||
strlen("migrate database to schema v37: SQLite rc=")) == 0);
|
strlen("migrate database to schema v36: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=38;", NULL, NULL, NULL) == SQLITE_OK);
|
CHECK(sqlite3_exec(raw, "PRAGMA user_version=37;", NULL, NULL, NULL) == SQLITE_OK);
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "schema version 38 is newer") != NULL);
|
CHECK(strstr(diagnostic, "schema version 37 is newer") != NULL);
|
||||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "migrate database to schema v37") != NULL);
|
CHECK(strstr(diagnostic, "migrate database to schema v36") != NULL);
|
||||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
|
|
@ -225,8 +225,7 @@ static bool test_factual_models(void) {
|
||||||
{
|
{
|
||||||
yyjson_val *program = yyjson_obj_get(root, "active_program");
|
yyjson_val *program = yyjson_obj_get(root, "active_program");
|
||||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_title")), "Next") == 0);
|
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_title")), "Next") == 0);
|
||||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") ==
|
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") == 0);
|
||||||
0);
|
|
||||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")),
|
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")),
|
||||||
"2026-09-21") == 0);
|
"2026-09-21") == 0);
|
||||||
}
|
}
|
||||||
|
|
@ -251,123 +250,6 @@ static bool test_factual_models(void) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool check_next_session(TrainlogDatabase *database,
|
|
||||||
const char *expected_id,
|
|
||||||
const char *expected_title,
|
|
||||||
const char *expected_date) {
|
|
||||||
yyjson_doc *document = NULL;
|
|
||||||
yyjson_val *program;
|
|
||||||
yyjson_val *id;
|
|
||||||
yyjson_val *title;
|
|
||||||
yyjson_val *date;
|
|
||||||
|
|
||||||
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
|
||||||
program = yyjson_obj_get(yyjson_doc_get_root(document), "active_program");
|
|
||||||
CHECK(!yyjson_is_null(program));
|
|
||||||
id = yyjson_obj_get(program, "next_session_id");
|
|
||||||
title = yyjson_obj_get(program, "next_session_title");
|
|
||||||
date = yyjson_obj_get(program, "next_session_planned_for");
|
|
||||||
CHECK(expected_id == NULL ? yyjson_is_null(id)
|
|
||||||
: yyjson_is_str(id) && strcmp(yyjson_get_str(id), expected_id) == 0);
|
|
||||||
CHECK(expected_title == NULL
|
|
||||||
? yyjson_is_null(title)
|
|
||||||
: yyjson_is_str(title) && strcmp(yyjson_get_str(title), expected_title) == 0);
|
|
||||||
CHECK(expected_date == NULL
|
|
||||||
? yyjson_is_null(date)
|
|
||||||
: yyjson_is_str(date) && strcmp(yyjson_get_str(date), expected_date) == 0);
|
|
||||||
yyjson_doc_free(document);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool test_current_program_plan(void) {
|
|
||||||
TrainlogDatabase *database = NULL;
|
|
||||||
yyjson_doc *document = NULL;
|
|
||||||
|
|
||||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
||||||
/* WHY: source dates remain historical; current planning and execution are
|
|
||||||
* independent facts. These rows exercise their precedence without touching
|
|
||||||
* any user database or invoking the rescheduling command. */
|
|
||||||
CHECK(sqlite3_exec(database->connection,
|
|
||||||
"INSERT INTO programs VALUES('pg_current','Current',NULL,'active',NULL,"
|
|
||||||
"NULL,'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1,"
|
|
||||||
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
|
|
||||||
"NULL);"
|
|
||||||
"INSERT INTO program_sessions VALUES"
|
|
||||||
"('ps_ceded','pg_current',0,'Optional','training','2026-09-29',NULL),"
|
|
||||||
"('ps_s2c','pg_current',1,'S2 C','training','2026-09-30',NULL),"
|
|
||||||
"('ps_later','pg_current',2,'Later','training','2026-10-03',NULL),"
|
|
||||||
"('ps_tie','pg_current',3,'Tie','training','2026-10-02',NULL),"
|
|
||||||
"('ps_undated','pg_current',4,'Undated','training',NULL,NULL);",
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL) == SQLITE_OK);
|
|
||||||
CHECK(check_next_session(database, "ps_ceded", "Optional", "2026-09-29"));
|
|
||||||
|
|
||||||
CHECK(sqlite3_exec(database->connection,
|
|
||||||
"INSERT INTO program_session_planning VALUES"
|
|
||||||
"('ps_ceded',NULL,'ceded','op_a','2026-10-01T00:00:00Z'),"
|
|
||||||
"('ps_s2c','2026-10-02','active','op_b','2026-10-01T00:00:00Z');",
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL) == SQLITE_OK);
|
|
||||||
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
|
|
||||||
|
|
||||||
CHECK(sqlite3_exec(database->connection,
|
|
||||||
"INSERT INTO program_session_executions VALUES"
|
|
||||||
"('ps_s2c','pg_current','se_fixture_1','in_progress',"
|
|
||||||
"'2026-10-02T12:00:00Z');",
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL) == SQLITE_OK);
|
|
||||||
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
|
|
||||||
|
|
||||||
CHECK(sqlite3_exec(database->connection,
|
|
||||||
"UPDATE program_session_executions SET state='completed' "
|
|
||||||
"WHERE program_session_id='ps_s2c';",
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL) == SQLITE_OK);
|
|
||||||
CHECK(check_next_session(database, "ps_tie", "Tie", "2026-10-02"));
|
|
||||||
CHECK(sqlite3_exec(database->connection,
|
|
||||||
"INSERT INTO program_session_executions VALUES"
|
|
||||||
"('ps_tie','pg_current','se_fixture_2','completed','2026-10-02T12:00:00Z'),"
|
|
||||||
"('ps_later','pg_current','se_fixture_3','deleted','2026-10-03T12:00:00Z');",
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL) == SQLITE_OK);
|
|
||||||
CHECK(check_next_session(database, "ps_undated", "Undated", NULL));
|
|
||||||
CHECK(sqlite3_exec(database->connection,
|
|
||||||
"INSERT INTO program_session_executions VALUES"
|
|
||||||
"('ps_undated','pg_current','se_fixture_4','completed',"
|
|
||||||
"'2026-10-04T12:00:00Z');",
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL) == SQLITE_OK);
|
|
||||||
CHECK(check_next_session(database, NULL, NULL, NULL));
|
|
||||||
|
|
||||||
CHECK(sqlite3_exec(database->connection,
|
|
||||||
"UPDATE programs SET state='archived' WHERE program_id='pg_current';",
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL) == SQLITE_OK);
|
|
||||||
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
|
||||||
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
|
|
||||||
yyjson_doc_free(document);
|
|
||||||
document = NULL;
|
|
||||||
|
|
||||||
CHECK(sqlite3_exec(database->connection,
|
|
||||||
"UPDATE programs SET state='active',deleted_at='2026-10-05T00:00:00Z' "
|
|
||||||
"WHERE program_id='pg_current';",
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL) == SQLITE_OK);
|
|
||||||
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
|
||||||
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
|
|
||||||
yyjson_doc_free(document);
|
|
||||||
trainlog_database_close(database);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
return test_empty_and_periods() && test_factual_models() && test_current_program_plan() ? 0 : 1;
|
return test_empty_and_periods() && test_factual_models() ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue