diff --git a/.gitattributes b/.gitattributes index b83f9dd6..dbcf249e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ tools/session_store/integration.patch whitespace=-blank-at-eol tools/aaps4_compat/master-admission.patch whitespace=-blank-at-eol +tools/aaps4_compat/upload-profile-retry.patch whitespace=-blank-at-eol,-blank-at-eof diff --git a/AI_NOTES.md b/AI_NOTES.md index 232349d9..e3758b78 100644 --- a/AI_NOTES.md +++ b/AI_NOTES.md @@ -1,3 +1,48 @@ +# Native Missing-Profile Upload Retry - 2026-10-23 + +## Change And Evidence + +- Reproduced three failures in the pinned native uploader: missing-profile TBR + and EB advanced cursors; direct transport acknowledged without a network call. +- Added a separate minimal retry patch: hold the unconverted record until its + historical profile is available. Preserve imported/identifier-only skips. +- Initial two native suites86passed;16recipe checks passed. Extended combined + verification covers emulated delivery and HTTP failures followed by recovery. +- Combined native recipe passed in1m4s:485tests, no failures/errors/skips, + including87uploader cases. Unchanged suites reused verified Gradle task outputs; + source cleanup returned the upstream checkout to its exact pristine pin. +- All22recipe/JSON-isolation checks passed; shell syntax and whitespace pass. +- The recipe applies/reverses only owned patches; unknown changes are preserved. + No Copilot runtime code, therapy, phone, server or existing APK modification. + +## Limits And Checks + +See AAPS4_UPLOAD_PROFILE_RETRY.md. An unavailable profile can hold that collection +indefinitely; no reset or current-profile substitution is permitted. Historical +skips, destination binding, physical delivery and clinical forecast validation +remain separate. Exact-head CI and reviewed integration remain required; no new +clinical admission follows from source verification. + +# Native Insulin Form Audit - 2026-10-23 + +- Five pure regressions reproduced hidden basal/extended fields in carbohydrate, + unrelated, instantaneous and emulated-child branches. A sixth regression used + accepted wire data and the native Room source. Original failing logs retained. +- Audit explicit delivery evidence before native mapper precedence. Preserve + legitimate bolus/basal/extended forms and one native emulated child; competing + forms refuse a partial inventory. Unrelated durations/glucose/profile context + and null fields do not create insulin. Storage remains lossless and unchanged. +- Retained-cache refresh, database reopen and valid correction recovery pass; + no clinical/action rows are written. All164focused tests pass without skips. + Frozen full test/lint/compile/assemble passed in6m48s:6800passed/six existing + skips, zero failures/errors, real pristine SDK TLS2.142s and zero lint errors + with382existing warnings/eight hints. All1431source hashes stayed unchanged + and APK retention passed. Mainfa6f22b7 CI39906923561 passed all three jobs. + Exact-head feature CI and main integration remain separate post-commit gates. +- Existing0.1.11 release, AAPS, live onset clock, targets, doses and alarms remain + unchanged. This stage does not attest complete upstream/pump history or enable + a clinically recommended meal waiting time. + # Insulin History Knowledge Boundary And 0.1.11 - 2026-10-23 - Main6a0a7894 CI39881365894 passed all three jobs. Native basal/profile source diff --git a/android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3InsulinInventory.kt b/android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3InsulinInventory.kt index 53367f54..6794b7c8 100644 --- a/android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3InsulinInventory.kt +++ b/android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3InsulinInventory.kt @@ -111,9 +111,14 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot) private fun dose(body: JsonObject): AapsV3InsulinDose? { val insulin = number(body, "insulin") + val event = string(body, "eventType") + val intervalFields = listOf("enteredinsulin", "absolute", "percent", "rate", "extendedEmulated") + val intervalEvidence = event in setOf("Temp Basal", "Combo Bolus") || + intervalFields.any { present(body, it) } || boolean(body, "isEmulatingTempBasal") == true require(insulin == null || insulin >= 0) - // The native mapper chooses insulin before eventType and before carbohydrate fields. + // Native branch precedence is not proof that competing delivery fields are absent. if (insulin != null && insulin > 0) { + require(!intervalEvidence) require((number(body, "carbs") ?: 0.0) == 0.0) require((number(body, "duration") ?: 0.0) == 0.0 && (number(body, "durationInMilliseconds") ?: 0.0) == 0.0) require(body["extendedEmulated"] == null || body["extendedEmulated"] == JsonNull) @@ -122,10 +127,20 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot) val config = body["icfg"]?.takeUnless { it == JsonNull }?.let { AapsV3ProfileJson.insulin(HistoryJson.obj(it)) } return AapsV3InsulinDose.Bolus(insulin, type, boolean(body, "isBasalInsulin") ?: false, config) } - if ((number(body, "carbs") ?: 0.0) != 0.0) return null - return when (string(body, "eventType")) { + require(boolean(body, "isBasalInsulin") != true) + if ((number(body, "carbs") ?: 0.0) != 0.0) { + require(!intervalEvidence) + return null + } + return when (event) { "Temp Basal" -> { - body["extendedEmulated"]?.takeUnless { it == JsonNull }?.let { return extended(HistoryJson.obj(it)) } + require(!present(body, "enteredinsulin")) + body["extendedEmulated"]?.takeUnless { it == JsonNull }?.let { + val child = HistoryJson.obj(it) + require(boolean(child, "isEmulatingTempBasal") == true) + return extended(child) + } + require(boolean(body, "isEmulatingTempBasal") != true) val duration = duration(body) val absolute = number(body, "absolute")?.also { require(it >= 0) } val percent = number(body, "percent")?.also { require(it >= -100) } @@ -137,11 +152,18 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot) } "Combo Bolus" -> extended(body) "Correction Bolus", "Meal Bolus", "Snack Bolus" -> throw IllegalArgumentException("missing_native_insulin") - else -> null + else -> { + require(!intervalEvidence) + null + } } } private fun extended(body: JsonObject): AapsV3InsulinDose.Extended { + require((number(body, "insulin") ?: 0.0) == 0.0 && (number(body, "carbs") ?: 0.0) == 0.0) + require(boolean(body, "isBasalInsulin") != true) + require(listOf("absolute", "percent", "extendedEmulated").none { present(body, it) }) + number(body, "rate")?.let { require(it >= 0) } val units = checkNotNull(number(body, "enteredinsulin")).also { require(it >= 0) } return AapsV3InsulinDose.Extended(units, duration(body), boolean(body, "isEmulatingTempBasal") ?: false) } @@ -176,4 +198,5 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot) private fun number(body: JsonObject, key: String) = body[key]?.takeUnless { it == JsonNull }?.let(HistoryJson::double) private fun string(body: JsonObject, key: String) = body[key]?.takeUnless { it == JsonNull }?.let(HistoryJson::string) private fun boolean(body: JsonObject, key: String) = body[key]?.takeUnless { it == JsonNull }?.let(HistoryJson::bool) + private fun present(body: JsonObject, key: String) = body[key]?.let { it != JsonNull } == true } diff --git a/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSourceTest.kt b/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSourceTest.kt index 9e9d05f8..482f0c70 100644 --- a/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSourceTest.kt +++ b/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSourceTest.kt @@ -137,6 +137,41 @@ class AapsV3EffectiveProfileSourceTest { Unit } + @Test fun acceptedMixedWirePayloadCannotHideDeliveryAcrossCacheAndReopen() = runBlocking { + val from = at - 60_000 + store().create(input("profile", from)) + val api = LocalNightscoutV3EntriesApi(db) { at + 100_000 } + val body = buildJsonObject { + put("app", "AAPS"); put("eventType", "Temp Basal"); put("date", from) + put("duration", 1); put("absolute", 0.8); put("carbs", 24) + }.toString() + val accepted = api.handle("POST", "/api/v3/treatments", emptyMap()) { body.toByteArray() } + assertEquals(201, accepted.status) + val id = Json.parseToJsonElement(accepted.body).jsonObject.getValue("identifier").jsonPrimitive.content + suspend fun rejected(reader: AapsV3EffectiveProfileSource) { + val result = reader.readEvidence(from, at, at + 200_000) + assertEquals(AapsV3ObservedInsulinStatus.INVALID, result.insulin.status) + assertTrue(result.insulin.records.isEmpty()) + assertEquals(AapsV3ReportedBasalStatus.INPUT_UNAVAILABLE, result.reportedBasal.status) + assertTrue(result.reportedBasal.segments.isEmpty()) + } + val source = AapsV3EffectiveProfileSource(store()) + rejected(source) + db.close(); db = open() + val reopened = AapsV3EffectiveProfileSource(store()) + rejected(reopened) + val original = checkNotNull(store().get(LocalV3Collection.TREATMENTS, id)) + val corrected = JsonObject(Json.parseToJsonElement(original.payloadJson).jsonObject - "carbs") + store().update(LocalV3DocumentInput(LocalV3Collection.TREATMENTS, id, "local-v3-owner", from, + corrected.toString()), original.modifiedRevision) + val result = reopened.readEvidence(from, at, at + 200_000) + assertEquals(AapsV3ObservedInsulinStatus.OBSERVED, result.insulin.status) + assertEquals(AapsV3ReportedBasalStatus.OBSERVED_WINDOW, result.reportedBasal.status) + assertFalse(result.insulin.upstreamCompletenessVerified) + assertTrue(db.therapyDao().since(0).isEmpty()) + assertTrue(db.actionCommandDao().latest(10).isEmpty()) + } + @Test fun profileHistorySharesRevisionAndRetainsCorrectionsAndDeletionAcrossReopen() = runBlocking { store().create(input("old", at - 60_000)) val original = store().create(input("new", at - 10_000, 6.5)).document diff --git a/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3InsulinInventoryTest.kt b/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3InsulinInventoryTest.kt index 7ba241e1..22f2a08c 100644 --- a/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3InsulinInventoryTest.kt +++ b/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3InsulinInventoryTest.kt @@ -229,4 +229,93 @@ class AapsV3InsulinInventoryTest { assertFalse(result.records.single().toString().contains("dose-1")) assertFalse(result.records.single().dose.toString().contains("historical")) } + + @Test fun unrecognizedDeliveryFieldsCannotBecomeAnEmptyOrPartialInventory() { + val indicators = listOf( + buildJsonObject { put("enteredinsulin", 1.0) }, + buildJsonObject { put("absolute", 0.0) }, + buildJsonObject { put("percent", -100) }, + buildJsonObject { put("rate", 0.8) }, + buildJsonObject { put("isEmulatingTempBasal", true) }, + buildJsonObject { put("isBasalInsulin", true) }, + buildJsonObject { put("extendedEmulated", buildJsonObject { + put("enteredinsulin", 1.0); put("duration", 30); put("isEmulatingTempBasal", true) + }) } + ) + for (indicator in indicators) for (event in listOf("Note", "Future Delivery")) { + val fields = JsonObject(indicator + ("eventType" to JsonPrimitive(event))) + val hidden = row("hidden", fields = fields) + for (result in listOf(read(hidden), read(row(), hidden))) { + assertEquals("$event $indicator", AapsV3ObservedInsulinStatus.INVALID, result.status) + assertTrue(result.records.isEmpty()) + } + // Unknown interval support cannot be discarded as expired. + assertEquals(AapsV3ObservedInsulinStatus.INVALID, + read(row("old", time = at - 7_200_000, fields = fields)).status) + } + } + + @Test fun carbohydrateBranchCannotHideBasalOrExtendedDelivery() { + for (delivery in listOf( + buildJsonObject { put("eventType", "Temp Basal"); put("absolute", 0.8); put("duration", 30) }, + buildJsonObject { put("eventType", "Combo Bolus"); put("enteredinsulin", 1.0); put("duration", 30) }, + buildJsonObject { put("eventType", "Carb Correction"); put("enteredinsulin", 1.0) } + )) { + val result = read(row(fields = JsonObject(delivery + ("carbs" to JsonPrimitive(24))))) + assertEquals(AapsV3ObservedInsulinStatus.INVALID, result.status) + assertTrue(result.records.isEmpty()) + } + } + + @Test fun instantaneousInsulinCannotHideAdditionalDeliveryForms() { + for (extra in listOf( + buildJsonObject { put("enteredinsulin", 1.0) }, + buildJsonObject { put("absolute", 0.0) }, + buildJsonObject { put("percent", -100) }, + buildJsonObject { put("rate", 0.8) }, + buildJsonObject { put("isEmulatingTempBasal", true) } + )) { + assertEquals(AapsV3ObservedInsulinStatus.INVALID, read(row(fields = JsonObject(extra + + ("insulin" to JsonPrimitive(2.2))))).status) + } + } + + @Test fun basalAndExtendedBranchesCannotHideAnotherForm() { + val basal = buildJsonObject { put("eventType", "Temp Basal"); put("duration", 30); put("absolute", 0.8) } + val extended = buildJsonObject { put("eventType", "Combo Bolus"); put("duration", 30); put("enteredinsulin", 1.0) } + for (fields in listOf(JsonObject(basal + ("enteredinsulin" to JsonPrimitive(1.0))), + JsonObject(extended + ("absolute" to JsonPrimitive(0.8))), + JsonObject(extended + ("percent" to JsonPrimitive(0.0))))) { + assertEquals(AapsV3ObservedInsulinStatus.INVALID, read(row(fields = fields)).status) + } + } + + @Test fun emulatedChildCannotSilentlyDropMixedOrRecursivePayloads() { + val child = buildJsonObject { put("enteredinsulin", 1.0); put("duration", 30); put("isEmulatingTempBasal", true) } + for (extra in listOf( + buildJsonObject { put("insulin", 2.0) }, + buildJsonObject { put("carbs", 24) }, + buildJsonObject { put("absolute", 0.8) }, + buildJsonObject { put("isEmulatingTempBasal", false) }, + buildJsonObject { put("extendedEmulated", child) } + )) { + val result = read(row(fields = buildJsonObject { + put("eventType", "Temp Basal"); put("duration", 30); put("absolute", 0.8) + put("extendedEmulated", JsonObject(child + extra)) + })) + assertEquals(AapsV3ObservedInsulinStatus.INVALID, result.status) + assertTrue(result.records.isEmpty()) + } + } + + @Test fun nonDeliveryDurationsGlucoseAndInsulinConfigurationDoNotBecomeDoses() { + for (fields in listOf( + buildJsonObject { put("eventType", "Carb Correction"); put("carbs", 24); put("duration", 120) }, + buildJsonObject { put("eventType", "Exercise"); put("duration", 30) }, + buildJsonObject { put("eventType", "Note"); put("glucose", 5.5); put("icfg", config()) }, + buildJsonObject { put("eventType", "Note"); put("enteredinsulin", JsonNull); put("rate", JsonNull) } + )) { + assertEquals(AapsV3ObservedInsulinStatus.NOT_OBSERVED, read(row(fields = fields)).status) + } + } } diff --git a/docs/AAPS4_INSULIN_OBSERVATIONS.md b/docs/AAPS4_INSULIN_OBSERVATIONS.md index e3d5ffcd..1bc8f5ba 100644 --- a/docs/AAPS4_INSULIN_OBSERVATIONS.md +++ b/docs/AAPS4_INSULIN_OBSERVATIONS.md @@ -28,6 +28,15 @@ earlier absence. Latest retained versions are not a historical version archive. Queries include intervals that start before the requested window and overlap it. Malformed records cannot be declared expired using an unknown duration. +Native mapper precedence is not completeness evidence. A carbohydrate branch or +unknown event cannot hide entered insulin, basal rate/percentage or an emulated +child. An instantaneous bolus cannot silently discard interval fields. Conflicting +forms reject the whole observational inventory, including old records with unknown +support. Null fields, ordinary carbohydrate/exercise durations and glucose/profile +configuration alone are not delivery. Emulated wrappers still select one child, +which must explicitly be emulating basal and cannot contain another delivery form. +Transport retention stays lossless; successful storage is separate from admission. + The existing native connection owner publishes observation status and record count. The count includes invalidated retained records and is not delivered insulin or IOB. Stop/revocation/offline ownership clears the published observation status. No new @@ -42,6 +51,11 @@ and ignores changes that only add that ID. These counters are not in treatment payloads. A zero queue therefore cannot prove that a new destination received the old history. Similar collection-specific selectors must be considered separately. +Missing historical profiles can also advance native interval-upload cursors +without a network request. AAPS4_UPLOAD_PROFILE_RETRY.md records the minimal +retry patch and its limits. It prevents future silent skips in the patched source; +it cannot attest or recover already missing records or lift the completeness gate. + Initial history admission needs a source/destination-bound generation and complete window with explicit revisions, deletions, record kinds and acknowledgements. Existing foreign Nightscout IDs cannot simply be assumed present in the local diff --git a/docs/AAPS4_UPLOAD_PROFILE_RETRY.md b/docs/AAPS4_UPLOAD_PROFILE_RETRY.md new file mode 100644 index 00000000..559097e4 --- /dev/null +++ b/docs/AAPS4_UPLOAD_PROFILE_RETRY.md @@ -0,0 +1,57 @@ +# Historical Profile Upload Retry + +Pinned upstream: AAPS4.0.0-rc1 at +`d5e7f5bdbb4aaefa4eaaa89a46fba257ef396fa0`. + +## Failure + +The native uploader converts temporary basal and extended bolus records using +the profile at the record timestamp. Without that profile, the stock transport +returns success before calling the network client. The extended-bolus selector +also counts a missing-profile record as ignored and advances its synchronization +cursor. An apparently drained queue therefore can omit insulin history. + +## Minimal Patch + +`tools/aaps4_compat/upload-profile-retry.patch` changes only the two uploader +classes and adds synthetic tests to their existing native suites: + +- A missing historical profile returns false for both CREATE and UPDATE of a + temporary basal or extended bolus, including an emulated basal and invalidation. +- The extended-bolus selector stops that collection at the unconverted record + without advancing its cursor. Its next normal upload retries the same record. +- Existing imported-record and identifier-only skip rules remain unchanged. +- No current-profile fallback, clinical record rewrite, cursor reset, new worker, + endpoint, credential, dose calculation or pump command is added. + +Missing profile is not an HTTP error to ignore. Even when the upstream +IGNORE_NS_V3_ERRORS option is enabled, it cannot turn an unsent conversion into +an acknowledgement. Other native HTTP-error policies remain unchanged. + +## Limits + +If the historical profile never becomes available, that collection remains +blocked. Newer records in that collection cannot bypass the gap; other collections +keep their existing upload behavior. This is deliberate retention, not an automatic +repair. Do not clear synchronization IDs or substitute today's profile to advance +the queue. An eventual explicit gap-aware transfer protocol is a separate stage. + +This does not recover records already skipped by an older build, verify foreign +Nightscout identifiers at a new destination, attest a stable full-history window, +or confirm physical pump delivery. Copilot still keeps +`upstreamCompletenessVerified=false` and the live meal recommendation gate closed. +No AAPS APK or phone update is part of this source stage. + +## Checks + +Three native failures were reproduced before the fix. Tests exercise real selector +and plugin methods against a mocked SDK client: missing-profile CREATE, recovery, +HTTP failure without cursor advancement, successful retry, then missing-profile +invalidation and recovery. Ordinary extended bolus, emulated basal and temporary +basal use separate cases. Imported and identifier-only records remain skipped. + +The existing verification recipe applies all reviewed patches to the exact clean +pin, runs the native suites, and reverses only its unchanged owned diff. Missing +patch files and concurrent changes fail without overwriting unknown work. CI +then rebuilds the pristine SDK before Copilot TLS tests. Passing these tests is +software evidence, not clinical recommendation admission. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 05f96bb0..d13d4c8b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,6 +36,9 @@ without separate coverage/clock/delivery admission. See AAPS4_REPORTED_BASAL.md. The insulin index, like profile history, rejects a newer retained collection revision before window filtering. Revisions of excluded rows must not erase exposure from an earlier as-known query; this is not a versioned history archive. +The typed insulin index also audits delivery fields before applying native branch +precedence. Mixed/unknown forms remain retained for diagnosis but cannot produce a +partial insulin or basal read. Transport behavior and clinical tables are unchanged. ## RC1 Target Transaction Boundary diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index bd247997..d6d1ead1 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -28,6 +28,10 @@ applies the reviewed receiver/target-transaction/test patch to a clean pinned RC checkout, runs native/SMS/executor and real Room rollback regressions, and reverses its own exact diff and added test file even after a test failure. Unknown concurrent edits are preserved and fail the check. The existing +recipe now includes upload-profile-retry.patch and both complete native uploader +suites; see AAPS4_UPLOAD_PROFILE_RETRY.md. A missing historical profile holds the +corresponding upload queue, not a successful acknowledgement. This verification +does not install the patch on a phone or attest full delivery history. The pristine SDK preparation gate stays unchanged; Verify separately installs the upstream-required Android37 platform (`platforms;android-37.0`, the exact Google package identifier) for native module tests. This is not a signed diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md index 38eb13aa..4a8c8820 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -45,6 +45,11 @@ - As-known insulin reads reject a collection revision newer than knowledge time before filtering. A moved/shortened/deleted dose, unrelated change or empty latest snapshot cannot be used to reconstruct an earlier absence of delivery. +- Native branch precedence cannot erase competing insulin forms. Carbohydrate, + unrelated or unknown records with explicit interval-delivery evidence reject + the inventory rather than disappearing. Instantaneous and extended forms cannot + absorb another form's fields. A valid emulated wrapper selects exactly one child. + Successful transport storage does not qualify such a payload for prediction. ## RC1 Target Admission diff --git a/docs/PLAN.md b/docs/PLAN.md index 0a35157b..1d001d2c 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,5 +1,28 @@ # PLAN +## Native Missing-Profile Upload Retry (2026-10-23) + +- [x] Reproduce three native false acknowledgements/cursor-advance failures. +- [x] Preserve missing-profile records for normal retry; no current-profile fallback. +- [x] Verify initial uploader suites86passed and patch-recipe checks16passed. +- [x] Combined native recipe passed485tests, including87uploader tests and all + six new cases; no failures or skips. Pristine source restored afterwards. +- [x] Full recipe/JSON-isolation suite22passed; shell syntax/diff checks passed. +- [ ] Publish reviewed source and inspect exact-head CI before integration. +- This is an external minimal AAPS source patch, not a device migration or full + history receipt. The recommended meal-time gate remains closed. + +## Native Insulin Form Audit (2026-10-23) + +- [x] Reproduce five form omissions plus one accepted-wire/native-Room omission. +- [x] Reject hidden/competing native delivery forms without changing transport. +- [x] All164focused tests passed; frozen full Android quality passed6800tests + with six existing skips, real SDK TLS, lint and APK retention checks. +- [x] Mainfa6f22b7 CI39906923561 passed all three jobs. +- [ ] Publish reviewed source, inspect exact-head CI and integrate into main. +- Independent upload/pump/clock coverage and prospective meal-time validation + remain required. No recommended eating-time admission follows from this audit. + ## Insulin History Knowledge Boundary And 0.1.11 (2026-10-23) - [x] Confirm main6a0a7894 CI39881365894 passed all three jobs. diff --git a/tools/aaps4_compat/test_master_recipe.py b/tools/aaps4_compat/test_master_recipe.py index b91fa43b..9db66054 100644 --- a/tools/aaps4_compat/test_master_recipe.py +++ b/tools/aaps4_compat/test_master_recipe.py @@ -26,12 +26,14 @@ def setUp(self): self.run_git("config", "user.email", "validation@example.invalid") (self.source / "receiver.txt").write_text("original\n") (self.source / "transport.txt").write_text("original transport\n") - (self.source / ".gitignore").write_text(".gradle-invoked\n.transport-invoked\n") + (self.source / "upload.txt").write_text("original upload\n") + (self.source / ".gitignore").write_text(".gradle-invoked\n.transport-invoked\n.upload-invoked\n") wrapper = self.source / "gradlew" wrapper.write_text( "#!/usr/bin/env bash\nset -eu\n" "printf '%s\\n' \"$@\" > .gradle-invoked\n" "cp transport.txt .transport-invoked\n" + "cp upload.txt .upload-invoked\n" "if [ -n \"${AAPS_INTEROP_CLASSPATH:-}\" ]; then\n" " mkdir -p \"$(dirname \"$AAPS_INTEROP_CLASSPATH\")\"\n" " printf '%s\\n' synthetic > \"$AAPS_INTEROP_CLASSPATH\"\nfi\n" @@ -43,10 +45,12 @@ def setUp(self): " printf '%s\\n' 'other edit' >> added.txt\nfi\n" "if [ \"${FIXTURE_MODE:-}\" = transport-edit ]; then\n" " printf '%s\\n' 'other edit' >> transport.txt\nfi\n" + "if [ \"${FIXTURE_MODE:-}\" = upload-edit ]; then\n" + " printf '%s\\n' 'other edit' >> upload.txt\nfi\n" "if [ \"${FIXTURE_MODE:-}\" = failure ]; then exit 9; fi\n" ) wrapper.chmod(0o755) - self.run_git("add", "receiver.txt", "transport.txt", "gradlew", ".gitignore") + self.run_git("add", "receiver.txt", "transport.txt", "upload.txt", "gradlew", ".gitignore") self.run_git("commit", "-qm", "Synthetic initial state") tools = self.root / "project" / "tools" / "aaps4_compat" tools.mkdir(parents=True) @@ -64,6 +68,10 @@ def setUp(self): self.transport_patch = tools / "transport-privacy.patch" self.transport_patch.write_text(self.run_git("diff", "--binary", "HEAD", "--", "transport.txt").stdout) (self.source / "transport.txt").write_text("original transport\n") + (self.source / "upload.txt").write_text("patched upload\n") + self.upload_patch = tools / "upload-profile-retry.patch" + self.upload_patch.write_text(self.run_git("diff", "--binary", "HEAD", "--", "upload.txt").stdout) + (self.source / "upload.txt").write_text("original upload\n") binaries = self.root / "bin" binaries.mkdir() self.scratch = self.root / "scratch" @@ -102,6 +110,7 @@ def test_success_reverses_only_its_patch(self): self.assertTrue((self.source / ".gradle-invoked").exists()) self.assertFalse((self.source / "added.txt").exists()) self.assertEqual("patched transport\n", (self.source / ".transport-invoked").read_text()) + self.assertEqual("patched upload\n", (self.source / ".upload-invoked").read_text()) arguments = (self.source / ".gradle-invoked").read_text().splitlines() self.assertIn(":plugins:sync:testAndroidHostTest", arguments) self.assertIn(":implementation:testAndroidHostTest", arguments) @@ -109,6 +118,8 @@ def test_success_reverses_only_its_patch(self): self.assertIn(":database:impl:jvmTest", arguments) self.assertIn("*.TargetAdmissionTransactionTest", arguments) self.assertIn("*.SmsCommunicatorPluginTest", arguments) + self.assertIn("*.NSClientV3PluginTest", arguments) + self.assertIn("*.DataSyncSelectorV3Test", arguments) self.assertIn(":core:nssdk:jvmTest", arguments) def test_missing_transport_patch_refuses_before_changing_source(self): @@ -125,6 +136,19 @@ def test_concurrent_transport_edit_is_not_reversed(self): self.assertIn("preserving the checkout", result.stderr) self.assertEqual("patched transport\nother edit\n", (self.source / "transport.txt").read_text()) + def test_missing_upload_patch_refuses_before_changing_source(self): + self.upload_patch.unlink() + result = self.run_recipe() + self.assertNotEqual(0, result.returncode) + self.assertFalse((self.source / ".gradle-invoked").exists()) + self.assertEqual("", self.run_git("diff", "HEAD", "--").stdout) + + def test_concurrent_upload_edit_is_not_reversed(self): + result = self.run_recipe("upload-edit") + self.assertNotEqual(0, result.returncode) + self.assertIn("preserving the checkout", result.stderr) + self.assertEqual("patched upload\nother edit\n", (self.source / "upload.txt").read_text()) + def test_prepare_rejects_untracked_sdk_source(self): (self.source / "unknown.txt").write_text("owner content\n") result = subprocess.run(["bash", str(self.prepare)], env=self.env, diff --git a/tools/aaps4_compat/upload-profile-retry.patch b/tools/aaps4_compat/upload-profile-retry.patch new file mode 100644 index 00000000..145fc103 --- /dev/null +++ b/tools/aaps4_compat/upload-profile-retry.patch @@ -0,0 +1,259 @@ +diff --git a/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/nsclientV3/DataSyncSelectorV3Test.kt b/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/nsclientV3/DataSyncSelectorV3Test.kt +index 6883cb9a10..b60bae2130 100644 +--- a/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/nsclientV3/DataSyncSelectorV3Test.kt ++++ b/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/nsclientV3/DataSyncSelectorV3Test.kt +@@ -2,6 +2,7 @@ package app.aaps.plugins.sync.nsclientV3 + + import app.aaps.core.data.model.BS + import app.aaps.core.data.model.CA ++import app.aaps.core.data.model.EB + import app.aaps.core.data.model.IDs + import app.aaps.core.interfaces.db.PersistenceLayer + import app.aaps.core.interfaces.nsclient.NSClientRepository +@@ -615,6 +616,31 @@ class DataSyncSelectorV3Test : TestBaseWithProfile() { + verify(nsClientV3Plugin, Times(0)).nsAdd(any(), any(), any(), anyOrNull()) + } + ++ @Test ++ fun missingProfilePreservesImportedExtendedBolusSkip() = runTest { ++ val value = EB(id = 1, timestamp = 10000, amount = 2.0, duration = 3600000, ++ ids = IDs(nightscoutId = "existing-1")) ++ verifyExtendedBolusSkipWithoutProfile(value, value) ++ } ++ ++ @Test ++ fun missingProfilePreservesExtendedBolusIdentifierOnlySkip() = runTest { ++ val value = EB(id = 1, timestamp = 10000, amount = 2.0, duration = 3600000, ++ ids = IDs(nightscoutId = "existing-1")) ++ verifyExtendedBolusSkipWithoutProfile(value, value.copy(id = 2, ids = IDs())) ++ } ++ ++ private suspend fun verifyExtendedBolusSkipWithoutProfile(current: EB, previous: EB) { ++ whenever(profileFunction.getProfile(10000)).thenReturn(null) ++ whenever(preferences.get(NsclientLongKey.ExtendedBolusLastSyncedId)).thenReturn(0L) ++ whenever(persistenceLayer.getLastExtendedBolusId()).thenReturn(previous.id) ++ whenever(persistenceLayer.getNextSyncElementExtendedBolus(0)).thenReturn(Pair(current, previous), null) ++ sut.processChangedExtendedBoluses() ++ verify(preferences).put(NsclientLongKey.ExtendedBolusLastSyncedId, previous.id) ++ verify(nsClientV3Plugin, Times(0)).nsAdd(any(), any(), any(), anyOrNull()) ++ verify(nsClientV3Plugin, Times(0)).nsUpdate(any(), any(), any(), anyOrNull()) ++ } ++ + // Tests for processChangedExtendedBoluses + @Test + fun processChangedExtendedBolusesWhenPausedTest() = runTest { +@@ -1053,4 +1079,4 @@ class DataSyncSelectorV3Test : TestBaseWithProfile() { + verify(preferences, Times(1)).put(NsclientLongKey.CarbsLastSyncedId, 7L) + verify(preferences, Times(1)).put(NsclientLongKey.CarbsLastSyncedId, 8L) + } +-} +\ No newline at end of file ++} +diff --git a/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/nsclientV3/NSClientV3PluginTest.kt b/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/nsclientV3/NSClientV3PluginTest.kt +index 5905fb2fd0..8c74de2fd3 100644 +--- a/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/nsclientV3/NSClientV3PluginTest.kt ++++ b/plugins/sync/src/androidHostTest/kotlin/app/aaps/plugins/sync/nsclientV3/NSClientV3PluginTest.kt +@@ -21,6 +21,7 @@ import app.aaps.core.data.model.TrendArrow + import app.aaps.core.data.plugin.PluginType + import app.aaps.core.data.pump.defs.PumpType + import app.aaps.core.data.time.T ++import app.aaps.core.interfaces.configuration.ExternalOptions + import app.aaps.core.interfaces.db.PersistenceLayer + import app.aaps.core.interfaces.logging.L + import app.aaps.core.interfaces.logging.UserEntryLogger +@@ -38,6 +39,7 @@ import app.aaps.core.nssdk.localmodel.treatment.CreateUpdateResponse + import app.aaps.core.nssdk.remotemodel.LastModified + import app.aaps.plugins.sync.SyncStringsValues + import app.aaps.plugins.sync.nsclientV3.clientcontrol.OrphanDetector ++import app.aaps.plugins.sync.nsclientV3.keys.NsclientLongKey + import app.aaps.plugins.sync.nsclientV3.keys.NsclientStringKey + import app.aaps.plugins.sync.nsclientV3.ws.NsConnection + import app.aaps.plugins.sync.nsclientV3.ws.NsLoadExecutor +@@ -63,8 +65,12 @@ import org.mockito.ArgumentMatchers.anyLong + import org.mockito.Mock + import org.mockito.kotlin.any + import org.mockito.kotlin.anyOrNull ++import org.mockito.kotlin.clearInvocations ++import org.mockito.kotlin.doAnswer ++import org.mockito.kotlin.eq + import org.mockito.kotlin.mock + import org.mockito.kotlin.never ++import org.mockito.kotlin.times + import org.mockito.kotlin.verify + import org.mockito.kotlin.whenever + import kotlin.concurrent.atomics.ExperimentalAtomicApi +@@ -496,6 +502,94 @@ internal class NSClientV3PluginTest : TestBaseWithProfile() { + assertThat(storeDataForDb.nsIdExtendedBoluses.snapshot()).hasSize(2) + } + ++ @Test ++ fun missingProfileNeverAcknowledgesIntervalDelivery() = runTest { ++ whenever(config.isEnabled(ExternalOptions.IGNORE_NS_V3_ERRORS)).thenReturn(true) ++ val pairs = listOf( ++ DataSyncSelector.PairExtendedBolus(EB(timestamp = 10000, amount = 2.0, duration = 3600000), 1), ++ DataSyncSelector.PairTemporaryBasal(TB(timestamp = 10000, type = TB.Type.NORMAL, ++ rate = 1.0, isAbsolute = true, duration = 3600000), 1) ++ ) ++ for (pair in pairs) { ++ assertThat(sut.nsAdd("treatments", pair, "0/1", null)).isFalse() ++ assertThat(sut.nsUpdate("treatments", pair, "0/1", null)).isFalse() ++ } ++ verify(nsAndroidClient, never()).createTreatment(anyOrNull()) ++ verify(nsAndroidClient, never()).updateTreatment(anyOrNull()) ++ assertThat(storeDataForDb.nsIdExtendedBoluses.snapshot()).isEmpty() ++ assertThat(storeDataForDb.nsIdTemporaryBasals.snapshot()).isEmpty() ++ } ++ ++ @Test ++ fun missingProfileKeepsExtendedBolusAndDeletionPending() = runTest { ++ verifyIntervalProfileRetry(extended = true) ++ } ++ ++ @Test ++ fun missingProfileKeepsTemporaryBasalAndDeletionPending() = runTest { ++ verifyIntervalProfileRetry(extended = false) ++ } ++ ++ @Test ++ fun missingProfileKeepsEmulatedBasalAndDeletionPending() = runTest { ++ verifyIntervalProfileRetry(extended = true, emulated = true) ++ } ++ ++ private suspend fun verifyIntervalProfileRetry(extended: Boolean, emulated: Boolean = false) { ++ val key = if (extended) NsclientLongKey.ExtendedBolusLastSyncedId else NsclientLongKey.TemporaryBasalLastSyncedId ++ var cursor = 0L ++ var revision = 1L ++ val eb = EB(id = 1, timestamp = 10000, amount = 2.0, duration = 3600000, isEmulatingTempBasal = emulated) ++ val tb = TB(id = 1, timestamp = 10000, type = TB.Type.NORMAL, ++ rate = 1.0, isAbsolute = true, duration = 3600000) ++ whenever(preferences.get(key)).thenAnswer { cursor } ++ doAnswer { cursor = it.getArgument(1); null }.whenever(preferences).put(eq(key), any()) ++ whenever(persistenceLayer.getLastExtendedBolusId()).thenAnswer { revision } ++ whenever(persistenceLayer.getLastTemporaryBasalId()).thenAnswer { revision } ++ whenever(persistenceLayer.getNextSyncElementExtendedBolus(any())).thenAnswer { ++ if (cursor < revision) Pair(eb, eb.copy(id = revision)) else null ++ } ++ whenever(persistenceLayer.getNextSyncElementTemporaryBasal(any())).thenAnswer { ++ if (cursor < revision) Pair(tb, tb.copy(id = revision)) else null ++ } ++ whenever(nsAndroidClient.createTreatment(anyOrNull())).thenReturn( ++ CreateUpdateResponse(500, null), CreateUpdateResponse(201, "receipt-1")) ++ whenever(nsAndroidClient.updateTreatment(anyOrNull())).thenReturn( ++ CreateUpdateResponse(500, null), CreateUpdateResponse(200, "receipt-1")) ++ val selector = DataSyncSelectorV3(preferences, aapsLogger, dateUtil, profileFunction, ++ activePlugin, profileRepository, persistenceLayer, storeDataForDb, config, nsClientRepository) { sut } ++ suspend fun upload() { ++ if (extended) selector.processChangedExtendedBoluses() else selector.processChangedTemporaryBasals() ++ } ++ ++ whenever(profileFunction.getProfile(10000)).thenReturn(null) ++ upload() ++ assertThat(cursor).isEqualTo(0L) ++ verify(nsAndroidClient, never()).createTreatment(anyOrNull()) ++ whenever(profileFunction.getProfile(10000)).thenReturn(effectiveProfile) ++ upload() ++ assertThat(cursor).isEqualTo(0L) ++ upload() ++ assertThat(cursor).isEqualTo(1L) ++ verify(nsAndroidClient, times(2)).createTreatment(anyOrNull()) ++ ++ revision = 2L ++ eb.isValid = false ++ tb.isValid = false ++ clearInvocations(nsAndroidClient) ++ whenever(profileFunction.getProfile(10000)).thenReturn(null) ++ upload() ++ assertThat(cursor).isEqualTo(1L) ++ verify(nsAndroidClient, never()).updateTreatment(anyOrNull()) ++ whenever(profileFunction.getProfile(10000)).thenReturn(effectiveProfile) ++ upload() ++ assertThat(cursor).isEqualTo(1L) ++ upload() ++ assertThat(cursor).isEqualTo(2L) ++ verify(nsAndroidClient, times(2)).updateTreatment(anyOrNull()) ++ verify(nsAndroidClient, never()).createTreatment(anyOrNull()) ++ } ++ + @Test + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + fun nsAddRunningModeTest() = runTest { +diff --git a/plugins/sync/src/commonMain/kotlin/app/aaps/plugins/sync/nsclientV3/DataSyncSelectorV3.kt b/plugins/sync/src/commonMain/kotlin/app/aaps/plugins/sync/nsclientV3/DataSyncSelectorV3.kt +index e9c0b7c134..aca674e790 100644 +--- a/plugins/sync/src/commonMain/kotlin/app/aaps/plugins/sync/nsclientV3/DataSyncSelectorV3.kt ++++ b/plugins/sync/src/commonMain/kotlin/app/aaps/plugins/sync/nsclientV3/DataSyncSelectorV3.kt +@@ -738,32 +738,31 @@ class DataSyncSelectorV3( + persistenceLayer.getNextSyncElementExtendedBolus(startId)?.let { eb -> + lastId = eb.second.id + val profile = profileFunction.getProfile(eb.first.timestamp) +- if (profile != null) { +- when { +- // new record with existing NS id => must be coming from NS => ignore +- eb.first.id == eb.second.id && eb.first.ids.nightscoutId != null -> { +- aapsLogger.debug(LTag.NSCLIENT_SYNC, "Ignoring ExtendedBolus. Loaded from NS: ${eb.second.id}") +- ignored++ +- } +- // only NsId changed, no need to upload +- eb.first.onlyNsIdAdded(eb.second) -> { +- aapsLogger.debug(LTag.NSCLIENT_SYNC, "Ignoring ExtendedBolus. Only NS id changed: ${eb.second.id}") +- ignored++ +- } +- // without nsId = create new +- eb.first.ids.nightscoutId == null -> { +- cont = nsClientV3Plugin().nsAdd("treatments", DataSyncSelector.PairExtendedBolus(eb.first, eb.second.id), "$startId/$lastDbId", profile) == true +- if (cont) added++ +- } +- // with nsId = update +- eb.first.ids.nightscoutId != null -> { +- cont = nsClientV3Plugin().nsUpdate("treatments", DataSyncSelector.PairExtendedBolus(eb.first, eb.second.id), "$startId/$lastDbId", profile) == true +- if (cont) updated++ +- } ++ when { ++ // new record with existing NS id => must be coming from NS => ignore ++ eb.first.id == eb.second.id && eb.first.ids.nightscoutId != null -> { ++ aapsLogger.debug(LTag.NSCLIENT_SYNC, "Ignoring ExtendedBolus. Loaded from NS: ${eb.second.id}") ++ ignored++ ++ } ++ // only NsId changed, no need to upload ++ eb.first.onlyNsIdAdded(eb.second) -> { ++ aapsLogger.debug(LTag.NSCLIENT_SYNC, "Ignoring ExtendedBolus. Only NS id changed: ${eb.second.id}") ++ ignored++ ++ } ++ profile == null -> { ++ aapsLogger.debug(LTag.NSCLIENT_SYNC, "Waiting to upload ExtendedBolus. Historical profile unavailable") ++ cont = false ++ } ++ // without nsId = create new ++ eb.first.ids.nightscoutId == null -> { ++ cont = nsClientV3Plugin().nsAdd("treatments", DataSyncSelector.PairExtendedBolus(eb.first, eb.second.id), "$startId/$lastDbId", profile) == true ++ if (cont) added++ ++ } ++ // with nsId = update ++ eb.first.ids.nightscoutId != null -> { ++ cont = nsClientV3Plugin().nsUpdate("treatments", DataSyncSelector.PairExtendedBolus(eb.first, eb.second.id), "$startId/$lastDbId", profile) == true ++ if (cont) updated++ + } +- } else { +- aapsLogger.debug(LTag.NSCLIENT_SYNC, "Ignoring ExtendedBolus. No profile: ${eb.second.id}") +- ignored++ + } + if (cont) confirmLastExtendedBolusIdIfGreater(eb.second.id) + } ?: run { +diff --git a/plugins/sync/src/commonMain/kotlin/app/aaps/plugins/sync/nsclientV3/NSClientV3Plugin.kt b/plugins/sync/src/commonMain/kotlin/app/aaps/plugins/sync/nsclientV3/NSClientV3Plugin.kt +index 1045353ed5..f2cd089080 100644 +--- a/plugins/sync/src/commonMain/kotlin/app/aaps/plugins/sync/nsclientV3/NSClientV3Plugin.kt ++++ b/plugins/sync/src/commonMain/kotlin/app/aaps/plugins/sync/nsclientV3/NSClientV3Plugin.kt +@@ -977,12 +977,12 @@ class NSClientV3Plugin( + is DataSyncSelector.PairTherapyEvent -> dataPair.value.toNSTherapyEvent() + + is DataSyncSelector.PairTemporaryBasal -> { +- profile ?: return true ++ profile ?: return false + dataPair.value.toNSTemporaryBasal(profile) + } + + is DataSyncSelector.PairExtendedBolus -> { +- profile ?: return true ++ profile ?: return false + dataPair.value.toNSExtendedBolus(profile) + } + diff --git a/tools/aaps4_compat/verify_master.sh b/tools/aaps4_compat/verify_master.sh index 4847de85..2d94284d 100644 --- a/tools/aaps4_compat/verify_master.sh +++ b/tools/aaps4_compat/verify_master.sh @@ -36,7 +36,8 @@ trap 'exit 130' INT trap 'exit 143' TERM cat "$root/tools/aaps4_compat/master-admission.patch" \ - "$root/tools/aaps4_compat/transport-privacy.patch" > "$temp/change.patch" + "$root/tools/aaps4_compat/transport-privacy.patch" \ + "$root/tools/aaps4_compat/upload-profile-retry.patch" > "$temp/change.patch" git -C "$AAPS_RC1_DIR" apply --check "$temp/change.patch" git -C "$AAPS_RC1_DIR" apply "$temp/change.patch" applied=true @@ -50,6 +51,8 @@ cd "$AAPS_RC1_DIR" --tests '*.clientcontrol.BatchActionMappingTest' \ --tests '*.clientcontrol.ClientControlUplinkIntegrationTest' \ --tests '*.SmsCommunicatorPluginTest' \ + --tests '*.NSClientV3PluginTest' \ + --tests '*.DataSyncSelectorV3Test' \ :implementation:testAndroidHostTest --tests '*.WizardBolusExecutorImplTest' \ :database:impl:testAndroidHostTest \ --tests '*.InsertAndCancelCurrentTemporaryTargetTransactionTest' \