fix: make unknown outcomes representable instead of success-shaped - #30
Merged
Conversation
Every defect here is one failure class: a failure rendered as a successful
empty result. `return []` when we could not look. A `count` of 0 that means
"the body was unreadable". A `False` that means "we never asked the user". A
bool that means "we did not check". Each fix makes the distinction
representable -- a raised exception, a returned reason, a different rendering
-- rather than adding a log line above the same wrong value.
Every fix below is mutation-checked: the production change was reverted, the
new test was confirmed to FAIL, and the fix restored.
hosted.py
* `CountResult.from_payload` used `payload.get("count", 0)`, so a response body
that lost or renamed `count` produced a confident all-clear on the one number
the tray exists to report. It now raises `InvalidCountPayload`, `poll_once`
reports a failed poll, and the badge keeps its last known value instead of
clearing to zero. The display-only subfields stay tolerant of absence.
* `_fire_notification` discarded the notifier's return value and `_handle_result`
advanced `_last_count` regardless, so the honest `False` that #29 added to
`_show_windows` went nowhere: a user was recorded as informed about breaks
they were never shown, and the count had to RISE again before a second
attempt. Delivery is now propagated and `_last_count` only advances when the
notification actually landed, so the next poll retries.
* `route_break_click` discarded `webbrowser.open`'s result. A click that opened
nothing now returns False, and `TrayApplication.open_needs_attention` tells
the user instead of leaving them in front of an unchanged screen.
config.py
* `TrayConfig.load` printed a warning and returned defaults when tray.json was
unreadable. `deployment_lane` defaults to "cloud", so a byoc install -- where
the fix must stay local -- was silently moved onto the hosted route by a
corrupt settings file. A missing file still means defaults; an unreadable one
raises `ConfigLoadError`. `load_or_defaults` hands back both so the caller
cannot lose it, and the tray now says so on startup.
menu.py
* `_get_recent_captures` swallowed every exception and returned `[]`, so an
unreadable captures directory rendered the same "No captures" as an empty
one. It now raises, and the menu says "Could not read captures".
* `_view_capture` never read the exit status of `openadapt visualize`, so a CLI
that ran and failed did nothing at all and said nothing. A non-zero exit now
falls back to the file browser, like a missing CLI always did.
platform/
* `prompt_input` returned None and `confirm_dialog` returned False when no
dialog could be shown -- identical to "the user cancelled" and "the user said
no". Clicking Start Recording or Delete could therefore do nothing, forever,
silently. Both now raise `DialogUnavailableError` when nothing could ask, and
the answers they do return are answers the user actually gave.
On Linux, zenity/kdialog exit 1 means the user declined; any other non-zero
exit is the tool failing, which used to be read as the user's answer AND
skipped the remaining fallbacks. On macOS, an osascript execution error is
now separated from a user cancel by stderr.
Callers: a recording proceeds with a default name (and says why there was no
prompt); a delete is not performed and the user is told it was not performed.
CI
* `ruff check src tests scripts` left examples/ and the four root-level helper
scripts outside every gate: 15 findings sat there while CI reported a green
lint. The gate now covers the repository and those findings are fixed --
including a screenshot helper that printed a success tick regardless of
whether the notification was delivered.
* The gitleaks install piped curl into tar without pipefail, so the step
reported tar's status rather than the download's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
A sweep of
openadapt-trayfor one failure class: a failure rendered as a successful empty result.return []when we could not look. Acountof 0 that means "the body was unreadable". AFalsethat means "we never asked the user". A bool that means "we did not check".The judgement rule applied throughout: where a function could not distinguish "no result" from "could not run", the fix makes the distinction representable — a raised exception, a returned reason, a different rendering. Adding a log line above the same wrong value is not a fix.
Every fix is mutation-checked: the production change was reverted, the new test was confirmed to FAIL, and the fix restored.
Verifying PR #29
Both defects #29 reported are real and the repairs hold. Mutation-checked against
b3caac7:test_failed_toast_reports_failurenotifications.py@b3caac7assert True is Falsetest_absent_entry_is_successkeychain.py@b3caac7assert False is Truetest_returncode_drives_result(Linux) passes against pre-#29 code —_show_linuxalways checkedreturncode, so it is a guard test, not a regression test. Reported as such rather than strengthened against a defect that does not exist.#29's toast fix was incomplete at the system level, and this PR finishes it:
_show_windowswas made to return an honestFalse, but its only caller —HostedPoller._fire_notification— discarded it, and_last_countadvanced regardless. The user was recorded as informed about breaks they were never shown, and the count had to RISE again before a second attempt.Defects fixed
hosted.pyCountResult.from_payloadinvented an all-clear.payload.get("count", 0)turned any body that lost or renamedcountinto "nothing needs attention" — on the one number the tray exists to report. Now raisesInvalidCountPayload; the badge keeps its last known value rather than clearing to zero._last_countonly advances when the notification landed, so the next poll retries.route_break_clickdiscardedwebbrowser.open's result. A click that opened nothing now returnsFalseandopen_needs_attentiontells the user.config.pytray.jsonsilently became "default settings".deployment_lanedefaults to"cloud", so a byoc install — where the fix must stay local — was moved onto the hosted route by a corrupt settings file, with only a printed warning. A missing file still means defaults; an unreadable one raisesConfigLoadError, and the tray says so on startup.menu.py_get_recent_capturesreturned[]on any exception, so an unreadable captures directory rendered the same reassuring "No captures" as an empty one. Now raises; the menu says "Could not read captures"._view_capturenever readopenadapt visualize's exit status, so a CLI that ran and failed did nothing at all and said nothing. Non-zero now falls back to the file browser, like a missing CLI always did.platform/prompt_inputreturnedNone(= "cancelled") andconfirm_dialogreturnedFalse(= "no") when no dialog could be shown at all — so Start Recording or Delete could do nothing, forever, silently. Both now raiseDialogUnavailableError.Guards that could not fire
No
|| true,continue-on-errororexit 0anywhere in.github/workflows/. Two real findings:ruff check src tests scriptsleftexamples/and the four root-level helper scripts outside every gate — 15 findings sat there unreported while CI showed a green lint. The gate now covers the repository and those findings are fixed, includinggenerate_screenshots.pyprinting a success tick regardless of whether the notification was delivered (the same failure class, in the tooling).curlintotarwithoutpipefail, so the step reported tar's status rather than the download's. Nowset -euo pipefail+curl --fail.Candidates judged NOT to be defects
_show_linux/_show_macos— already derive their result fromreturncode.pystray.Icon.notify()returningTrue— pystray's API is fire-and-forget with no delivery signal; there is nothing to derive a checked outcome from.icons._load_icon/_load_master— degrade to a correctly rendered fallback icon, not to an empty result, and log since fix: report keychain, toast and IPC failures honestly; pin ruff to 0.16.x #29.app._launch_desktop_app— the uncheckedPopenis covered byensure_desktop_connection, which polls and returnsFalseon timeout. The failure is already representable.poll_oncereturningNonefor every failure kind — theset_offline(True)flag already makes "could not look" representable to the UI and distinct from a count of zero.ipc._handle_message/_process_messageunknown message type, and_on_ipc_status_update'sexcept KeyError: pass— event sinks with no return channel; the only available change is a log line, which is not a fix. Rendering the last known good state is the designed degradation.scripts/generate_icons.py/check_release_consistency.py—check=Trueandparser.error()mean their success values are derived from checked outcomes.Verification
uv sync --locked --extra dev,uv run ruff check .,uv run pytest tests -q(198 passed, 2 skipped),uv build— all clean locally.🤖 Generated with Claude Code
https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM