Skip to content

feat(helper): reconcile installs, route applies - #669

Draft
Alex Shabalin (alex-sparus) wants to merge 2 commits into
alex/fix-helper-runnerfrom
alex/fix-helper-reconcile
Draft

feat(helper): reconcile installs, route applies#669
Alex Shabalin (alex-sparus) wants to merge 2 commits into
alex/fix-helper-runnerfrom
alex/fix-helper-reconcile

Conversation

@alex-sparus

@alex-sparus Alex Shabalin (alex-sparus) commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Problem: the GUI meets an installed helper of unknown build that may be running an activation, and must drive it toward the stored decision — adopt, replace, or remove — without ever interrupting a running activation. Every apply must pick between the helper and the administrator-password prompt, and a helper refusal must never silently become a password prompt. One pass is rarely enough: the platform refuses a register for about a second after an unregister, and approval waits on a human.

Solution: one idempotent reconciliation pass — a status-by-goal table with displacement gates re-checked before every mutation and a typed commitment token minted only by the register step's final checks — over a ServiceManagement adapter that registers only after the unregister completion confirms the old process is gone. A pass that finds an activation running ends with a WaitingOnActivation report; the convergence loop re-observes it on its waiting cadence, uncounted against the mutation bound. activation_path routes each apply from reconciled service state, totally over decision and status, and an attempted helper exchange never falls through to the prompt. The sync agent stays a fire-and-defer activation client. The helper preference is device-local: settings import cannot carry or reset it.

Stacked on #668.

Test Plan

  • cargo test green: scripted-world reconciliation tests over the full status-by-goal table (adoption, replacement, deferral, verify branches, gate re-checks, mid-run Disable declining the register), the complete activation-routing table (every refusal proven never to reach the prompt), convergence-loop bounds, sync-agent dispatch outcomes

Docs

  • No docs update needed

Prior review

Carried over from Scott-approved PRs, byte-identical or nearly: service.rs (4 lines differ, a test string) from #620/#635, preferences.rs/prefs.rs/settings_io.rs from #620. reconcile.rs keeps #635's shape (status-by-goal table, scripted-world tests) but is substantially rewritten — no drain machinery, deferral instead — and activation_path.rs/helper_permission.rs differ by ~20% from the #636-era versions. Review effort belongs on those three.

@alex-sparus

Alex Shabalin (alex-sparus) commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by jj-ryu.

@darkmatter

darkmatter Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

🎨 Storybook preview

Open Storybook preview

Updated for 7de50ac


⚠️ Detected UI changes (5)

These stories' HTML snapshots changed. I've added screenshots + links to the changed stories below. Review them carefully then accept the changes to regenerate baselines and include them in this PR:

Flows/Evolve › Playground

Flows/Evolve › Playground

Flows/Evolve › 1. Begin (idle)

Flows/Evolve › 1. Begin (idle)

Flows/Evolve › 2. Evolving (progress)

Flows/Evolve › 2. Evolving (progress)

Flows/Evolve › Evolving With Error Event

Flows/Evolve › Evolving With Error Event

Flows/Evolve › 3. Review (changes generated)

Flows/Evolve › 3. Review (changes generated)


Accept UI changes

  • Click here to accept these changes

Alternatively, you can run bun run test:update-snapshots locally to re-generate the baselines and then push the changes to this PR.

What does this do?

The screenshots above show UI changes detected by the Storybook
snapshot tests run on this PR. Each image is the rendered output of
a Storybook story from the code in this PR branch; the snapshot
test compared it against the committed baseline in
__snapshots__/ and flagged the difference.

Checking the box tells the darkmatter[bot] to regenerate the
baselines from this PR's current code and commit them directly to
this branch. The new baselines become the source of truth for
future runs — only accept after confirming the visual changes are
intentional.

Comparison baseline: the committed __snapshots__/ files on this
PR branch (carried forward from develop). Accept updates them in
place on this branch.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor
Warnings
⚠️

No Linear issue ID found in this PR's title, description, or branch name (expected something like ENG-123). Add one so this work is traceable in Linear, or add #no-linear to the PR description to acknowledge it's intentionally untracked.

⚠️ PR is marked WIP / draft — do not merge until ready for review.
⚠️ ❗ Big PR (5121 lines changed). Consider splitting it into smaller, focused changes.

📋 PR Overview

