fix(program): add durable ordered reschedule candidate
This commit is contained in:
parent
c17394ece2
commit
6c0e7af2f0
80 changed files with 2032 additions and 132 deletions
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
## Unreleased — 0.1.7
|
||||
|
||||
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. This candidate
|
||||
is not merged into the stable installation or deployed to Android.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..33
|
||||
manifest.getInt("schema") in 17..34
|
||||
) {
|
||||
"Unsupported backup."
|
||||
}
|
||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
|||
}
|
||||
)
|
||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..34) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -370,6 +370,7 @@ internal class SyncGenerationCoordinator(
|
|||
ACK_RECOVERY_CAPABILITY,
|
||||
"session-preparations-v2",
|
||||
"programs-v1",
|
||||
"programs-v2",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
(ANDROID_KINDS.values +
|
||||
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
|
||||
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) +
|
||||
Kind("trainlog-programs", 1, "programs-v1.json", false))
|
||||
Kind("trainlog-programs", 1, "programs-v1.json", false) +
|
||||
Kind("trainlog-programs", 2, "programs-v2.json", false))
|
||||
.map { it.format to it.version }
|
||||
.toSet()
|
||||
|
||||
|
|
@ -773,6 +774,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
throw SyncGenerationException("artifact digest mismatch")
|
||||
artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8)
|
||||
}
|
||||
if ("programs-v1" in artifacts && "programs-v2" in artifacts)
|
||||
throw SyncGenerationException("multiple Program revisions in one generation")
|
||||
return ValidatedGeneration(manifest, sha256(raw), artifacts)
|
||||
}
|
||||
|
||||
|
|
@ -947,6 +950,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
apply("programs-v1") {
|
||||
repository.applyProgramsV1Json(checkNotNull(a["programs-v1"]))
|
||||
}
|
||||
if ("programs-v2" in a)
|
||||
apply("programs-v2") {
|
||||
repository.applyProgramsV2Json(checkNotNull(a["programs-v2"]))
|
||||
}
|
||||
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
|
||||
apply("exercise-profile-state") {
|
||||
repository.applyExerciseProfileStateJson(
|
||||
|
|
|
|||
|
|
@ -11825,7 +11825,11 @@ class TrainlogRepository(
|
|||
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
|
||||
* wins over a later stale snapshot and never touches drafts, preparations, history or catalog.
|
||||
*/
|
||||
fun applyProgramsV1Json(json: String): ProgramsImportResult {
|
||||
fun applyProgramsV1Json(json: String): ProgramsImportResult = applyProgramsJson(json, 1)
|
||||
|
||||
fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2)
|
||||
|
||||
private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult {
|
||||
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
|
||||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
|
||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||
|
|
@ -11853,7 +11857,7 @@ class TrainlogRepository(
|
|||
try {
|
||||
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
|
||||
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
|
||||
root.getInt("version") != 1 || !timestamp(root.opt("generated_at")) ||
|
||||
root.getInt("version") != documentVersion || !timestamp(root.opt("generated_at")) ||
|
||||
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
|
||||
throw ProgramsImportInvalid("Unsupported Programs document.")
|
||||
val programs = root.getJSONArray("programs")
|
||||
|
|
@ -11880,11 +11884,16 @@ class TrainlogRepository(
|
|||
for (programIndex in 0 until programs.length()) {
|
||||
val program = programs.optJSONObject(programIndex)
|
||||
?: throw ProgramsImportInvalid("Invalid program.")
|
||||
if (!program.exact(
|
||||
val programKeysValid = if (documentVersion == 2) program.exact(
|
||||
"program_id", "revision_id", "revision_sequence", "title", "note", "state", "start_date",
|
||||
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
||||
"source_payload_sha256", "sessions",
|
||||
) else program.exact(
|
||||
"program_id", "revision_id", "title", "note", "state", "start_date",
|
||||
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
||||
"source_payload_sha256", "sessions",
|
||||
) ||
|
||||
)
|
||||
if (!programKeysValid ||
|
||||
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
|
||||
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
|
||||
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
|
||||
|
|
@ -11894,17 +11903,33 @@ class TrainlogRepository(
|
|||
(program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
|
||||
program.opt("sessions") !is JSONArray)
|
||||
throw ProgramsImportInvalid("Invalid program metadata.")
|
||||
if (documentVersion == 2 && !integer(program.opt("revision_sequence"), 0, Int.MAX_VALUE))
|
||||
throw ProgramsImportInvalid("Invalid Program revision sequence.")
|
||||
val sessions = program.getJSONArray("sessions")
|
||||
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
|
||||
for (sessionIndex in 0 until sessions.length()) {
|
||||
val session = sessions.optJSONObject(sessionIndex)
|
||||
?: throw ProgramsImportInvalid("Invalid program session.")
|
||||
if (!session.exact("program_session_id", "title", "session_type", "planned_for", "note", "occurrences") ||
|
||||
val validKeys = if (documentVersion == 2) session.exact(
|
||||
"program_session_id", "position", "title", "session_type", "planned_for",
|
||||
"current_for", "planning_state", "note", "occurrences",
|
||||
) else session.exact(
|
||||
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences",
|
||||
)
|
||||
if (!validKeys ||
|
||||
!bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) ||
|
||||
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
|
||||
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
|
||||
session.opt("occurrences") !is JSONArray)
|
||||
throw ProgramsImportInvalid("Invalid program session metadata.")
|
||||
if (documentVersion == 2 &&
|
||||
(!integer(session.opt("position"), 0, 255) || session.getInt("position") != sessionIndex ||
|
||||
!date(session.opt("current_for")) ||
|
||||
session.optString("planning_state") !in setOf("active", "ceded") ||
|
||||
(session.optString("planning_state") == "ceded" && !session.isNull("current_for")) ||
|
||||
(session.optString("planning_state") == "active" &&
|
||||
session.isNull("current_for") && !session.isNull("planned_for"))))
|
||||
throw ProgramsImportInvalid("Invalid Program planning state.")
|
||||
val occurrences = session.getJSONArray("occurrences")
|
||||
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
|
||||
val entryIds = mutableSetOf<String>()
|
||||
|
|
@ -11930,6 +11955,12 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
data class ExistingProjection(
|
||||
val revisionId: String,
|
||||
val digest: String,
|
||||
val version: Int,
|
||||
val sequence: Long,
|
||||
)
|
||||
val db = database.writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
|
|
@ -11962,28 +11993,86 @@ class TrainlogRepository(
|
|||
.use { it.moveToFirst() }
|
||||
if (tombstoned) { skipped++; continue }
|
||||
val digest = causalDigest(program)
|
||||
val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id))
|
||||
.use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) }
|
||||
if (existing?.first == program.getString("revision_id")) {
|
||||
if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.")
|
||||
skipped++
|
||||
continue
|
||||
val existing = db.rawQuery(
|
||||
"SELECT revision_id,snapshot_sha256,projection_version,revision_sequence " +
|
||||
"FROM synced_programs WHERE program_id=?",
|
||||
arrayOf(id),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else ExistingProjection(
|
||||
cursor.getString(0), cursor.getString(1), cursor.getInt(2), cursor.getLong(3),
|
||||
)
|
||||
}
|
||||
val sequence = if (documentVersion == 2) program.getLong("revision_sequence") else 0L
|
||||
// WHY: a later V1 artifact cannot erase current dates or a
|
||||
// ceded slot after this phone has accepted Programs V2.
|
||||
if (existing != null && documentVersion < existing.version)
|
||||
throw ProgramsImportInvalid("Programs V2 capability required.")
|
||||
if (existing != null && documentVersion == 2 &&
|
||||
(sequence < existing.sequence ||
|
||||
(sequence == existing.sequence && existing.version == 2 &&
|
||||
existing.revisionId != program.getString("revision_id"))))
|
||||
throw ProgramsImportInvalid("Stale Program revision sequence.")
|
||||
if (existing?.revisionId == program.getString("revision_id")) {
|
||||
if (existing.digest == digest) { skipped++; continue }
|
||||
if (documentVersion != 2 || existing.version != 1 ||
|
||||
existing.digest != causalDigest(JSONObject(program.toString()).apply {
|
||||
remove("revision_sequence")
|
||||
getJSONArray("sessions").let { rows ->
|
||||
for (sessionIndex in 0 until rows.length()) {
|
||||
rows.getJSONObject(sessionIndex).apply {
|
||||
remove("position")
|
||||
remove("current_for")
|
||||
remove("planning_state")
|
||||
}
|
||||
}
|
||||
}
|
||||
})) throw ProgramsImportInvalid("Program revision identity conflict.")
|
||||
}
|
||||
if (documentVersion == 2) {
|
||||
val activeProgramSession = db.rawQuery(
|
||||
"SELECT source_program_session_id FROM active_session_draft " +
|
||||
"WHERE id=1 AND source_program_id=?",
|
||||
arrayOf(id),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
if (activeProgramSession != null) {
|
||||
val newSessions = program.getJSONArray("sessions")
|
||||
for (sessionIndex in 0 until newSessions.length()) {
|
||||
val candidate = newSessions.getJSONObject(sessionIndex)
|
||||
if (candidate.getString("program_session_id") == activeProgramSession) {
|
||||
val existingPlan = db.rawQuery(
|
||||
"SELECT current_for,planning_state FROM synced_program_sessions " +
|
||||
"WHERE program_session_id=?",
|
||||
arrayOf(activeProgramSession),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else
|
||||
(if (cursor.isNull(0)) null else cursor.getString(0)) to cursor.getString(1)
|
||||
}
|
||||
if (existingPlan != null &&
|
||||
(existingPlan.first != candidate.optStringOrNull("current_for") ||
|
||||
existingPlan.second != candidate.getString("planning_state")))
|
||||
throw ProgramsImportInvalid("Active Program draft blocks reschedule.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
db.delete("synced_programs", "program_id=?", arrayOf(id))
|
||||
db.execSQL(
|
||||
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"),
|
||||
program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
|
||||
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
|
||||
program.getInt("source_version"), program.getString("source_payload_sha256"), digest),
|
||||
program.getInt("source_version"), program.getString("source_payload_sha256"), digest,
|
||||
documentVersion, sequence),
|
||||
)
|
||||
val sessions = program.getJSONArray("sessions")
|
||||
for (sessionIndex in 0 until sessions.length()) {
|
||||
val session = sessions.getJSONObject(sessionIndex)
|
||||
val sessionId = session.getString("program_session_id")
|
||||
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?)", arrayOf<Any?>(
|
||||
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?,?,?)", arrayOf<Any?>(
|
||||
sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"),
|
||||
session.optStringOrNull("planned_for"), session.optStringOrNull("note")))
|
||||
session.optStringOrNull("planned_for"), session.optStringOrNull("note"),
|
||||
if (documentVersion == 2) session.optStringOrNull("current_for") else session.optStringOrNull("planned_for"),
|
||||
if (documentVersion == 2) session.getString("planning_state") else "active"))
|
||||
val entries = session.getJSONArray("occurrences")
|
||||
for (entryIndex in 0 until entries.length()) {
|
||||
val entry = entries.getJSONObject(entryIndex)
|
||||
|
|
@ -12052,7 +12141,7 @@ class TrainlogRepository(
|
|||
} ?: return null
|
||||
val sessions = mutableListOf<SyncedProgramSession>()
|
||||
db.rawQuery(
|
||||
"SELECT program_session_id,title,session_type,planned_for,note " +
|
||||
"SELECT program_session_id,title,session_type,planned_for,note,current_for,planning_state " +
|
||||
"FROM synced_program_sessions WHERE program_id=? " +
|
||||
"ORDER BY position,program_session_id",
|
||||
arrayOf(programId),
|
||||
|
|
@ -12095,6 +12184,8 @@ class TrainlogRepository(
|
|||
sessionCursor.getString(1),
|
||||
sessionCursor.getString(2),
|
||||
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
|
||||
if (sessionCursor.isNull(5)) null else sessionCursor.getString(5),
|
||||
sessionCursor.getString(6),
|
||||
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
|
||||
execution.first,
|
||||
execution.second,
|
||||
|
|
@ -12160,7 +12251,8 @@ class TrainlogRepository(
|
|||
val available = db.rawQuery(
|
||||
"SELECT s.session_type FROM synced_program_sessions s " +
|
||||
"JOIN synced_programs p ON p.program_id=s.program_id " +
|
||||
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active'",
|
||||
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active' " +
|
||||
"AND s.planning_state='active'",
|
||||
arrayOf(programId, programSessionId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
?: return StartProgramSessionResult.NotAvailable
|
||||
|
|
@ -12454,7 +12546,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
33,
|
||||
34,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -12808,6 +12900,34 @@ private class TrainlogDatabaseHelper(
|
|||
createSleepImmutabilityTriggers(db)
|
||||
version = 33
|
||||
}
|
||||
if (version < 34 && newVersion >= 34) {
|
||||
/* WHY: V1 retains the source date; V2 needs an independent current
|
||||
* target and a durable ceded-slot state. INVARIANT: no local draft
|
||||
* or completed workout is rewritten by the projection migration. */
|
||||
if (!tableHasColumn(db, "synced_programs", "projection_version")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE synced_programs ADD COLUMN projection_version INTEGER NOT NULL " +
|
||||
"DEFAULT 1 CHECK(projection_version IN(1,2))",
|
||||
)
|
||||
}
|
||||
if (!tableHasColumn(db, "synced_programs", "revision_sequence")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE synced_programs ADD COLUMN revision_sequence INTEGER NOT NULL " +
|
||||
"DEFAULT 0 CHECK(revision_sequence>=0)",
|
||||
)
|
||||
}
|
||||
if (!tableHasColumn(db, "synced_program_sessions", "current_for")) {
|
||||
db.execSQL("ALTER TABLE synced_program_sessions ADD COLUMN current_for TEXT")
|
||||
db.execSQL("UPDATE synced_program_sessions SET current_for=planned_for")
|
||||
}
|
||||
if (!tableHasColumn(db, "synced_program_sessions", "planning_state")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE synced_program_sessions ADD COLUMN planning_state TEXT NOT NULL " +
|
||||
"DEFAULT 'active' CHECK(planning_state IN('active','ceded'))",
|
||||
)
|
||||
}
|
||||
version = 34
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
|
|
@ -12824,7 +12944,9 @@ private class TrainlogDatabaseHelper(
|
|||
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
|
||||
start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
source_format TEXT NOT NULL, source_version INTEGER NOT NULL,
|
||||
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL
|
||||
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL,
|
||||
projection_version INTEGER NOT NULL DEFAULT 1 CHECK(projection_version IN(1,2)),
|
||||
revision_sequence INTEGER NOT NULL DEFAULT 0 CHECK(revision_sequence>=0)
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
|
|
@ -12833,7 +12955,9 @@ private class TrainlogDatabaseHelper(
|
|||
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
|
||||
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
|
||||
planned_for TEXT, note TEXT, UNIQUE(program_id,position)
|
||||
planned_for TEXT, note TEXT, current_for TEXT,
|
||||
planning_state TEXT NOT NULL DEFAULT 'active' CHECK(planning_state IN('active','ceded')),
|
||||
UNIQUE(program_id,position)
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ data class SyncedProgramSession(
|
|||
val title: String,
|
||||
val sessionType: String,
|
||||
val plannedFor: String?,
|
||||
val currentFor: String?,
|
||||
val planningState: String,
|
||||
val note: String?,
|
||||
val executionState: ProgramSessionExecutionState,
|
||||
val executionSessionId: String?,
|
||||
|
|
|
|||
|
|
@ -118,7 +118,12 @@ fun ProgramDetailScreen(
|
|||
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
|
||||
program.note?.let { TrainlogInfo(it) }
|
||||
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
|
||||
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
|
||||
val ceded = program.sessions.count { it.planningState == "ceded" }
|
||||
TrainlogInfo(
|
||||
"$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}" +
|
||||
if (ceded > 0) " · $ceded créneau(x) cédé(s)" else "",
|
||||
if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted,
|
||||
)
|
||||
}
|
||||
message?.let { TrainlogInfo(it) }
|
||||
program.sessions.forEachIndexed { index, session ->
|
||||
|
|
@ -145,12 +150,16 @@ fun ProgramDetailScreen(
|
|||
color = LocalTrainlogColors.current.text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
ProgramStateBadge(strings.getString(stateLabel), completed)
|
||||
ProgramStateBadge(
|
||||
if (session.planningState == "ceded" && !completed) "Créneau cédé"
|
||||
else strings.getString(stateLabel),
|
||||
completed,
|
||||
)
|
||||
}
|
||||
TrainlogInfo(
|
||||
listOfNotNull(
|
||||
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
|
||||
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||
session.currentFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
|
||||
).joinToString(" · "),
|
||||
)
|
||||
|
|
@ -164,7 +173,7 @@ fun ProgramDetailScreen(
|
|||
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
|
||||
)
|
||||
when (session.executionState) {
|
||||
ProgramSessionExecutionState.TODO -> TrainlogButton(
|
||||
ProgramSessionExecutionState.TODO -> if (session.planningState == "active") TrainlogButton(
|
||||
strings.getString(R.string.program_session_start),
|
||||
{ start(session.programSessionId) },
|
||||
Modifier.testTag("program-session-start-${session.programSessionId}"),
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
/* v13 intentionally adds the frozen machine identities and
|
||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
|||
reopened.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(33, it.version) }
|
||||
).use { assertEquals(34, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(33, it.version) }
|
||||
).use { assertEquals(34, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(33, db.version)
|
||||
assertEquals(34, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(33, db.version)
|
||||
assertEquals(34, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT session_type,session_kind FROM sessions",
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(33, db.version)
|
||||
assertEquals(34, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM exercises WHERE " +
|
||||
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(33, db.version)
|
||||
assertEquals(34, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
||||
"'cardio_guidance_runs','cardio_guidance_phases'," +
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest {
|
|||
migrated.listSleepDiary()
|
||||
migrated.close()
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
1,
|
||||
scalar(
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
|||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(34, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -70,6 +70,67 @@ class ProgramsProjectionRepositoryTest {
|
|||
.put("deletions", deletions)
|
||||
.toString()
|
||||
|
||||
private fun artifactV2(
|
||||
program: JSONObject,
|
||||
sequence: Int = 0,
|
||||
currentFor: String = "2026-10-01",
|
||||
): String {
|
||||
program.put("revision_sequence", sequence)
|
||||
val session = program.getJSONArray("sessions").getJSONObject(0)
|
||||
session.put("position", 0)
|
||||
session.put("current_for", currentFor)
|
||||
session.put("planning_state", "active")
|
||||
return JSONObject()
|
||||
.put("format", "trainlog-programs")
|
||||
.put("version", 2)
|
||||
.put("generated_at", "2026-09-18T12:00:00Z")
|
||||
.put("programs", JSONArray().put(program))
|
||||
.put("deletions", JSONArray())
|
||||
.toString()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v2UpgradePreservesCurrentDateAndRejectsLegacyDowngrade() {
|
||||
val name = "program-v2-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
|
||||
)
|
||||
val upgraded = program()
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV2Json(artifactV2(upgraded)),
|
||||
)
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
val stale = artifactV2(program())
|
||||
val newer = program().put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV2Json(artifactV2(newer, 1, "2026-10-02")),
|
||||
)
|
||||
assertTrue(repository.applyProgramsV2Json(stale) is ProgramsImportResult.Invalid)
|
||||
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
assertTrue(repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
|
||||
is ProgramsImportResult.Invalid)
|
||||
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertEquals("2026-10-02", repository.getSyncedProgram(program().getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun preparationArtifact(
|
||||
includeProgramProvenance: Boolean = true,
|
||||
deliveryState: String = "pending",
|
||||
|
|
@ -211,6 +272,12 @@ class ProgramsProjectionRepositoryTest {
|
|||
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(programId, active.sourceProgramId)
|
||||
assertEquals(programSessionId, active.sourceProgramSessionId)
|
||||
val changedPlan = program().apply {
|
||||
put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
|
||||
}
|
||||
assertTrue(repository.applyProgramsV2Json(artifactV2(changedPlan))
|
||||
is ProgramsImportResult.Invalid)
|
||||
assertEquals(null, repository.getSyncedProgram(programId)!!.sessions.single().currentFor)
|
||||
val performed = active.copy(
|
||||
exercises = active.exercises.map { occurrence ->
|
||||
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
|
||||
|
|
@ -218,6 +285,12 @@ class ProgramsProjectionRepositoryTest {
|
|||
)
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV2Json(artifactV2(changedPlan)),
|
||||
)
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(programId)!!
|
||||
.sessions.single().currentFor)
|
||||
assertEquals(
|
||||
ProgramSessionExecutionState.COMPLETED,
|
||||
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
|
||||
|
|
@ -238,6 +311,8 @@ class ProgramsProjectionRepositoryTest {
|
|||
try {
|
||||
val detail = repository.listSyncedPrograms().single()
|
||||
assertEquals(1, detail.sessionCount)
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(detail.programId)!!
|
||||
.sessions.single().currentFor)
|
||||
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
||||
assertEquals(1, repository.listSessions().size)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
|||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(1, repository.listPreparedSessions().size)
|
||||
repository.close()
|
||||
assertEquals("33", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals("34", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
"0",
|
||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ class SleepDiaryRepositoryTest {
|
|||
SQLiteDatabase.OPEN_READWRITE,
|
||||
)
|
||||
try {
|
||||
assertEquals(33, raw.version)
|
||||
assertEquals(34, raw.version)
|
||||
assertThrows(SQLiteException::class.java) {
|
||||
raw.execSQL(
|
||||
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
|
||||
|
|
|
|||
|
|
@ -878,7 +878,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(33, it.getInt(0))
|
||||
assertEquals(34, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -2382,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(33, cursor.getInt(0))
|
||||
assertEquals(34, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2509,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(33, cursor.getInt(0))
|
||||
assertEquals(34, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
# Android application
|
||||
|
||||
## Candidate schema v34: read-only Programs V2 projection
|
||||
|
||||
The isolated Program rescheduling candidate 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. This candidate is not installed on the personal phone.
|
||||
|
||||
## Sleep Diary V1 and active V2 companion
|
||||
|
||||
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
# Current implementation state
|
||||
|
||||
## Isolated Program rescheduling candidate
|
||||
|
||||
The `trainlog-program-recalage-v1` worktree contains a local candidate for
|
||||
explicit ordered Program rescheduling, desktop schema v37, Android schema v34,
|
||||
and the capability-selected `trainlog-programs` V2 projection. The stable
|
||||
0.1.7 installation and personal Program have not been migrated or rescheduled.
|
||||
The candidate passed local native, Web, Android JVM, build, and targeted
|
||||
sanitizer validation. It still requires coordinated PC/Android deployment
|
||||
before it is an operational feature. Completed-session calendar
|
||||
dates in stable 0.1.7 continue to come from their linked factual sessions.
|
||||
|
||||
Full-generation synchronization uses one generation/ACK business engine across
|
||||
versioned transports. On the paired 0.1.4 deployment, automatic local selection
|
||||
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
# Desktop database
|
||||
|
||||
## Candidate schema v37: Program planning
|
||||
|
||||
Schema v37 is additive to the isolated Program rescheduling candidate. 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 only in the candidate worktree/build. The stable
|
||||
installation's database remains at its existing schema until a separately
|
||||
authorized coordinated deployment and backup.
|
||||
|
||||
## Causal heart-rate tail correction (schema v36, no migration)
|
||||
|
||||
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
|
||||
|
|
|
|||
|
|
@ -68,3 +68,32 @@ source definition and derived preparations. A deleted program cannot be
|
|||
resurrected or prepared; an archived program cannot be prepared. The optional
|
||||
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
|
||||
projection and does not alter this import format.
|
||||
|
||||
## Local candidate: explicit ordered rescheduling
|
||||
|
||||
The isolated `trainlog-program-recalage-v1` candidate 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 candidate 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,5 +1,12 @@
|
|||
# Roadmap
|
||||
|
||||
The isolated Program cascade candidate is implemented in the
|
||||
`trainlog-program-recalage-v1` worktree and remains outside the stable 0.1.7
|
||||
installation. Its next gate is a coordinated PC/Android deployment review,
|
||||
followed by a newly calculated personal Program preview and separate user
|
||||
confirmation. No personal reschedule or publication is implied by candidate
|
||||
tests.
|
||||
|
||||
The controlled private rollout is complete for the paired daily installation:
|
||||
physical libmtp, verified backups, signing continuity, migrations, correlated
|
||||
ACKs, restart, and idempotent replay passed. Generation mode remains an
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ does not publish to it or let it override an artifact in the new endpoint.
|
|||
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
|
||||
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
|
||||
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
|
||||
| PC -> Android | `programs-v2.json` | candidate `trainlog-programs` v2 companion: original and current dates, ceded state, ordered revision sequence, and the same deletion tombstones |
|
||||
| Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance |
|
||||
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||
| Android -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 |
|
||||
|
|
@ -299,6 +300,33 @@ V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does
|
|||
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
|
||||
semantics.
|
||||
|
||||
### Isolated candidate: Programs V2 capability matrix
|
||||
|
||||
`trainlog-programs` V2 is a separate strict artifact selected only for a peer
|
||||
advertising `programs-v2`. Its session adds `position`, nullable `current_for`
|
||||
and `planning_state=active|ceded`; its Program adds `revision_sequence`.
|
||||
`planned_for`, stable IDs, source entries and tombstones retain their V1
|
||||
meaning. One generation contains exactly one Programs version. Android v34
|
||||
stores the V2 projection transactionally and rejects a lower sequence, a
|
||||
same-sequence different revision, a later V1 downgrade, or a changed plan for
|
||||
its currently active Program draft. Execution facts return through the
|
||||
unchanged Program-executions V1 companion and retain their actual timestamps.
|
||||
|
||||
| Desktop state | Android capability | Program result | Other domains |
|
||||
|---|---|---|---|
|
||||
| No reschedule | `programs-v1` only | V1 original plan | Normal generation |
|
||||
| No reschedule | `programs-v2` | V2 projection; V1 is omitted | Normal generation |
|
||||
| Rescheduled live Program | `programs-v2` | V2 absolute current plan | Normal generation |
|
||||
| Rescheduled live Program | V1 only, absent or stale V2 | Program artifact omitted; run reports incomplete Program convergence after ACK | Other artifacts still synchronize |
|
||||
| Program draft started offline after the last V2 ACK | `programs-v2` | Android rejects a changed plan without rewriting its draft; explicit conflict resolution is required | Generation rejection is durable |
|
||||
|
||||
The desktop commit requires that the latest generation to each previously
|
||||
contacted Android peer was an acknowledged V2 Program generation no older than
|
||||
five minutes. This bounds, but cannot eliminate, a phone-side race: Android's
|
||||
active-draft check is the final guard. A failed or downgraded peer cannot be
|
||||
reported as current for the Program domain. The existing generation, request,
|
||||
ACK, tombstone, transport, Sleep and HR wire contracts are unchanged.
|
||||
|
||||
## Optional Program executions companion V1
|
||||
|
||||
`trainlog-program-executions` V1 is an Android-to-desktop full-generation
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from pathlib import Path
|
|||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import export_programs
|
||||
import export_programs_v2
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
|
|
@ -79,6 +80,41 @@ class ProgramsExchangeTest(unittest.TestCase):
|
|||
"requested_at": "2026-09-18T12:00:00Z",
|
||||
}], payload["deletions"])
|
||||
|
||||
def test_v2_carries_current_date_and_ceded_state_without_changing_v1(self):
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE program_session_planning(
|
||||
program_session_id TEXT PRIMARY KEY,current_for TEXT,
|
||||
state TEXT,operation_id TEXT,updated_at TEXT);
|
||||
CREATE TABLE program_revision_sequences(
|
||||
program_id TEXT PRIMARY KEY,revision_sequence INTEGER NOT NULL);
|
||||
INSERT INTO program_revision_sequences VALUES('pg_live',1);
|
||||
INSERT INTO program_session_planning VALUES(
|
||||
'pgs_live','2026-10-01','active','op_fixture','2026-09-30T12:00:00Z');
|
||||
PRAGMA user_version=37;
|
||||
""")
|
||||
db.commit()
|
||||
initial = export_programs.export_programs(self.database)
|
||||
current = export_programs_v2.export_programs_v2(self.database)
|
||||
self.assertEqual(1, initial["version"])
|
||||
self.assertEqual(2, current["version"])
|
||||
original = initial["programs"][0]["sessions"][0]
|
||||
planned = current["programs"][0]["sessions"][0]
|
||||
self.assertIsNone(original["planned_for"])
|
||||
self.assertEqual(set(original), {
|
||||
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences"
|
||||
})
|
||||
self.assertEqual("2026-10-01", planned["current_for"])
|
||||
self.assertEqual(1, current["programs"][0]["revision_sequence"])
|
||||
self.assertEqual("active", planned["planning_state"])
|
||||
self.assertEqual(0, planned["position"])
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.execute("UPDATE program_session_planning SET current_for=NULL,state='ceded'")
|
||||
db.commit()
|
||||
ceded = export_programs_v2.export_programs_v2(self.database)
|
||||
self.assertIsNone(ceded["programs"][0]["sessions"][0]["current_for"])
|
||||
self.assertEqual("ceded", ceded["programs"][0]["sessions"][0]["planning_state"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||
self.assertEqual(inventory["product_version"], "0.1.7")
|
||||
self.assertEqual(inventory["desktop_schema"], 36)
|
||||
self.assertEqual(inventory["desktop_schema"], 37)
|
||||
self.assertEqual(inventory["android_schema"], 32)
|
||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||
self.assertIn("programs-v1", inventory["protocols"])
|
||||
|
|
|
|||
|
|
@ -71,6 +71,58 @@ class GenerationTest(unittest.TestCase):
|
|||
with closing(sqlite3.connect(destination)) as reopened:
|
||||
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
|
||||
|
||||
def test_rescheduled_program_requires_v2_capability_before_publication(self):
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE program_session_planning(
|
||||
program_session_id TEXT PRIMARY KEY,current_for TEXT,state TEXT);
|
||||
CREATE TABLE programs(program_id TEXT PRIMARY KEY,deleted_at TEXT);
|
||||
CREATE TABLE program_sessions(
|
||||
program_session_id TEXT PRIMARY KEY,program_id TEXT);
|
||||
INSERT INTO programs VALUES('pg_fixture',NULL);
|
||||
INSERT INTO program_sessions VALUES('pgs_fixture','pg_fixture');
|
||||
INSERT INTO program_session_planning VALUES('pgs_fixture','2026-10-01','active');
|
||||
PRAGMA user_version=37;
|
||||
""")
|
||||
db.commit()
|
||||
artifacts = (
|
||||
("history", "trainlog-mobile-export", 4, "history.json", True,
|
||||
"controlled.py", ()),
|
||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||
"export_programs.py", ()),
|
||||
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
|
||||
"export_programs_v2.py", ()),
|
||||
)
|
||||
|
||||
def exporter(tool, _extra, output, _snapshot):
|
||||
if tool == "controlled.py":
|
||||
output.write_text(json.dumps({"format": "trainlog-mobile-export", "version": 4}))
|
||||
return
|
||||
output.write_text(json.dumps({"format": "trainlog-programs",
|
||||
"version": 2 if tool.endswith("_v2.py") else 1,
|
||||
"programs": [], "deletions": []}))
|
||||
|
||||
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
|
||||
mock.patch.object(generation, "run_export", exporter):
|
||||
with self.assertRaisesRegex(generation.GenerationError, "Programs V2 capability"):
|
||||
generation.capture_desktop(self.database, self.root / "legacy", PEER_B, RUN, GEN)
|
||||
self.assertFalse((self.root / "legacy" / "staging" / GEN).exists())
|
||||
legacy_stage, legacy_manifest, _ = generation.capture_desktop(
|
||||
self.database, self.root / "legacy-domain-only", PEER_B, RUN, GEN,
|
||||
set(), True,
|
||||
)
|
||||
self.assertFalse((legacy_stage / "programs-v1.json").exists())
|
||||
self.assertEqual(["history"], [a["logical_name"] for a in legacy_manifest["artifacts"]])
|
||||
stage, manifest, _ = generation.capture_desktop(
|
||||
self.database, self.root / "modern", PEER_B, RUN,
|
||||
"gen_55555555-5555-4555-8555-555555555555",
|
||||
{"programs-v2"},
|
||||
)
|
||||
self.assertTrue((stage / "programs-v2.json").is_file())
|
||||
self.assertFalse((stage / "programs-v1.json").exists())
|
||||
self.assertEqual(["history", "programs-v2"],
|
||||
[a["logical_name"] for a in manifest["artifacts"]])
|
||||
|
||||
def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
|
||||
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
|
|
|
|||
|
|
@ -271,8 +271,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36):
|
||||
fail("desktop schema v33-v36 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36, 37):
|
||||
fail("desktop schema v33-v37 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -395,8 +395,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36):
|
||||
fail("desktop schema v34-v36 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36, 37):
|
||||
fail("desktop schema v34-v37 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
|||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise CausalError(f"desktop schema v20-v36 required, found v{version}")
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise CausalError(f"desktop schema v20-v37 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise LifecycleError(f"desktop schema v19-v36 required, found v{version}")
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise LifecycleError(f"desktop schema v19-v37 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
MEASUREMENT_FIELDS = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||
|
|
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
|
|||
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||
raise ValueError("schema desktop v15-v36 requis")
|
||||
raise ValueError("schema desktop v15-v37 requis")
|
||||
|
||||
root = {
|
||||
"format": "TRAINLOG_AI_EXPORT",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v18-v36 requis")
|
||||
raise ValueError("schema desktop v18-v37 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = tuple(range(8, 37))
|
||||
supported_versions = tuple(range(8, 38))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v36 requis")
|
||||
raise ValueError("schema desktop v8 à v37 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(8, 37))
|
||||
supported_versions = tuple(range(8, 38))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v36 requis")
|
||||
raise ValueError("schema desktop v8 à v37 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal equipment protection requires the staged artifact")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ def main():
|
|||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise ValueError("schema desktop v12-v36 requis")
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise ValueError("schema desktop v12-v37 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(11, 37))
|
||||
supported_versions = tuple(range(11, 38))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v11-v36 requis")
|
||||
raise ValueError("schema desktop v11-v37 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||
exercises = []
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in range(8, 37):
|
||||
if version not in range(8, 38):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
supported_versions = range(11, 37)
|
||||
supported_versions = range(11, 38)
|
||||
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
||||
raise ValueError("schema desktop v11-v36 requis (v10 accepté pour export V2 explicite)")
|
||||
raise ValueError("schema desktop v11-v37 requis (v10 accepté pour export V2 explicite)")
|
||||
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ def nullable(row, index):
|
|||
|
||||
def export_programs(database: Path) -> dict:
|
||||
with closing(connect_database(database)) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise ValueError("desktop schema v26 through v32 required")
|
||||
programs = []
|
||||
for row in db.execute(
|
||||
|
|
|
|||
59
tools/export_programs_v2.py
Normal file
59
tools/export_programs_v2.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export the versioned desktop-owned Program planning projection."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from export_programs import export_programs
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
def export_programs_v2(database: Path) -> dict:
|
||||
# WHY: V1's strict session fields are frozen. V2 carries absolute current
|
||||
# dates and ceded-slot state without changing the imported source date.
|
||||
value = export_programs(database)
|
||||
with closing(connect_database(database)) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 37:
|
||||
raise ValueError("desktop schema v37 required for Programs V2")
|
||||
for program in value["programs"]:
|
||||
sequence = db.execute(
|
||||
"SELECT revision_sequence FROM program_revision_sequences WHERE program_id=?",
|
||||
(program["program_id"],),
|
||||
).fetchone()
|
||||
program["revision_sequence"] = sequence[0] if sequence is not None else 0
|
||||
planning = {
|
||||
row[0]: (row[1], row[2])
|
||||
for row in db.execute(
|
||||
"SELECT p.program_session_id,p.current_for,p.state "
|
||||
"FROM program_session_planning p JOIN program_sessions s "
|
||||
"ON s.program_session_id=p.program_session_id WHERE s.program_id=?",
|
||||
(program["program_id"],),
|
||||
)
|
||||
}
|
||||
for position, session in enumerate(program["sessions"]):
|
||||
current, state = planning.get(
|
||||
session["program_session_id"], (session["planned_for"], "active")
|
||||
)
|
||||
session["position"] = position
|
||||
session["current_for"] = current
|
||||
session["planning_state"] = state
|
||||
value["version"] = 2
|
||||
return value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.output.write_text(
|
||||
json.dumps(export_programs_v2(args.database), ensure_ascii=False,
|
||||
sort_keys=True, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
|
|||
|
||||
|
||||
def build_export(connection: sqlite3.Connection) -> dict:
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("desktop schema v24, v25, v26 through v32 required")
|
||||
deliveries = []
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
|
|||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(15, 37)
|
||||
supported_versions = range(15, 38)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v15-v36 requis")
|
||||
raise ValueError("schema desktop v15-v37 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ def split_target(value: str) -> tuple[str, str]:
|
|||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in range(27, 37):
|
||||
raise CorrectionError(f"heart-rate correction requires schema v27-v36, found v{version}")
|
||||
if version not in range(27, 38):
|
||||
raise CorrectionError(f"heart-rate correction requires schema v27-v37, found v{version}")
|
||||
|
||||
|
||||
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -282,8 +282,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
|
||||
fail("desktop schema v30-v36 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v30-v37 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
|
|||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||
any failure leaves no draft, child, or import-identity row behind.
|
||||
"""
|
||||
supported_versions = range(18, 37)
|
||||
supported_versions = range(18, 38)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v18-v36 requis")
|
||||
raise ImportFailure("schema desktop v18-v37 requis")
|
||||
draft, entries = validate(payload, connection)
|
||||
digest = canonical_digest(payload)
|
||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -206,9 +206,9 @@ def main():
|
|||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(8, 37)
|
||||
supported_versions = range(8, 38)
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v36 requis")
|
||||
fail("schema desktop v8 à v37 requis")
|
||||
proof_path = args.mobile_export
|
||||
if proof_path is None:
|
||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||
|
|
|
|||
|
|
@ -53,9 +53,9 @@ def validate(payload, reserved):
|
|||
|
||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||
"""Apply validated definitions without owning the surrounding transaction."""
|
||||
supported_versions = range(8, 37)
|
||||
supported_versions = range(8, 38)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v36 requis")
|
||||
fail("schema desktop v8 à v37 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
fail("causal equipment protection requires the staged artifact")
|
||||
imported = skipped = 0
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
supported_versions = range(12, 37)
|
||||
supported_versions = range(12, 38)
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v12-v36 requis")
|
||||
fail("schema desktop v12-v37 requis")
|
||||
for source, canonical in aliases:
|
||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||
if target is None: fail(f"cible canonique absente: {canonical}")
|
||||
|
|
|
|||
|
|
@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
|
|||
|
||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||
"""Apply validated zone rows without owning transaction boundaries."""
|
||||
supported_versions = range(11, 37)
|
||||
supported_versions = range(11, 38)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v11-v36 requis")
|
||||
raise ImportFailure("schema desktop v11-v37 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
|
|||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
fail("schema desktop v17-v36 requis")
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("schema desktop v17-v37 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -847,9 +847,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in range(5, 37):
|
||||
if version not in range(5, 38):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v36 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v37 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
|
|||
require_supported_schema(connection)
|
||||
|
||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36")
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v37")
|
||||
if schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
supported_versions = range(15, 37)
|
||||
supported_versions = range(15, 38)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v15-v36 requis")
|
||||
fail("schema desktop v15-v37 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
||||
seen=set()
|
||||
for index,item in enumerate(root["exercise_feedback"]):
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ RUNTIME_TOOLS = [
|
|||
"post_sync_ai_drive.py",
|
||||
"export_session_preparations.py",
|
||||
"export_programs.py",
|
||||
"export_programs_v2.py",
|
||||
"program_execution_exchange.py",
|
||||
]
|
||||
|
||||
|
|
@ -120,7 +121,7 @@ def main() -> int:
|
|||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.7",
|
||||
"desktop_schema": 36,
|
||||
"desktop_schema": 37,
|
||||
"android_schema": 32,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ def load(path: Path) -> dict:
|
|||
|
||||
|
||||
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v27 through v32 required")
|
||||
added = 0
|
||||
advanced = 0
|
||||
|
|
|
|||
|
|
@ -184,8 +184,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36):
|
||||
fail("desktop schema v31-v36 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v31-v37 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -637,8 +637,8 @@ def main() -> None:
|
|||
import_command.add_argument("--database", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
|
||||
fail("desktop schema v30-v36 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v30-v37 required")
|
||||
if args.command == "export":
|
||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")), encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ ARTIFACTS = (
|
|||
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||
"export_programs.py", ()),
|
||||
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
|
||||
"export_programs_v2.py", ()),
|
||||
("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False,
|
||||
"sleep_diary_exchange.py", ("export",)),
|
||||
)
|
||||
|
|
@ -221,9 +223,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
|||
|
||||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise GenerationError("desktop schema v24 through v36 required")
|
||||
raise GenerationError("desktop schema v24 through v37 required")
|
||||
|
||||
|
||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||
|
|
@ -366,7 +368,9 @@ def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path)
|
|||
|
||||
|
||||
def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
||||
run_id: str | None = None, generation_id: str | None = None):
|
||||
run_id: str | None = None, generation_id: str | None = None,
|
||||
peer_capabilities: set[str] | None = None,
|
||||
allow_program_skip: bool = False):
|
||||
if not valid_id(consumer, "peer"):
|
||||
raise GenerationError("invalid expected consumer")
|
||||
generation = generation_id or "gen_" + str(uuid.uuid4())
|
||||
|
|
@ -405,9 +409,35 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
producer = peer_identity(source, "desktop")
|
||||
source.commit()
|
||||
source.backup(target)
|
||||
with closing(connect_database(snapshot)) as planning_db:
|
||||
has_planning_table = planning_db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
||||
"AND name='program_session_planning'"
|
||||
).fetchone() is not None
|
||||
has_reschedule = has_planning_table and planning_db.execute(
|
||||
"SELECT 1 FROM program_session_planning plan "
|
||||
"JOIN program_sessions session ON session.program_session_id=plan.program_session_id "
|
||||
"JOIN programs program ON program.program_id=session.program_id "
|
||||
"WHERE program.deleted_at IS NULL LIMIT 1"
|
||||
).fetchone() is not None
|
||||
supports_v2 = peer_capabilities is not None and "programs-v2" in peer_capabilities
|
||||
# CONTRACT: one generation contains exactly one Program revision per
|
||||
# identity. A V1-only peer cannot preserve a changed current schedule.
|
||||
# INVARIANT: reject before publication rather than claiming convergence.
|
||||
skip_programs = has_reschedule and not supports_v2
|
||||
if skip_programs and not allow_program_skip:
|
||||
raise GenerationError("Programs V2 capability required for rescheduled programs")
|
||||
if supports_v2 and not has_planning_table:
|
||||
raise GenerationError("Programs V2 requires desktop schema v37")
|
||||
descriptors = []
|
||||
total = 0
|
||||
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
|
||||
if skip_programs and logical in ("programs-v1", "programs-v2"):
|
||||
continue
|
||||
if logical == "programs-v1" and supports_v2:
|
||||
continue
|
||||
if logical == "programs-v2" and not supports_v2:
|
||||
continue
|
||||
output = stage / filename
|
||||
run_export(tool, extra, output, snapshot)
|
||||
size, checksum = digest_file(output)
|
||||
|
|
@ -521,7 +551,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
|
||||
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
|
||||
(operation["operation_id"], generation, 0 if existed else 1))
|
||||
programs_path = stage / "programs-v1.json"
|
||||
programs_path = stage / ("programs-v2.json" if supports_v2 else "programs-v1.json")
|
||||
if programs_path.is_file():
|
||||
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
|
||||
for deletion in captured_programs["deletions"]:
|
||||
|
|
|
|||
|
|
@ -827,8 +827,13 @@ def main() -> int:
|
|||
# diagnostics to the separately bounded stderr channel.
|
||||
with redirect_stdout(sys.stderr):
|
||||
stage, manifest, digest = generation.capture_desktop(
|
||||
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id
|
||||
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id,
|
||||
set(peer["capabilities"]), True,
|
||||
)
|
||||
program_domain_incompatible = not any(
|
||||
artifact["logical_name"] in ("programs-v1", "programs-v2")
|
||||
for artifact in manifest["artifacts"]
|
||||
)
|
||||
del stage
|
||||
published = generation.publish(
|
||||
args.database, outgoing_id, args.transport_root / "desktop-objects"
|
||||
|
|
@ -926,6 +931,14 @@ def main() -> int:
|
|||
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
|
||||
raise RuntimeError("generation evidence did not corroborate completion")
|
||||
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
|
||||
if program_domain_incompatible:
|
||||
# WHY: the old peer cannot retain the new absolute schedule, but its
|
||||
# unrelated domains can still converge through the same generation.
|
||||
# CONTRACT: never report whole-run success while its Program view is stale.
|
||||
raise RuntimeError(
|
||||
"Programs V2 capability required: other domains synchronized, "
|
||||
"Program planning not published to this peer"
|
||||
)
|
||||
ai_post_sync = publish_ai_export_to_android(args, deadline)
|
||||
report = {
|
||||
"format": "trainlog-sync-worker-report",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 36
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 37
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,4 +63,17 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
|||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
/* WHY: original Program dates are immutable source evidence. CONTRACT: the
|
||||
* preview is read only; commit requires its exact fact/choice fingerprint,
|
||||
* current Program revision and a stable operation ID. INVARIANT: one commit
|
||||
* changes absolute planning dates in one transaction, never history rows. */
|
||||
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
|
||||
const char *program_id,
|
||||
const char *operation_id,
|
||||
const char *body,
|
||||
size_t body_size,
|
||||
bool commit,
|
||||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ trainlog_core_sources = files(
|
|||
'src/web_sessions.c',
|
||||
'src/web_session_deletions.c',
|
||||
'src/web_programs.c',
|
||||
'src/web_program_reschedule.c',
|
||||
'src/web_sleep.c',
|
||||
'src/web_exercises.c',
|
||||
'src/web_equipment.c',
|
||||
|
|
|
|||
|
|
@ -1578,6 +1578,39 @@ static const char *const MIGRATE_V35_TO_V36_SQL =
|
|||
"immutable');END;"
|
||||
"PRAGMA user_version=36;COMMIT;";
|
||||
|
||||
/* WHY: a Program's imported date is evidence, while an explicit reschedule
|
||||
* owns its current target and an optional relinquishment of a future slot.
|
||||
* CONTRACT: absent planning rows retain the original date and active state;
|
||||
* operation identities bind replay to an exact request digest. INVARIANT:
|
||||
* migration changes no execution, preparation, source date, or history row. */
|
||||
static const char *const MIGRATE_V36_TO_V37_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TABLE IF NOT EXISTS program_session_planning("
|
||||
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
|
||||
"ON DELETE RESTRICT,"
|
||||
"current_for TEXT,"
|
||||
"state TEXT NOT NULL CHECK(state IN('active','ceded')),"
|
||||
"operation_id TEXT NOT NULL,updated_at TEXT NOT NULL,"
|
||||
"CHECK((state='active' AND current_for IS NOT NULL) OR "
|
||||
"(state='ceded' AND current_for IS NULL)));"
|
||||
"CREATE TABLE IF NOT EXISTS program_reschedule_operations("
|
||||
"operation_id TEXT PRIMARY KEY,"
|
||||
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
||||
"request_sha256 TEXT NOT NULL CHECK(length(request_sha256)=64),"
|
||||
"expected_revision TEXT NOT NULL,"
|
||||
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
|
||||
"CREATE INDEX IF NOT EXISTS program_reschedule_operations_program "
|
||||
"ON program_reschedule_operations(program_id,created_at);"
|
||||
"CREATE TABLE IF NOT EXISTS program_revision_sequences("
|
||||
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
||||
"revision_sequence INTEGER NOT NULL CHECK(revision_sequence>=1));"
|
||||
"CREATE TRIGGER IF NOT EXISTS programs_revision_sequence AFTER UPDATE OF revision_id "
|
||||
"ON programs WHEN OLD.revision_id<>NEW.revision_id BEGIN "
|
||||
"INSERT INTO program_revision_sequences(program_id,revision_sequence) "
|
||||
"VALUES(NEW.program_id,1) ON CONFLICT(program_id) DO UPDATE SET "
|
||||
"revision_sequence=revision_sequence+1;END;"
|
||||
"PRAGMA user_version=37;COMMIT;";
|
||||
|
||||
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
|
||||
* before medication capture joined the same unreleased migration. CONTRACT:
|
||||
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
||||
|
|
@ -2413,7 +2446,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
||||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
||||
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
|
||||
version == 36) {
|
||||
version == 36 || version == 37) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -2585,6 +2618,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status == TRAINLOG_STATUS_OK && version < 36) {
|
||||
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 37) {
|
||||
status = execute_sql(database, MIGRATE_V36_TO_V37_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = ensure_v18_ai_draft_publication_state(database);
|
||||
}
|
||||
|
|
@ -2604,7 +2640,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status != TRAINLOG_STATUS_OK) {
|
||||
set_open_diagnostic(output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v36" : "migrate database to schema v36",
|
||||
version == 0 ? "create schema v37" : "migrate database to schema v37",
|
||||
database->connection,
|
||||
SQLITE_ERROR);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
|
|
|
|||
645
tui/src/web_program_reschedule.c
Normal file
645
tui/src/web_program_reschedule.c
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
#include "trainlog/web_programs.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <openssl/evp.h>
|
||||
#include <sqlite3.h>
|
||||
#include <yyjson.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "trainlog/id.h"
|
||||
|
||||
typedef struct PlanningRow {
|
||||
const char *id;
|
||||
const char *original;
|
||||
const char *current;
|
||||
const char *execution;
|
||||
const char *planning;
|
||||
const char *assigned;
|
||||
bool cede;
|
||||
} PlanningRow;
|
||||
|
||||
static TrainlogStatus
|
||||
planning_error(const char *reason, TrainlogStatus status, char **output, size_t *size) {
|
||||
size_t length = strlen(reason) + 16U;
|
||||
*output = malloc(length);
|
||||
if (*output == NULL) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
*size = (size_t)snprintf(*output, length, "{\"error\":\"%s\"}", reason);
|
||||
return status;
|
||||
}
|
||||
|
||||
static bool planning_digest(
|
||||
const char *first, size_t first_size, const char *second, size_t second_size, char output[65]) {
|
||||
EVP_MD_CTX *context = EVP_MD_CTX_new();
|
||||
unsigned char digest[32];
|
||||
unsigned int length = 0U;
|
||||
bool okay = context != NULL && EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
|
||||
EVP_DigestUpdate(context, first, first_size) == 1 &&
|
||||
EVP_DigestUpdate(context, second, second_size) == 1 &&
|
||||
EVP_DigestFinal_ex(context, digest, &length) == 1 && length == 32U;
|
||||
EVP_MD_CTX_free(context);
|
||||
if (!okay) {
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0U; index < 32U; ++index) {
|
||||
(void)snprintf(output + index * 2U, 3U, "%02x", digest[index]);
|
||||
}
|
||||
output[64] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool valid_civil_date(const char *value) {
|
||||
unsigned year, month, day;
|
||||
int days;
|
||||
if (value == NULL || strlen(value) != 10U || value[4] != '-' || value[7] != '-' ||
|
||||
sscanf(value, "%4u-%2u-%2u", &year, &month, &day) != 3 || year < 1900U || year > 9999U ||
|
||||
month < 1U || month > 12U) {
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0U; index < 10U; ++index) {
|
||||
if (index != 4U && index != 7U && (value[index] < '0' || value[index] > '9')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
static const int MONTH_DAYS[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
|
||||
days = MONTH_DAYS[month - 1U];
|
||||
if (month == 2U && (year % 4U == 0U && (year % 100U != 0U || year % 400U == 0U))) {
|
||||
days = 29;
|
||||
}
|
||||
return day >= 1U && day <= (unsigned)days;
|
||||
}
|
||||
|
||||
static bool
|
||||
add_nullable(yyjson_mut_doc *document, yyjson_mut_val *object, const char *key, const char *value) {
|
||||
return value == NULL ? yyjson_mut_obj_add_null(document, object, key)
|
||||
: yyjson_mut_obj_add_strcpy(document, object, key, value);
|
||||
}
|
||||
|
||||
static bool request_valid(yyjson_val *root, bool commit) {
|
||||
yyjson_obj_iter iterator;
|
||||
yyjson_val *key;
|
||||
size_t count = 0U;
|
||||
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != (commit ? 6U : 5U)) {
|
||||
return false;
|
||||
}
|
||||
iterator = yyjson_obj_iter_with(root);
|
||||
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
|
||||
const char *name = yyjson_get_str(key);
|
||||
if (strcmp(name, "start_session_id") != 0 && strcmp(name, "through_session_id") != 0 &&
|
||||
strcmp(name, "start_date") != 0 && strcmp(name, "ceded_session_ids") != 0 &&
|
||||
strcmp(name, "expected_revision") != 0 &&
|
||||
!(commit && strcmp(name, "preview_sha256") == 0)) {
|
||||
return false;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
return count == (commit ? 6U : 5U) && yyjson_is_str(yyjson_obj_get(root, "start_session_id")) &&
|
||||
yyjson_is_str(yyjson_obj_get(root, "through_session_id")) &&
|
||||
yyjson_is_str(yyjson_obj_get(root, "expected_revision")) &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "start_session_id")) <= 128U &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "through_session_id")) <= 128U &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "expected_revision")) <= 128U &&
|
||||
valid_civil_date(yyjson_get_str(yyjson_obj_get(root, "start_date"))) &&
|
||||
yyjson_is_arr(yyjson_obj_get(root, "ceded_session_ids")) &&
|
||||
yyjson_arr_size(yyjson_obj_get(root, "ceded_session_ids")) <= 64U &&
|
||||
(!commit || (yyjson_is_str(yyjson_obj_get(root, "preview_sha256")) &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "preview_sha256")) == 64U));
|
||||
}
|
||||
|
||||
static bool has_preparation(TrainlogDatabase *database, const char *session_id) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
bool present = true;
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM session_preparations WHERE "
|
||||
"source_program_session_id=? AND withdrawn_at IS NULL LIMIT 1",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) == SQLITE_OK &&
|
||||
sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) == SQLITE_OK) {
|
||||
present = sqlite3_step(statement) != SQLITE_DONE;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return present;
|
||||
}
|
||||
|
||||
static TrainlogStatus peer_planning_fresh(TrainlogDatabase *database) {
|
||||
static const char SQL[] =
|
||||
"SELECT EXISTS(SELECT 1 FROM "
|
||||
"(SELECT DISTINCT consumer_peer_id FROM sync_generations "
|
||||
"WHERE producer_kind='desktop') peer WHERE "
|
||||
"NOT EXISTS(SELECT 1 FROM sync_generations generation "
|
||||
"JOIN sync_generation_artifacts artifact "
|
||||
"ON artifact.generation_id=generation.generation_id "
|
||||
"WHERE generation.generation_id=(SELECT latest.generation_id "
|
||||
"FROM sync_generations latest WHERE latest.consumer_peer_id=peer.consumer_peer_id "
|
||||
"AND latest.producer_kind='desktop' "
|
||||
"ORDER BY julianday(latest.generated_at) DESC,latest.generation_id DESC LIMIT 1) "
|
||||
"AND generation.status='acknowledged' AND artifact.logical_name='programs-v2' "
|
||||
"AND julianday(generation.acknowledged_at)>=julianday('now','-5 minutes')))";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(statement) == SQLITE_ROW) {
|
||||
status =
|
||||
sqlite3_column_int(statement, 0) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return status;
|
||||
}
|
||||
|
||||
static TrainlogStatus build_preview(TrainlogDatabase *database,
|
||||
const char *program_id,
|
||||
yyjson_val *request,
|
||||
char **output,
|
||||
size_t *output_size) {
|
||||
char *detail = NULL;
|
||||
size_t detail_size = 0U;
|
||||
yyjson_doc *source = NULL;
|
||||
yyjson_mut_doc *response = NULL;
|
||||
yyjson_val *program;
|
||||
yyjson_val *sessions;
|
||||
yyjson_val *ceded;
|
||||
yyjson_mut_val *root;
|
||||
yyjson_mut_val *moves;
|
||||
PlanningRow rows[64] = {0};
|
||||
const char *slots[64] = {0};
|
||||
size_t first = 64U;
|
||||
size_t last = 64U;
|
||||
size_t count;
|
||||
size_t slot_count = 0U;
|
||||
size_t next_slot = 0U;
|
||||
char fingerprint[65];
|
||||
char *choices = NULL;
|
||||
size_t choices_size = 0U;
|
||||
TrainlogStatus status =
|
||||
trainlog_web_programs_detail_json(database, program_id, &detail, &detail_size);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
return status;
|
||||
}
|
||||
source = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
|
||||
program = source == NULL ? NULL : yyjson_doc_get_root(source);
|
||||
sessions = program == NULL ? NULL : yyjson_obj_get(program, "sessions");
|
||||
ceded = yyjson_obj_get(request, "ceded_session_ids");
|
||||
count = yyjson_arr_size(sessions);
|
||||
if (program == NULL || count > 64U ||
|
||||
!yyjson_equals_str(yyjson_obj_get(program, "state"), "active") ||
|
||||
!yyjson_equals_str(yyjson_obj_get(program, "revision_id"),
|
||||
yyjson_get_str(yyjson_obj_get(request, "expected_revision"))) ||
|
||||
!yyjson_is_str(yyjson_obj_get(program, "end_date"))) {
|
||||
status = planning_error(
|
||||
"stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
for (size_t index = 0U; index < count; ++index) {
|
||||
yyjson_val *session = yyjson_arr_get(sessions, index);
|
||||
rows[index].id = yyjson_get_str(yyjson_obj_get(session, "program_session_id"));
|
||||
rows[index].original = yyjson_get_str(yyjson_obj_get(session, "planned_for"));
|
||||
rows[index].current = yyjson_get_str(yyjson_obj_get(session, "current_for"));
|
||||
rows[index].execution = yyjson_get_str(yyjson_obj_get(session, "execution_state"));
|
||||
rows[index].planning = yyjson_get_str(yyjson_obj_get(session, "planning_state"));
|
||||
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "start_session_id"))) ==
|
||||
0) {
|
||||
first = index;
|
||||
}
|
||||
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "through_session_id"))) ==
|
||||
0) {
|
||||
last = index;
|
||||
}
|
||||
}
|
||||
if (first >= count || last >= count || first > last ||
|
||||
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
|
||||
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
|
||||
status = planning_error(
|
||||
"invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
|
||||
yyjson_val *id = yyjson_arr_get(ceded, index);
|
||||
bool matched = false;
|
||||
if (!yyjson_is_str(id) || yyjson_get_len(id) > 128U) {
|
||||
status = planning_error(
|
||||
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
for (size_t candidate = first; candidate <= last; ++candidate) {
|
||||
if (strcmp(rows[candidate].id, yyjson_get_str(id)) == 0 && !rows[candidate].cede) {
|
||||
rows[candidate].cede = true;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
status = planning_error(
|
||||
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
for (size_t index = first; index <= last; ++index) {
|
||||
if (strcmp(rows[index].execution, "todo") != 0 ||
|
||||
has_preparation(database, rows[index].id) || rows[index].original == NULL ||
|
||||
!valid_civil_date(rows[index].original)) {
|
||||
status = planning_error(
|
||||
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
if (strcmp(rows[index].planning, "ceded") == 0 && !rows[index].cede) {
|
||||
status = planning_error(
|
||||
"ceded_session_requires_selection", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
// WHY: a second explicit decision starts from the durable current
|
||||
// schedule. A ceded identity has no current date, so its original slot
|
||||
// is available only when the user selects that identity again.
|
||||
const char *slot = rows[index].current == NULL ? rows[index].original : rows[index].current;
|
||||
if (strcmp(slot, yyjson_get_str(yyjson_obj_get(request, "start_date"))) >= 0) {
|
||||
slots[slot_count++] = slot;
|
||||
}
|
||||
}
|
||||
// Selected Program slots define permitted days; no rest day is synthesized.
|
||||
// Position order may differ from dates, so sort candidate dates explicitly.
|
||||
for (size_t outer = 0U; outer < slot_count; ++outer) {
|
||||
for (size_t inner = outer + 1U; inner < slot_count; ++inner) {
|
||||
if (strcmp(slots[inner], slots[outer]) < 0) {
|
||||
const char *swap = slots[outer];
|
||||
slots[outer] = slots[inner];
|
||||
slots[inner] = swap;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A previously ceded original day may now be occupied by an active
|
||||
// session. It remains one calendar slot for this operation.
|
||||
size_t unique_slots = 0U;
|
||||
for (size_t index = 0U; index < slot_count; ++index) {
|
||||
if (unique_slots == 0U || strcmp(slots[index], slots[unique_slots - 1U]) != 0) {
|
||||
slots[unique_slots++] = slots[index];
|
||||
}
|
||||
}
|
||||
slot_count = unique_slots;
|
||||
for (size_t index = first; index <= last; ++index) {
|
||||
if (rows[index].cede) {
|
||||
continue;
|
||||
}
|
||||
if (next_slot >= slot_count ||
|
||||
strcmp(slots[next_slot], yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
|
||||
status = planning_error("insufficient_or_duplicate_slots",
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT,
|
||||
output,
|
||||
output_size);
|
||||
goto done;
|
||||
}
|
||||
rows[index].assigned = slots[next_slot++];
|
||||
}
|
||||
// CONTRACT: the choice digest includes stable identities in their supplied
|
||||
// order. The fact digest includes the complete current Program projection.
|
||||
{
|
||||
yyjson_mut_doc *choice_document = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *choice_root =
|
||||
choice_document == NULL ? NULL : yyjson_mut_obj(choice_document);
|
||||
if (choice_document == NULL || choice_root == NULL) {
|
||||
yyjson_mut_doc_free(choice_document);
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
yyjson_mut_doc_set_root(choice_document, choice_root);
|
||||
(void)yyjson_mut_obj_add_strcpy(
|
||||
choice_document,
|
||||
choice_root,
|
||||
"start_session_id",
|
||||
yyjson_get_str(yyjson_obj_get(request, "start_session_id")));
|
||||
(void)yyjson_mut_obj_add_strcpy(
|
||||
choice_document,
|
||||
choice_root,
|
||||
"through_session_id",
|
||||
yyjson_get_str(yyjson_obj_get(request, "through_session_id")));
|
||||
(void)yyjson_mut_obj_add_strcpy(choice_document,
|
||||
choice_root,
|
||||
"start_date",
|
||||
yyjson_get_str(yyjson_obj_get(request, "start_date")));
|
||||
yyjson_mut_val *ids = yyjson_mut_arr(choice_document);
|
||||
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
|
||||
(void)yyjson_mut_arr_add_strcpy(
|
||||
choice_document, ids, yyjson_get_str(yyjson_arr_get(ceded, index)));
|
||||
}
|
||||
(void)yyjson_mut_obj_add_val(choice_document, choice_root, "ceded_session_ids", ids);
|
||||
choices = yyjson_mut_write(choice_document, YYJSON_WRITE_NOFLAG, &choices_size);
|
||||
yyjson_mut_doc_free(choice_document);
|
||||
}
|
||||
if (choices == NULL ||
|
||||
!planning_digest(detail, detail_size, choices, choices_size, fingerprint)) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
response = yyjson_mut_doc_new(NULL);
|
||||
root = response == NULL ? NULL : yyjson_mut_obj(response);
|
||||
moves = response == NULL ? NULL : yyjson_mut_arr(response);
|
||||
if (response == NULL || root == NULL || moves == NULL) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
yyjson_mut_doc_set_root(response, root);
|
||||
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
|
||||
(void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
|
||||
(void)yyjson_mut_obj_add_strcpy(
|
||||
response, root, "revision_id", yyjson_get_str(yyjson_obj_get(program, "revision_id")));
|
||||
(void)yyjson_mut_obj_add_strcpy(response, root, "preview_sha256", fingerprint);
|
||||
(void)yyjson_mut_obj_add_val(response, root, "moves", moves);
|
||||
for (size_t index = first; index <= last; ++index) {
|
||||
yyjson_mut_val *move = yyjson_mut_obj(response);
|
||||
(void)yyjson_mut_obj_add_strcpy(response, move, "program_session_id", rows[index].id);
|
||||
(void)add_nullable(response, move, "original_for", rows[index].original);
|
||||
(void)add_nullable(response, move, "old_for", rows[index].current);
|
||||
(void)add_nullable(response, move, "new_for", rows[index].assigned);
|
||||
const char *change =
|
||||
rows[index].cede ? "ceded"
|
||||
: rows[index].current != NULL && strcmp(rows[index].current, rows[index].assigned) == 0
|
||||
? "unchanged"
|
||||
: "rescheduled";
|
||||
(void)yyjson_mut_obj_add_strcpy(response, move, "change", change);
|
||||
(void)yyjson_mut_arr_add_val(moves, move);
|
||||
}
|
||||
*output = yyjson_mut_write(response, YYJSON_WRITE_NOFLAG, output_size);
|
||||
status = *output == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
||||
done:
|
||||
free(choices);
|
||||
yyjson_mut_doc_free(response);
|
||||
yyjson_doc_free(source);
|
||||
free(detail);
|
||||
return status;
|
||||
}
|
||||
|
||||
static TrainlogStatus apply_preview(TrainlogDatabase *database,
|
||||
const char *operation_id,
|
||||
const char *program_id,
|
||||
const char *expected_revision,
|
||||
const char *request_digest,
|
||||
const char *preview,
|
||||
char **output,
|
||||
size_t *output_size) {
|
||||
yyjson_doc *document = yyjson_read(preview, strlen(preview), YYJSON_READ_NOFLAG);
|
||||
yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document);
|
||||
yyjson_val *moves = root == NULL ? NULL : yyjson_obj_get(root, "moves");
|
||||
sqlite3_stmt *statement = NULL;
|
||||
char revision[80];
|
||||
char now[32];
|
||||
time_t clock_now = time(NULL);
|
||||
struct tm utc;
|
||||
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
if (moves == NULL || clock_now == (time_t)-1 || gmtime_r(&clock_now, &utc) == NULL ||
|
||||
strftime(now, sizeof(now), "%Y-%m-%dT%H:%M:%SZ", &utc) == 0U ||
|
||||
trainlog_id_generate("pgr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"UPDATE programs SET revision_id=?,updated_at=? WHERE program_id=? "
|
||||
"AND revision_id=? AND deleted_at IS NULL AND state='active'",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
||||
status = TRAINLOG_STATUS_CONFLICT;
|
||||
goto done;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO program_session_planning "
|
||||
"(program_session_id,current_for,state,operation_id,updated_at) "
|
||||
"VALUES(?,?,?,?,?) ON CONFLICT(program_session_id) DO UPDATE SET "
|
||||
"current_for=excluded.current_for,state=excluded.state,"
|
||||
"operation_id=excluded.operation_id,updated_at=excluded.updated_at",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK) {
|
||||
goto done;
|
||||
}
|
||||
for (size_t index = 0U; index < yyjson_arr_size(moves); ++index) {
|
||||
yyjson_val *move = yyjson_arr_get(moves, index);
|
||||
yyjson_val *new_for = yyjson_obj_get(move, "new_for");
|
||||
if (yyjson_equals_str(yyjson_obj_get(move, "change"), "unchanged")) {
|
||||
continue;
|
||||
}
|
||||
sqlite3_reset(statement);
|
||||
sqlite3_clear_bindings(statement);
|
||||
if (sqlite3_bind_text(statement,
|
||||
1,
|
||||
yyjson_get_str(yyjson_obj_get(move, "program_session_id")),
|
||||
-1,
|
||||
SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
(yyjson_is_null(new_for)
|
||||
? sqlite3_bind_null(statement, 2)
|
||||
: sqlite3_bind_text(
|
||||
statement, 2, yyjson_get_str(new_for), -1, SQLITE_TRANSIENT)) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement,
|
||||
3,
|
||||
yyjson_equals_str(yyjson_obj_get(move, "change"), "ceded") ? "ceded"
|
||||
: "active",
|
||||
-1,
|
||||
SQLITE_STATIC) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE) {
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
{
|
||||
size_t length = strlen(preview) + strlen(revision) + 52U;
|
||||
*output = malloc(length);
|
||||
if (*output == NULL) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
*output_size = (size_t)snprintf(*output,
|
||||
length,
|
||||
"%.*s,\"applied\":true,\"new_revision_id\":\"%s\"}",
|
||||
(int)strlen(preview) - 1,
|
||||
preview,
|
||||
revision);
|
||||
}
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO program_reschedule_operations VALUES(?,?,?,?,?,?)",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, request_digest, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 5, *output, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 6, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE) {
|
||||
goto done;
|
||||
}
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
done:
|
||||
(void)sqlite3_finalize(statement);
|
||||
yyjson_doc_free(document);
|
||||
return status;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
|
||||
const char *program_id,
|
||||
const char *operation_id,
|
||||
const char *body,
|
||||
size_t body_size,
|
||||
bool commit,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
yyjson_doc *request_doc = NULL;
|
||||
yyjson_val *request;
|
||||
sqlite3_stmt *statement = NULL;
|
||||
char request_digest[65];
|
||||
char *preview = NULL;
|
||||
size_t preview_size = 0U;
|
||||
TrainlogStatus status;
|
||||
if (database == NULL || program_id == NULL || body == NULL || output_json == NULL ||
|
||||
output_size == NULL || (commit && (operation_id == NULL || operation_id[0] == '\0')) ||
|
||||
body_size > 16384U || (operation_id != NULL && strlen(operation_id) > 128U)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
request_doc = yyjson_read(body, body_size, YYJSON_READ_NOFLAG);
|
||||
request = request_doc == NULL ? NULL : yyjson_doc_get_root(request_doc);
|
||||
if (!request_valid(request, commit) ||
|
||||
!planning_digest(body, body_size, "", 0U, request_digest)) {
|
||||
yyjson_doc_free(request_doc);
|
||||
return planning_error("invalid_reschedule_request",
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT,
|
||||
output_json,
|
||||
output_size);
|
||||
}
|
||||
if (sqlite3_exec(
|
||||
database->connection, commit ? "BEGIN IMMEDIATE" : "BEGIN", NULL, NULL, NULL) !=
|
||||
SQLITE_OK) {
|
||||
yyjson_doc_free(request_doc);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if (commit) {
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT program_id,request_sha256,response_json FROM "
|
||||
"program_reschedule_operations WHERE operation_id=?",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
if (sqlite3_step(statement) == SQLITE_ROW) {
|
||||
const char *known_program = (const char *)sqlite3_column_text(statement, 0);
|
||||
const char *known_digest = (const char *)sqlite3_column_text(statement, 1);
|
||||
const char *saved = (const char *)sqlite3_column_text(statement, 2);
|
||||
if (known_program != NULL && known_digest != NULL && saved != NULL &&
|
||||
strcmp(known_program, program_id) == 0 &&
|
||||
strcmp(known_digest, request_digest) == 0) {
|
||||
*output_json = strdup(saved);
|
||||
*output_size = *output_json == NULL ? 0U : strlen(*output_json);
|
||||
status = *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
status = planning_error("operation_identity_conflict",
|
||||
TRAINLOG_STATUS_CONFLICT,
|
||||
output_json,
|
||||
output_size);
|
||||
}
|
||||
goto finish;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM program_requests WHERE request_id=? "
|
||||
"UNION ALL SELECT 1 FROM program_deletions WHERE request_id=?",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
int cross_command = sqlite3_step(statement);
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
if (cross_command != SQLITE_DONE) {
|
||||
status = cross_command == SQLITE_ROW ? planning_error("operation_identity_conflict",
|
||||
TRAINLOG_STATUS_CONFLICT,
|
||||
output_json,
|
||||
output_size)
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
// WHY: the desktop's last imported draft snapshot cannot prove that
|
||||
// Android stayed idle while offline. A recent acknowledged V2 Program
|
||||
// generation is required; Android independently rejects a changed
|
||||
// schedule if a Program draft starts after that acknowledgement.
|
||||
status = peer_planning_fresh(database);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||
status = planning_error("program_peer_not_fresh", status, output_json, output_size);
|
||||
}
|
||||
goto finish;
|
||||
}
|
||||
}
|
||||
status = build_preview(database, program_id, request, &preview, &preview_size);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
if (preview != NULL) {
|
||||
*output_json = preview;
|
||||
*output_size = preview_size;
|
||||
preview = NULL;
|
||||
}
|
||||
goto finish;
|
||||
}
|
||||
if (!commit) {
|
||||
*output_json = preview;
|
||||
*output_size = preview_size;
|
||||
preview = NULL;
|
||||
goto finish;
|
||||
}
|
||||
{
|
||||
yyjson_doc *preview_doc = yyjson_read(preview, preview_size, YYJSON_READ_NOFLAG);
|
||||
yyjson_val *preview_root = preview_doc == NULL ? NULL : yyjson_doc_get_root(preview_doc);
|
||||
const char *fingerprint = yyjson_get_str(yyjson_obj_get(preview_root, "preview_sha256"));
|
||||
if (fingerprint == NULL ||
|
||||
strcmp(fingerprint, yyjson_get_str(yyjson_obj_get(request, "preview_sha256"))) != 0) {
|
||||
status = planning_error(
|
||||
"stale_reschedule_preview", TRAINLOG_STATUS_CONFLICT, output_json, output_size);
|
||||
} else {
|
||||
status = apply_preview(database,
|
||||
operation_id,
|
||||
program_id,
|
||||
yyjson_get_str(yyjson_obj_get(request, "expected_revision")),
|
||||
request_digest,
|
||||
preview,
|
||||
output_json,
|
||||
output_size);
|
||||
}
|
||||
yyjson_doc_free(preview_doc);
|
||||
}
|
||||
finish:
|
||||
(void)sqlite3_finalize(statement);
|
||||
free(preview);
|
||||
yyjson_doc_free(request_doc);
|
||||
if (status == TRAINLOG_STATUS_OK && commit) {
|
||||
if (sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
|
||||
return status;
|
||||
}
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
||||
if (status != TRAINLOG_STATUS_OK && *output_json == NULL) {
|
||||
return planning_error("reschedule_unavailable", status, output_json, output_size);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
|
@ -365,10 +365,12 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database,
|
|||
!add_column_text(document, session, "title", statement, 2) ||
|
||||
!add_column_text(document, session, "session_type", statement, 3) ||
|
||||
!add_column_text(document, session, "planned_for", statement, 4) ||
|
||||
!add_column_text(document, session, "note", statement, 5) ||
|
||||
!add_column_text(document, session, "execution_state", statement, 6) ||
|
||||
!add_column_text(document, session, "execution_session_id", statement, 7) ||
|
||||
!add_column_text(document, session, "execution_started_at", statement, 8) ||
|
||||
!add_column_text(document, session, "current_for", statement, 5) ||
|
||||
!add_column_text(document, session, "planning_state", statement, 6) ||
|
||||
!add_column_text(document, session, "note", statement, 7) ||
|
||||
!add_column_text(document, session, "execution_state", statement, 8) ||
|
||||
!add_column_text(document, session, "execution_session_id", statement, 9) ||
|
||||
!add_column_text(document, session, "execution_started_at", statement, 10) ||
|
||||
!yyjson_mut_obj_add_val(document, session, "occurrences", entries) ||
|
||||
!yyjson_mut_arr_add_val(sessions, session)) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
|
|
@ -383,7 +385,9 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
|
|||
yyjson_mut_val *sessions) {
|
||||
static const char SESSION_SQL[] =
|
||||
"SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
|
||||
"ps.note,CASE WHEN pe.state='completed' THEN 'completed' "
|
||||
"CASE WHEN pp.state='ceded' THEN NULL ELSE COALESCE(pp.current_for,ps.planned_for) END,"
|
||||
"COALESCE(pp.state,'active'),ps.note,"
|
||||
"CASE WHEN pe.state='completed' THEN 'completed' "
|
||||
"WHEN pe.state='in_progress' THEN 'in_progress' "
|
||||
"WHEN pe.state='deleted' THEN 'deleted' "
|
||||
"WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE "
|
||||
|
|
@ -394,7 +398,9 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
|
|||
* 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_executions pe "
|
||||
"FROM program_sessions ps LEFT JOIN program_session_planning pp "
|
||||
"ON pp.program_session_id=ps.program_session_id "
|
||||
"LEFT JOIN program_session_executions pe "
|
||||
"ON pe.program_session_id=ps.program_session_id "
|
||||
"LEFT JOIN sessions actual ON actual.session_id=pe.session_id "
|
||||
"WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id";
|
||||
|
|
@ -1165,7 +1171,25 @@ static TrainlogStatus program_replay_archive(TrainlogDatabase *database,
|
|||
result = SQLITE_OK;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
if (result != SQLITE_OK || *found) {
|
||||
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM program_reschedule_operations WHERE operation_id=?",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return result == SQLITE_ROW ? TRAINLOG_STATUS_CONFLICT
|
||||
: result == SQLITE_DONE ? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
|
||||
|
|
@ -1357,7 +1381,9 @@ static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database,
|
|||
}
|
||||
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM program_requests WHERE request_id=?1",
|
||||
"SELECT 1 FROM program_requests WHERE request_id=?1 "
|
||||
"UNION ALL SELECT 1 FROM program_reschedule_operations "
|
||||
"WHERE operation_id=?1",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
|
|
@ -1609,7 +1635,7 @@ static char *program_build_preparation_body(yyjson_val *session,
|
|||
command,
|
||||
root,
|
||||
"planned_for",
|
||||
yyjson_val_mut_copy(command, yyjson_obj_get(session, "planned_for")));
|
||||
yyjson_val_mut_copy(command, yyjson_obj_get(session, "current_for")));
|
||||
(void)yyjson_mut_obj_add_val(
|
||||
command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note")));
|
||||
(void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft");
|
||||
|
|
@ -1652,6 +1678,10 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
|||
yyjson_doc_free(document);
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
if (!yyjson_equals_str(yyjson_obj_get(session, "planning_state"), "active")) {
|
||||
yyjson_doc_free(document);
|
||||
return TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
body = program_build_preparation_body(session, program_id, program_session_id, &body_size);
|
||||
yyjson_doc_free(document);
|
||||
if (body == NULL) {
|
||||
|
|
|
|||
|
|
@ -705,14 +705,22 @@ static enum MHD_Result queue_owned_programs_json(struct MHD_Connection *connecti
|
|||
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||
enum MHD_Result result =
|
||||
queue_json(connection,
|
||||
MHD_HTTP_CONFLICT,
|
||||
json == NULL ? "{\"error\":\"program_conflict\"}\n" : json,
|
||||
NULL);
|
||||
free(json);
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL);
|
||||
return result;
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
||||
enum MHD_Result result =
|
||||
queue_json(connection,
|
||||
MHD_HTTP_UNPROCESSABLE_CONTENT,
|
||||
json == NULL ? "{\"error\":\"invalid_program\"}\n" : json,
|
||||
NULL);
|
||||
free(json);
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL);
|
||||
return result;
|
||||
}
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
|
|
@ -930,6 +938,8 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
|||
static const char PREFIX[] = "/api/v1/sessions/program/";
|
||||
static const char ARCHIVE_SUFFIX[] = "/archive";
|
||||
static const char DELETE_SUFFIX[] = "/delete";
|
||||
static const char PREVIEW_SUFFIX[] = "/reschedule/preview";
|
||||
static const char APPLY_SUFFIX[] = "/reschedule/apply";
|
||||
const char *identity;
|
||||
const char *sessions_marker;
|
||||
char *json = NULL;
|
||||
|
|
@ -950,6 +960,38 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
|||
identity = url + strlen(PREFIX);
|
||||
identity_length = strlen(identity);
|
||||
sessions_marker = strstr(identity, "/sessions/");
|
||||
if ((identity_length > strlen(PREVIEW_SUFFIX) &&
|
||||
strcmp(identity + identity_length - strlen(PREVIEW_SUFFIX), PREVIEW_SUFFIX) == 0) ||
|
||||
(identity_length > strlen(APPLY_SUFFIX) &&
|
||||
strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0)) {
|
||||
bool commit = strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0;
|
||||
size_t suffix_length = commit ? strlen(APPLY_SUFFIX) : strlen(PREVIEW_SUFFIX);
|
||||
char program_id[TRAINLOG_ID_MAX + 1U];
|
||||
const char *operation_id =
|
||||
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
|
||||
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||
"{\"error\":\"method_not_allowed\"}\n",
|
||||
"POST");
|
||||
}
|
||||
if (!program_mutation_allowed(context, connection) ||
|
||||
!copy_path_component(
|
||||
program_id, sizeof(program_id), identity, identity_length - suffix_length) ||
|
||||
(commit && (operation_id == NULL || operation_id[0] == '\0'))) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
||||
}
|
||||
status = trainlog_web_programs_reschedule_json(context->database,
|
||||
program_id,
|
||||
operation_id,
|
||||
request->body,
|
||||
request->body_size,
|
||||
commit,
|
||||
&json,
|
||||
&json_size);
|
||||
return queue_owned_programs_json(connection, status, json, json_size);
|
||||
}
|
||||
if (identity_length > strlen(ARCHIVE_SUFFIX) &&
|
||||
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
|
||||
return handle_program_archive(context, connection, identity, identity_length, method);
|
||||
|
|
|
|||
|
|
@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
|
|||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strncmp(diagnostic,
|
||||
"migrate database to schema v36: SQLite rc=",
|
||||
strlen("migrate database to schema v36: SQLite rc=")) == 0);
|
||||
"migrate database to schema v37: SQLite rc=",
|
||||
strlen("migrate database to schema v37: SQLite rc=")) == 0);
|
||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||
|
|
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
|
|||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=37;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=38;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 37 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 38 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v36") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v37") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
|
|
@ -608,10 +608,343 @@ static bool legacy_integer_weight_zero_prepares_editable_draft(void) {
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool ordered_reschedule_is_atomic_and_idempotent(void) {
|
||||
static const char *const DATES[] = {
|
||||
"2026-09-21", "2026-09-22", "2026-09-23", "2026-09-24", "2026-09-25", "2026-09-26",
|
||||
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02", "2026-10-03",
|
||||
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09", "2026-10-10",
|
||||
"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17",
|
||||
};
|
||||
static const char REQUEST[] = "{\"start_session_id\":\"pgs_fixture_07\","
|
||||
"\"through_session_id\":\"pgs_fixture_22\","
|
||||
"\"start_date\":\"2026-10-01\","
|
||||
"\"ceded_session_ids\":[\"pgs_fixture_11\",\"pgs_fixture_17\"],"
|
||||
"\"expected_revision\":\"pgr_fixture\"}";
|
||||
TrainlogDatabase *database = NULL;
|
||||
char *preview = NULL;
|
||||
char *applied = NULL;
|
||||
char *replayed = NULL;
|
||||
char *request = NULL;
|
||||
size_t size = 0U;
|
||||
size_t applied_size = 0U;
|
||||
char fingerprint[80];
|
||||
char sql[1024];
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
|
||||
"created_at,updated_at,revision_id,source_format,source_version,"
|
||||
"source_payload_sha256) VALUES('pg_fixture','Fixture','active',"
|
||||
"'2026-09-21','2026-10-18','2026-09-20T00:00:00Z',"
|
||||
"'2026-09-20T00:00:00Z','pgr_fixture','trainlog-program',1,"
|
||||
"'0000000000000000000000000000000000000000000000000000000000000000')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
for (size_t index = 0U; index < 24U; ++index) {
|
||||
(void)snprintf(sql,
|
||||
sizeof(sql),
|
||||
"INSERT INTO program_sessions VALUES('pgs_fixture_%02zu','pg_fixture',"
|
||||
"%zu,'Session %zu','training','%s',NULL)",
|
||||
index,
|
||||
index,
|
||||
index,
|
||||
DATES[index]);
|
||||
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
|
||||
if (index < 7U) {
|
||||
const char *actual = index == 5U ? "2026-09-28T09:00:00+02:00"
|
||||
: index == 6U ? "2026-09-30T17:00:00+02:00"
|
||||
: "2026-09-21T09:00:00+02:00";
|
||||
(void)snprintf(sql,
|
||||
sizeof(sql),
|
||||
"INSERT INTO sessions(session_id,started_at) "
|
||||
"VALUES('se_fixture_%02zu','%s')",
|
||||
index,
|
||||
actual);
|
||||
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
|
||||
(void)snprintf(sql,
|
||||
sizeof(sql),
|
||||
"INSERT INTO program_session_executions VALUES("
|
||||
"'pgs_fixture_%02zu','pg_fixture','se_fixture_%02zu',"
|
||||
"'completed','2026-09-30T00:00:00Z')",
|
||||
index,
|
||||
index);
|
||||
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
|
||||
}
|
||||
}
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO sessions(session_id,started_at) "
|
||||
"VALUES('se_evening_stretch','2026-10-01T20:00:00+02:00')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
char *invalid_date = replace_once(REQUEST, "2026-10-01", "2026-02-30");
|
||||
CHECK(invalid_date != NULL);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
NULL,
|
||||
invalid_date,
|
||||
strlen(invalid_date),
|
||||
false,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
free(invalid_date);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
char *too_late = replace_once(REQUEST, "2026-10-01", "2026-10-16");
|
||||
CHECK(too_late != NULL);
|
||||
CHECK(trainlog_web_programs_reschedule_json(
|
||||
database, "pg_fixture", NULL, too_late, strlen(too_late), false, &replayed, &size) ==
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
CHECK(strstr(replayed, "insufficient_or_duplicate_slots") != NULL);
|
||||
free(too_late);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
// The DST transition on October 25 is a rest day in this fixture.
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
|
||||
"created_at,updated_at,revision_id,source_format,source_version,"
|
||||
"source_payload_sha256) VALUES('pg_dst','DST','active','2026-10-24',"
|
||||
"'2026-10-27','2026-10-20T00:00:00Z','2026-10-20T00:00:00Z',"
|
||||
"'pgr_dst','trainlog-program',1,"
|
||||
"'0000000000000000000000000000000000000000000000000000000000000000');"
|
||||
"INSERT INTO program_sessions VALUES"
|
||||
"('pgs_dst_0','pg_dst',0,'Before','training','2026-10-24',NULL),"
|
||||
"('pgs_dst_1','pg_dst',1,'After','training','2026-10-26',NULL),"
|
||||
"('pgs_dst_2','pg_dst',2,'Next','training','2026-10-27',NULL)",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
static const char DST_REQUEST[] =
|
||||
"{\"start_session_id\":\"pgs_dst_0\",\"through_session_id\":\"pgs_dst_2\","
|
||||
"\"start_date\":\"2026-10-24\",\"ceded_session_ids\":[],"
|
||||
"\"expected_revision\":\"pgr_dst\"}";
|
||||
CHECK(
|
||||
trainlog_web_programs_reschedule_json(
|
||||
database, "pg_dst", NULL, DST_REQUEST, strlen(DST_REQUEST), false, &replayed, &size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(replayed, "\"new_for\":\"2026-10-26\"") != NULL);
|
||||
CHECK(strstr(replayed, "2026-10-25") == NULL);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
CHECK(trainlog_web_programs_reschedule_json(
|
||||
database, "pg_fixture", NULL, REQUEST, strlen(REQUEST), false, &preview, &size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(extract_string(preview, "preview_sha256", fingerprint, sizeof(fingerprint)));
|
||||
CHECK(strstr(preview,
|
||||
"\"program_session_id\":\"pgs_fixture_07\","
|
||||
"\"original_for\":\"2026-09-29\","
|
||||
"\"old_for\":\"2026-09-29\","
|
||||
"\"new_for\":\"2026-10-01\"") != NULL);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 0);
|
||||
request = malloc(strlen(REQUEST) + strlen(fingerprint) + 32U);
|
||||
CHECK(request != NULL);
|
||||
(void)snprintf(request,
|
||||
strlen(REQUEST) + strlen(fingerprint) + 32U,
|
||||
"%.*s,\"preview_sha256\":\"%s\"}",
|
||||
(int)strlen(REQUEST) - 1,
|
||||
REQUEST,
|
||||
fingerprint);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO sync_generations(generation_id,run_id,producer_peer_id,"
|
||||
"consumer_peer_id,producer_kind,generated_at,manifest_sha256,status,"
|
||||
"manifest_json,staging_path) VALUES('gen_fixture','sy_fixture',"
|
||||
"'peer_desktop','peer_phone','desktop',"
|
||||
"strftime('%Y-%m-%dT%H:%M:%SZ','now'),'digest','captured','{}','/tmp')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO sync_generation_artifacts VALUES('gen_fixture','programs-v2',"
|
||||
"'trainlog-programs',2,'programs-v2.json',0,'digest',0)",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_fixture",
|
||||
request,
|
||||
strlen(request),
|
||||
true,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(strstr(replayed, "program_peer_not_fresh") != NULL);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 0);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"UPDATE sync_generations SET status='acknowledged',"
|
||||
"acknowledged_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') "
|
||||
"WHERE generation_id='gen_fixture'",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_fixture",
|
||||
request,
|
||||
strlen(request),
|
||||
true,
|
||||
&applied,
|
||||
&applied_size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 11);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning WHERE state='ceded'") ==
|
||||
2);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(DISTINCT current_for) FROM program_session_planning "
|
||||
"WHERE state='active'") == 9);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_session_executions "
|
||||
"WHERE state='completed'") == 7);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM sessions WHERE "
|
||||
"session_id='se_fixture_05' AND started_at='2026-09-28T09:00:00+02:00'") == 1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM sessions WHERE "
|
||||
"session_id='se_fixture_06' AND started_at='2026-09-30T17:00:00+02:00'") == 1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM sessions WHERE "
|
||||
"session_id='se_evening_stretch' AND started_at='2026-10-01T20:00:00+02:00'") ==
|
||||
1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_sessions WHERE "
|
||||
"planned_for='2026-09-29' AND program_session_id='pgs_fixture_07'") == 1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_session_planning WHERE "
|
||||
"program_session_id='pgs_fixture_09' AND current_for='2026-10-03'") == 1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_session_planning WHERE "
|
||||
"program_session_id='pgs_fixture_22'") == 0);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_fixture",
|
||||
request,
|
||||
strlen(request),
|
||||
true,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strcmp(applied, replayed) == 0);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_reschedule_operations") == 1);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
char *different_request = replace_once(request, "2026-10-01", "2026-10-02");
|
||||
CHECK(different_request != NULL);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_fixture",
|
||||
different_request,
|
||||
strlen(different_request),
|
||||
true,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_CONFLICT);
|
||||
free(different_request);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
char new_revision[80];
|
||||
char next_fingerprint[80];
|
||||
CHECK(extract_string(applied, "new_revision_id", new_revision, sizeof(new_revision)));
|
||||
CHECK(scalar(database,
|
||||
"SELECT revision_sequence FROM program_revision_sequences "
|
||||
"WHERE program_id='pg_fixture'") == 1);
|
||||
char second_choice[512];
|
||||
(void)snprintf(second_choice,
|
||||
sizeof(second_choice),
|
||||
"{\"start_session_id\":\"pgs_fixture_07\","
|
||||
"\"through_session_id\":\"pgs_fixture_09\","
|
||||
"\"start_date\":\"2026-10-01\",\"ceded_session_ids\":[],"
|
||||
"\"expected_revision\":\"%s\"}",
|
||||
new_revision);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
NULL,
|
||||
second_choice,
|
||||
strlen(second_choice),
|
||||
false,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(replayed,
|
||||
"\"program_session_id\":\"pgs_fixture_09\","
|
||||
"\"original_for\":\"2026-10-01\","
|
||||
"\"old_for\":\"2026-10-03\","
|
||||
"\"new_for\":\"2026-10-03\"") != NULL);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
char *next_choice = replace_once(REQUEST, "pgr_fixture", new_revision);
|
||||
CHECK(next_choice != NULL);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
NULL,
|
||||
next_choice,
|
||||
strlen(next_choice),
|
||||
false,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(extract_string(replayed, "preview_sha256", next_fingerprint, sizeof(next_fingerprint)));
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
char *next_request = malloc(strlen(next_choice) + strlen(next_fingerprint) + 32U);
|
||||
CHECK(next_request != NULL);
|
||||
(void)snprintf(next_request,
|
||||
strlen(next_choice) + strlen(next_fingerprint) + 32U,
|
||||
"%.*s,\"preview_sha256\":\"%s\"}",
|
||||
(int)strlen(next_choice) - 1,
|
||||
next_choice,
|
||||
next_fingerprint);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"CREATE TRIGGER fail_reschedule_fixture BEFORE INSERT ON "
|
||||
"program_reschedule_operations WHEN NEW.operation_id='op_failure' "
|
||||
"BEGIN SELECT RAISE(ABORT,'fixture failure'); END",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_failure",
|
||||
next_request,
|
||||
strlen(next_request),
|
||||
true,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_reschedule_operations") == 1);
|
||||
(void)snprintf(sql,
|
||||
sizeof(sql),
|
||||
"SELECT COUNT(*) FROM programs WHERE program_id='pg_fixture' "
|
||||
"AND revision_id='%s'",
|
||||
new_revision);
|
||||
CHECK(scalar(database, sql) == 1);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_executions VALUES("
|
||||
"'pgs_fixture_07','pg_fixture','se_late','completed',"
|
||||
"'2026-10-01T10:00:00Z')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_stale",
|
||||
next_request,
|
||||
strlen(next_request),
|
||||
true,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(strstr(replayed, "prepared_or_executed_session") != NULL);
|
||||
free(replayed);
|
||||
free(next_request);
|
||||
free(next_choice);
|
||||
free(applied);
|
||||
free(preview);
|
||||
free(request);
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return import_archive_and_prepare() && strict_import_rejections() &&
|
||||
mixed_profile_prepare_regression() &&
|
||||
legacy_integer_weight_zero_prepares_editable_draft()
|
||||
legacy_integer_weight_zero_prepares_editable_draft() &&
|
||||
ordered_reschedule_is_atomic_and_idempotent()
|
||||
? EXIT_SUCCESS
|
||||
: EXIT_FAILURE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export interface ProgramSession {
|
|||
title: string
|
||||
session_type: 'training' | 'max_test'
|
||||
planned_for: string | null
|
||||
current_for: string | null
|
||||
planning_state: 'active' | 'ceded'
|
||||
note: string | null
|
||||
execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' | 'deleted'
|
||||
execution_session_id: string | null
|
||||
|
|
@ -62,6 +64,30 @@ export interface ProgramDetail {
|
|||
sessions: ProgramSession[]
|
||||
}
|
||||
|
||||
export interface RescheduleChoice {
|
||||
start_session_id: string
|
||||
through_session_id: string
|
||||
start_date: string
|
||||
ceded_session_ids: string[]
|
||||
expected_revision: string
|
||||
}
|
||||
|
||||
export interface RescheduleMove {
|
||||
program_session_id: string
|
||||
original_for: string | null
|
||||
old_for: string | null
|
||||
new_for: string | null
|
||||
change: 'rescheduled' | 'ceded' | 'unchanged'
|
||||
}
|
||||
|
||||
export interface ReschedulePreview {
|
||||
api_version: 1
|
||||
program_id: string
|
||||
revision_id: string
|
||||
preview_sha256: string
|
||||
moves: RescheduleMove[]
|
||||
}
|
||||
|
||||
export interface ProgramImportPreview {
|
||||
api_version: 1
|
||||
program_id: string
|
||||
|
|
@ -106,6 +132,47 @@ function requestId(prefix = 'web'): string {
|
|||
return `${prefix}_${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
export function newRescheduleOperationId(): string {
|
||||
return requestId('pr')
|
||||
}
|
||||
|
||||
async function rescheduleRequest(
|
||||
programId: string,
|
||||
mode: 'preview' | 'apply',
|
||||
choice: RescheduleChoice & { preview_sha256?: string },
|
||||
operationId?: string,
|
||||
): Promise<ReschedulePreview> {
|
||||
const csrf = await mutationCsrfToken()
|
||||
const response = await fetch(
|
||||
`/api/v1/sessions/program/${encodeURIComponent(programId)}/reschedule/${mode}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'X-Trainlog-CSRF-Token': csrf,
|
||||
'X-Trainlog-Request-ID': operationId ?? requestId('pr'),
|
||||
},
|
||||
body: JSON.stringify(choice),
|
||||
},
|
||||
)
|
||||
if (!response.ok) throw new Error(await errorReason(response))
|
||||
const value: unknown = await response.json()
|
||||
if (!object(value) || value.api_version !== 1 || value.program_id !== programId ||
|
||||
typeof value.revision_id !== 'string' || typeof value.preview_sha256 !== 'string' ||
|
||||
!Array.isArray(value.moves)) throw new TypeError('aperçu de recalage invalide')
|
||||
return value as unknown as ReschedulePreview
|
||||
}
|
||||
|
||||
export function previewProgramReschedule(programId: string, choice: RescheduleChoice) {
|
||||
return rescheduleRequest(programId, 'preview', choice)
|
||||
}
|
||||
|
||||
export function applyProgramReschedule(
|
||||
programId: string, choice: RescheduleChoice, previewSha256: string, operationId: string,
|
||||
) {
|
||||
return rescheduleRequest(programId, 'apply', { ...choice, preview_sha256: previewSha256 }, operationId)
|
||||
}
|
||||
|
||||
export async function fetchAllPrograms(
|
||||
search = '',
|
||||
state = '',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ const api = vi.hoisted(() => ({
|
|||
fetchAllPrograms: vi.fn(),
|
||||
fetchProgram: vi.fn(),
|
||||
createPreparationFromProgram: vi.fn(),
|
||||
previewProgramReschedule: vi.fn(),
|
||||
applyProgramReschedule: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/programs', async (importOriginal) => ({
|
||||
|
|
@ -45,6 +47,8 @@ function session(
|
|||
title,
|
||||
session_type: 'training',
|
||||
planned_for: plannedFor,
|
||||
current_for: plannedFor,
|
||||
planning_state: 'active',
|
||||
note: null,
|
||||
execution_state: executionState,
|
||||
execution_session_id: null,
|
||||
|
|
@ -88,6 +92,8 @@ describe('ProgramsCalendarPage', () => {
|
|||
api.fetchAllPrograms.mockReset()
|
||||
api.fetchProgram.mockReset()
|
||||
api.createPreparationFromProgram.mockReset()
|
||||
api.previewProgramReschedule.mockReset()
|
||||
api.applyProgramReschedule.mockReset()
|
||||
api.fetchAllPrograms.mockResolvedValue([item()])
|
||||
api.fetchProgram.mockResolvedValue(detail())
|
||||
})
|
||||
|
|
@ -101,6 +107,53 @@ describe('ProgramsCalendarPage', () => {
|
|||
expect(screen.queryByRole('combobox', { name: 'Programme actif' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps distinct cards on one date and labels a ceded slot separately', async () => {
|
||||
const main = session('pgs_main', 'Séance principale', '2026-09-18')
|
||||
const evening = session('pgs_evening', 'Étirements du soir', '2026-09-18')
|
||||
const ceded = { ...session('pgs_ceded', 'Séance écartée', '2026-09-19'),
|
||||
current_for: null, planning_state: 'ceded' as const }
|
||||
api.fetchProgram.mockResolvedValue(detail({ sessions: [main, evening, ceded] }))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
const friday = await screen.findByLabelText('Ven 18/09/2026')
|
||||
expect(within(friday).getAllByRole('article')).toHaveLength(2)
|
||||
expect(within(friday).getByText('Séance principale')).toBeInTheDocument()
|
||||
expect(within(friday).getByText('Étirements du soir')).toBeInTheDocument()
|
||||
const saturday = screen.getByLabelText('Sam 19/09/2026')
|
||||
expect(within(saturday).getByText('Créneau cédé')).toBeInTheDocument()
|
||||
expect(within(saturday).queryByRole('button', { name: 'Préparer' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('previews ordered changes before one confirmation', async () => {
|
||||
const before = detail({ sessions: [
|
||||
session('pgs_b', 'B', '2026-09-18'),
|
||||
session('pgs_c', 'C', '2026-09-19'),
|
||||
] })
|
||||
api.fetchProgram.mockResolvedValue(before)
|
||||
api.previewProgramReschedule.mockResolvedValue({
|
||||
api_version: 1, program_id: 'pg_one', revision_id: 'pgr_one',
|
||||
preview_sha256: 'f'.repeat(64), moves: [
|
||||
{ program_session_id: 'pgs_b', original_for: '2026-09-18',
|
||||
old_for: '2026-09-18', new_for: '2026-09-19', change: 'rescheduled' },
|
||||
{ program_session_id: 'pgs_c', original_for: '2026-09-19',
|
||||
old_for: '2026-09-19', new_for: null, change: 'ceded' },
|
||||
],
|
||||
})
|
||||
api.applyProgramReschedule.mockResolvedValue({})
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
|
||||
fireEvent.change(screen.getByLabelText('Première séance'), { target: { value: 'pgs_b' } })
|
||||
fireEvent.change(screen.getByLabelText('Dernière séance'), { target: { value: 'pgs_c' } })
|
||||
fireEvent.change(screen.getByLabelText('Date de reprise'), { target: { value: '2026-09-19' } })
|
||||
fireEvent.click(screen.getByLabelText(/C \(2026-09-19\)/))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Calculer l’aperçu' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Aperçu avant application' })).toBeInTheDocument()
|
||||
expect(api.applyProgramReschedule).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirmer ce recalage' }))
|
||||
await waitFor(() => expect(api.applyProgramReschedule).toHaveBeenCalledTimes(1))
|
||||
expect(api.applyProgramReschedule.mock.calls[0][2]).toBe('f'.repeat(64))
|
||||
})
|
||||
|
||||
it('shows the exact empty state and navigates to Sessions program administration', async () => {
|
||||
api.fetchAllPrograms.mockResolvedValue([])
|
||||
const onNavigate = vi.fn()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createPreparationFromProgram,
|
||||
applyProgramReschedule,
|
||||
fetchAllPrograms,
|
||||
fetchProgram,
|
||||
newRescheduleOperationId,
|
||||
previewProgramReschedule,
|
||||
type ProgramDetail,
|
||||
type ProgramListItem,
|
||||
type ProgramSession,
|
||||
type RescheduleChoice,
|
||||
type ReschedulePreview,
|
||||
} from '../api/programs'
|
||||
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||
import { formatCivilDate, formatDate, parseCivilDate, validTimestampValue } from '../presentation/dateFormat'
|
||||
|
|
@ -59,7 +64,8 @@ function displayedDate(session: ProgramSession): string | null {
|
|||
// A completed link without a valid history timestamp must not masquerade as
|
||||
// a workout performed on the imported plan date.
|
||||
return session.execution_state === 'completed'
|
||||
? actualExecutionDate(session) : session.planned_for
|
||||
? actualExecutionDate(session)
|
||||
: session.planning_state === 'ceded' ? session.planned_for : session.current_for
|
||||
}
|
||||
|
||||
function calendarDays(program: ProgramDetail): CalendarDay[] {
|
||||
|
|
@ -126,7 +132,13 @@ function ProgramSessionCard({ programId, session, pending, preparationId, onPrep
|
|||
const actualDate = actualExecutionDate(session)
|
||||
return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}>
|
||||
<h3>{session.title}</h3>
|
||||
<span className="program-calendar-state-label">{stateLabels[session.execution_state]}</span>
|
||||
<span className="program-calendar-state-label">{session.planning_state === 'ceded' &&
|
||||
session.execution_state !== 'completed' ? 'Créneau cédé' : stateLabels[session.execution_state]}</span>
|
||||
{session.planning_state === 'active' && session.current_for !== session.planned_for &&
|
||||
session.execution_state !== 'completed' && <p className="program-calendar-date-note">
|
||||
Recalée du {session.planned_for === null ? 'jour initial non défini' : formatCivilDate(session.planned_for, dateFormat)}
|
||||
{' au '}{session.current_for === null ? 'jour non défini' : formatCivilDate(session.current_for, dateFormat)}
|
||||
</p>}
|
||||
{actualDate !== null && actualDate !== session.planned_for && <p className="program-calendar-date-note">
|
||||
Prévue le {session.planned_for === null ? 'date non définie' : formatCivilDate(session.planned_for, dateFormat)}
|
||||
{' · '}effectuée le {formatCivilDate(actualDate, dateFormat)}
|
||||
|
|
@ -134,7 +146,8 @@ function ProgramSessionCard({ programId, session, pending, preparationId, onPrep
|
|||
{session.execution_state === 'completed' && actualDate === null &&
|
||||
<p className="program-calendar-date-note">Date réelle indisponible</p>}
|
||||
<div className="program-calendar-action">
|
||||
{session.execution_state === 'todo' && <button type="button" disabled={pending}
|
||||
{session.execution_state === 'todo' && session.planning_state === 'active' &&
|
||||
<button type="button" disabled={pending}
|
||||
onClick={() => onPrepare(programId, session)}>
|
||||
{pending ? 'Préparation…' : 'Préparer'}
|
||||
</button>}
|
||||
|
|
@ -156,6 +169,14 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
const [pendingSessions, setPendingSessions] = useState<Set<string>>(new Set())
|
||||
const [preparationIds, setPreparationIds] = useState<Record<string, string>>({})
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [rescheduleStart, setRescheduleStart] = useState('')
|
||||
const [rescheduleThrough, setRescheduleThrough] = useState('')
|
||||
const [rescheduleDate, setRescheduleDate] = useState('')
|
||||
const [cededIds, setCededIds] = useState<string[]>([])
|
||||
const [reschedulePreview, setReschedulePreview] = useState<{
|
||||
choice: RescheduleChoice, result: ReschedulePreview, operationId: string
|
||||
} | null>(null)
|
||||
const [reschedulePending, setReschedulePending] = useState(false)
|
||||
const preparing = useRef(new Set<string>())
|
||||
const mounted = useRef(true)
|
||||
const selectedIdRef = useRef('')
|
||||
|
|
@ -200,6 +221,11 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
setDetailPending(true)
|
||||
setDetailError('')
|
||||
setAnnouncement('')
|
||||
setReschedulePreview(null)
|
||||
setRescheduleStart('')
|
||||
setRescheduleThrough('')
|
||||
setRescheduleDate('')
|
||||
setCededIds([])
|
||||
setPreparationIds({})
|
||||
fetchProgram(selectedId, controller.signal).then((value) => {
|
||||
if (isCurrent()) setDetail(value)
|
||||
|
|
@ -215,6 +241,54 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
|
||||
const days = useMemo(() => detail === null ? [] : calendarDays(detail), [detail])
|
||||
const undatedSessions = detail?.sessions.filter((session) => dateValue(displayedDate(session)) === null) ?? []
|
||||
const openSessions = detail?.sessions.filter((session) => session.execution_state === 'todo') ?? []
|
||||
|
||||
const previewReschedule = async () => {
|
||||
if (!detail || !rescheduleStart || !rescheduleThrough || !rescheduleDate) return
|
||||
const programId = detail.program_id
|
||||
const generation = selectionGeneration.current
|
||||
const choice: RescheduleChoice = {
|
||||
start_session_id: rescheduleStart,
|
||||
through_session_id: rescheduleThrough,
|
||||
start_date: rescheduleDate,
|
||||
ceded_session_ids: cededIds,
|
||||
expected_revision: detail.revision_id,
|
||||
}
|
||||
setReschedulePending(true)
|
||||
setReschedulePreview(null)
|
||||
try {
|
||||
const result = await previewProgramReschedule(programId, choice)
|
||||
if (mounted.current && selectionGeneration.current === generation) {
|
||||
setReschedulePreview({ choice, result, operationId: newRescheduleOperationId() })
|
||||
setAnnouncement('Aperçu calculé. Vérifiez les dates avant de confirmer.')
|
||||
}
|
||||
} catch (reason) {
|
||||
setAnnouncement(`Recalage impossible : ${errorMessage(reason, 'erreur inconnue')}`)
|
||||
} finally {
|
||||
setReschedulePending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmReschedule = async () => {
|
||||
if (!detail || !reschedulePreview) return
|
||||
const { choice, result, operationId } = reschedulePreview
|
||||
const programId = detail.program_id
|
||||
const generation = selectionGeneration.current
|
||||
setReschedulePending(true)
|
||||
try {
|
||||
await applyProgramReschedule(programId, choice, result.preview_sha256, operationId)
|
||||
const reread = await fetchProgram(programId)
|
||||
if (mounted.current && selectionGeneration.current === generation) {
|
||||
setDetail(reread)
|
||||
setReschedulePreview(null)
|
||||
setAnnouncement('Recalage enregistré. La synchronisation Android reste à vérifier.')
|
||||
}
|
||||
} catch (reason) {
|
||||
setAnnouncement(`Recalage non confirmé : ${errorMessage(reason, 'erreur inconnue')}`)
|
||||
} finally {
|
||||
setReschedulePending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const prepare = async (programId: string, session: ProgramSession) => {
|
||||
const sessionId = session.program_session_id
|
||||
|
|
@ -316,6 +390,54 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
</ul>
|
||||
</header>
|
||||
<p className="program-calendar-announcement" aria-live="polite">{announcement}</p>
|
||||
<section className="program-calendar-reschedule" aria-label="Recaler le programme">
|
||||
<h2>Recaler la suite</h2>
|
||||
<p>Choisissez les séances concernées dans leur ordre, la reprise et les créneaux cédés.
|
||||
L’aperçu utilise les dates de créneaux déjà inscrites au programme.</p>
|
||||
<div className="program-calendar-reschedule-fields">
|
||||
<label>Première séance<select value={rescheduleStart} onChange={(event) => {
|
||||
setRescheduleStart(event.target.value); setReschedulePreview(null)
|
||||
}}><option value="">Choisir</option>{openSessions.map((session) =>
|
||||
<option key={session.program_session_id} value={session.program_session_id}>
|
||||
Début : {session.title}</option>)}</select></label>
|
||||
<label>Dernière séance<select value={rescheduleThrough} onChange={(event) => {
|
||||
setRescheduleThrough(event.target.value); setReschedulePreview(null)
|
||||
}}><option value="">Choisir</option>{openSessions.map((session) =>
|
||||
<option key={session.program_session_id} value={session.program_session_id}>
|
||||
Fin : {session.title}</option>)}</select></label>
|
||||
<label>Date de reprise<input type="date" value={rescheduleDate}
|
||||
min={detail.start_date ?? undefined} max={detail.end_date ?? undefined}
|
||||
onChange={(event) => { setRescheduleDate(event.target.value); setReschedulePreview(null) }} /></label>
|
||||
</div>
|
||||
<fieldset><legend>Séances qui cèdent leur créneau</legend>
|
||||
{openSessions.map((session) => <label key={session.program_session_id}>
|
||||
<input type="checkbox" checked={cededIds.includes(session.program_session_id)}
|
||||
onChange={(event) => {
|
||||
setCededIds((current) => event.target.checked
|
||||
? [...current, session.program_session_id]
|
||||
: current.filter((id) => id !== session.program_session_id))
|
||||
setReschedulePreview(null)
|
||||
}} />{session.title} ({session.planned_for ?? 'sans date'})
|
||||
</label>)}
|
||||
</fieldset>
|
||||
<button type="button" disabled={reschedulePending || !rescheduleStart ||
|
||||
!rescheduleThrough || !rescheduleDate} onClick={previewReschedule}>Calculer l’aperçu</button>
|
||||
{reschedulePreview && <div className="program-calendar-reschedule-preview">
|
||||
<h3>Aperçu avant application</h3>
|
||||
<table><thead><tr><th>Séance</th><th>Date initiale</th><th>Date actuelle</th>
|
||||
<th>Nouvelle date</th><th>Changement</th></tr></thead><tbody>
|
||||
{reschedulePreview.result.moves.map((move) => <tr key={move.program_session_id}>
|
||||
<td>{detail.sessions.find((session) => session.program_session_id === move.program_session_id)?.title}</td>
|
||||
<td>{move.original_for ?? '—'}</td><td>{move.old_for ?? '—'}</td>
|
||||
<td>{move.new_for ?? '—'}</td>
|
||||
<td>{move.change === 'ceded' ? 'Créneau cédé'
|
||||
: move.change === 'unchanged' ? 'Inchangée' : 'Recalée'}</td>
|
||||
</tr>)}
|
||||
</tbody></table>
|
||||
<button type="button" disabled={reschedulePending} onClick={confirmReschedule}>
|
||||
Confirmer ce recalage</button>
|
||||
</div>}
|
||||
</section>
|
||||
{days.length > 0 && <div className="program-calendar-scroll" tabIndex={0}
|
||||
aria-label="Calendrier hebdomadaire du programme">
|
||||
<div className="program-calendar-grid">
|
||||
|
|
|
|||
|
|
@ -1978,6 +1978,43 @@ h2 {
|
|||
color: var(--subtext-0);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.program-calendar-reschedule {
|
||||
margin: 0 0 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--surface-1);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.program-calendar-reschedule-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.program-calendar-reschedule-fields label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: min(100%, 12rem);
|
||||
}
|
||||
.program-calendar-reschedule fieldset {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.program-calendar-reschedule-preview {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.program-calendar-reschedule-preview table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.program-calendar-reschedule-preview th,
|
||||
.program-calendar-reschedule-preview td {
|
||||
padding: 0.35rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--surface-1);
|
||||
}
|
||||
.program-calendar-scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
|
|
|
|||
Loading…
Reference in a new issue