Lines changed 5121 (+4687 / -434)
Files 3 added, 14 modified, 0 deleted
Draft / WIP yes
Has Test Plan yes
Linear issue no
No Test Plan Needed no
New UI components no
New Storybook stories no
New Rust modules yes (3)
New TS source files no
New tests no
package.json touched no
Cargo.toml touched no
Infra / CI touched no

🔬 Coverage

Report Lines Statements Functions Branches
apps/native/coverage/coverage-summary.json 35.6% 35.2% 30.5% 29.5%

Generated by 🚫 dangerJS against 7de50ac

/// force: a main-thread decision would block on the slot while the holder's
/// pass may be awaiting a main-queue dispatch — a deadlock, where an
/// observation merely misreports. Every current caller is an async handler.
pub fn grant<R: Runtime>(app: &AppHandle<R>) -> Reconciled {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conditional logic looks a little odd; maybe the login items should only be opened either before OR after depending on report type instead of both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

via AI:

At most one of the two fires per click — the second is guarded on !awaiting_approval_already — so it is either-or already, just spelled as two triggers because they answer different questions: the before-open keeps the "Open Settings" button's promise immediately (and even if the run then stops short), while the after-open is the first-time Enable path, where the approval wait only exists once this run registered. Folding them into one post-run check would delay the first case behind the whole run and drop it on a failed one. Good catch that the doc didn't say "at most one" out loud though — fixed.

}

/// The table. Total over the stored decision and all four statuses.
fn route(helper: &HelperUse, status: Result<RegistrationStatus, String>) -> Route {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I asked this or an analogous question in a previous PR iteration of this feature: It seems like we might not want to treat refusal as activation failure (depending on how the UI presents back to the user).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I'll fix via adding refused: bool into ActivateResult and fixing the consumers

Comment thread apps/native/src-tauri/src/rebuild/activation_path.rs
fn commit_to_register<E: Environment>(env: &E) -> Result<Committed, Reconciled> {
// A register is a mutation like any other.
env.gate()?;
match env.preference() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little hard to see that there still can't be a race where the preference changes between here and the actual register, maybe you can confirm?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

via AI:

The window is real (and known) but can't lose the decision or the end state: a decision is written before its own pass (decide), which then blocks on the run slot until this pass ends and carries the decision out against whatever this pass did. A write that slips past the re-read costs at most one registration that the immediately-following pass removes. The re-read exists to make the common case (a Disable during the slow service calls) never register at all. Documented on commit_to_register now.

Problem: the GUI meets an installed helper of unknown build that may
be running an activation, and must drive it toward the stored decision
— adopt, replace, or remove — without ever interrupting a running
activation. Every apply must pick between the helper and the
administrator-password prompt, and a helper refusal must never
silently become a password prompt. One pass is rarely enough: the
platform refuses a register for about a second after an unregister,
and approval waits on a human.

Solution: one idempotent reconciliation pass — a status-by-goal table
with displacement gates re-checked before every mutation and a typed
commitment token minted only by the register step's final checks —
over a ServiceManagement adapter that registers only after the
unregister completion confirms the old process is gone. A pass that
finds an activation running ends with a WaitingOnActivation report;
the convergence loop re-observes it on its waiting cadence, uncounted
against the mutation bound. activation_path routes each apply from
reconciled service state, totally over decision and status, and an
attempted helper exchange never falls through to the prompt. The sync
agent stays a fire-and-defer activation client. The helper preference
is device-local: settings import cannot carry or reset it.
Problem: a Finder-replace upgrade leaves the old helper failing
signature validation (errSecCSStaticCodeChanged), so reconciliation
classifies it RootUnverifiable and removes it without asking Status.
The Activating deferral is unreachable in the one scenario it exists
for: an upgrade during a running activation kills the old helper and
the applying client's result reply is lost.

Solution: split what validation gates. Every action stays gated; the
Status read from a root peer is not. assessed_status hardcodes the
request, so no mutating request can take the relaxed path; a parseable
answer from an unverifiable root peer becomes UnverifiableAnswered, and
an Activating answer buys exactly one thing: WaitingOnActivation(None),
postponing the peer's own replacement. Any other answer, and any probe
failure, takes the removal path unchanged. The reply authorizes
nothing: build-ID claims are ignored, activation details are withheld
from the report, and the post-register verify loop treats the variant
as a failure.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants