Skip to content

fix(wizard): skip a plugin only when it is actually installed, not merely recorded - #563

Merged
fluck-boss merged 6 commits into
risa-labs-inc:devfrom
arjun28115:fix/wizard-installed-predicate
Sep 24, 2026
Merged

fluck-boss merged 6 commits into
risa-labs-inc:devfrom
arjun28115:fix/wizard-installed-predicate

Conversation

@arjun28115

@arjun28115 arjun28115 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

The Toolbox Setup Wizard used the manager's entry-existence check to skip selected plugins. A rejected plugin can leave a DISABLED entry after its jar is deleted, so reopening the wizard counted it as installed without attempting installation. The skip guard now uses the shared PluginDependencyResolution.installedAndOnDisk predicate.

A loaded plugin still counts when its recorded jar path is stale. An access-disabled plugin with its jar intact also counts. Missing or binary-incompatible disabled plugins are retried.

The review follow-up also checks the results of both Store and GitHub installs before recording success: registration-time binary incompatibility returns a successful Result containing a DISABLED plugin, which must instead reach the failure list. Access-disabled installations remain successful. This fixes result reporting without changing the manager's public API or the wizard's navigation.

Contributors

  • @arjun28115 (Arjun Singla): original skip-guard fix and six predicate tests, commit 5bd71037e2864690e89c74dec6c77032e11debcf.
  • Codex review follow-up, committed by @kshivang: shared install-result validation for both download paths, five additional tests, and displaced KDoc repair. This follow-up is separate from the original contribution.
  • @arjun28115, after that: refuse an occupied destination instead of replace-and-roll-back (3f15e9c), promotion inside the failure boundary with a per-path revert and the refusal widened to the whole jar-plus-sidecar footprint (3d7f888), and a test-call fix (f77c177).

The draft blocker, and how it was retired

This was left in draft over artifact preservation: a DISABLED plugin with a stale recorded path could reach the promote-then-load-failure cleanup and lose a persisted artifact. The fix was to stop needing that cleanup to be clever, in two steps.

  • 3f15e9c refuses an occupied destination instead of replacing it and rolling back. Every rollback fix had revealed another way to lose the artifact it was protecting; refusing has no tail, because nothing is overwritten, so nothing needs undoing.
  • 3d7f888 makes promotion all-or-nothing, which was @kshivang's last blocking finding: moving the jar and then its sidecar is two moves, and they sat outside the failure boundary. moveTo now records each path as it lands and deletes exactly those on failure, and the refusal covers the whole footprint, sidecars included.

Together: every path this call writes was established free beforehand, so a load failure after promotion can only delete what this call itself published. A resident artifact, however stale its recorded path, is never at a path this call writes to, so it cannot be the thing deleted.

3d7f888 and f77c177 have not been reviewed yet. The last review was of 3f15e9c. That is the one thing this PR is waiting on.

Validation

Tested on dev as of 2026-09-24, merged in as 8fcf9a69 (198 commits; a merge rather than a rebase so every SHA above stays valid; no conflicts).

  • ./gradlew :composeApp:desktopTest: 6372 tests, 0 failures, 5 skipped. This PR's own WizardInstalledPredicateTest 11/11 and WizardStagedInstallTest 14/14, run fresh.
  • ./gradlew detekt ktlintCheck --continue: clean across all modules.

Not tested

The tests exercise the shared predicates, the install-result classification and the staging with real files. No live wizard run has been done: reopen the Toolbox Setup Wizard with a rejected or deleted plugin selected, and it should attempt the install rather than report it already installed; a still-incompatible download should appear as a failure; loaded plugins with stale recorded paths and access-disabled plugins with intact jars should still be skipped.

🤖 Generated with Claude Code

@kshivang

Copy link
Copy Markdown
Contributor

@claude Please review the current head 5bd7103 against dev, focusing on wizard retry behavior and the installed/usable predicate. No current review exists.

@claude

claude Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 6m 11s —— View job


Review: wizard skip guard, installed-vs-usable

  • Gather context (diff, PluginDependencyResolution, sibling call sites, wizard state/retry path)
  • Review the installed/usable predicate change
  • Review wizard retry behavior
  • Review the new test
  • Post review

Verdict: the one-line change at PluginInstallService.kt:111 is correct and matches the three existing host call sites (MissingDependencyReporter.kt:57, RememberHomeTools.kt:92, TabTypeAvailability.kt:156) byte-for-byte in the predicates it supplies, including passing both by name. I confirmed both clauses against DynamicPluginManager: the load-time path returns Result.failure after markIncompatible + a DISABLED entry (DynamicPluginManager.kt:934-962), and the wizard's own failure branch then deletes the jar (PluginInstallService.kt:266) - so an entry-only check really did leave a dangling entry the wizard would skip forever. Six findings, most severe first.


1. The other half of the defect is still there: isSuccess is not LOADED

The PR's stated problem is "the wizard finishes reporting success for something it never installed". The skip guard was one of two places that does that. The store branch still does it for the plugin it actually attempts:

PluginInstallService.kt:220-254 takes installResult.isSuccess, and the registration-time incompatibility path returns Result.success(info) with state = DISABLED after force-unloading the plugin (DynamicPluginManager.kt:1127-1166). So for that plugin the wizard adds it to installedIds, logs Plugin installed successfully, and writes an installed.json row with enabled = true - even though line 224 has already computed registered = state == LOADED two lines above, purely for the dependency gate. The GitHub branch is the same shape: installFromGitHub returns Result.success(installed) for any non-null info (:406-410) and the caller adds to installedIds on non-null (:127-128), with state == LOADED checked only for the manifest list.

Net effect after this PR: the run that attempts such a plugin still tells the user it installed; the next run correctly no longer skips it (that's the fix) and re-attempts, and reports success again.

One caveat if you take this on - registered alone is the wrong discriminator, for the reason pluginSectionAbsence already documents: the access-hidden case is also success + DISABLED (DynamicPluginManager.kt:1169-1187) and that plugin genuinely is installed. The thing that separates them is PluginCrashRegistry.isIncompatible(manifest.pluginId), which is exactly what the new predicate already consults. Fix this →

2. Retry behavior: what the fix buys, and one sharp edge

What it buys is bigger than the PR body claims, and worth saying out loud:

  • "Try Again" is state.reset() (PluginInstallWizardWindow.kt:157-159), and reset() does not clear _selectedPlugins (PluginInstallWizardState.kt:288-297), so the same selection is re-attempted. Before this change, a plugin rejected at load time had an entry (jar deleted at :266) and so was skipped on the retry, counted in installedIds, and the wizard went straight to "Complete". The retry button was a no-op that reported success. That is the clearest statement of the bug and it isn't in the PR description.
  • Which retry, though: installPlugins never returns Result.failure - every per-plugin failure lands in failedIds and the flow completes - so the installationError / "Installation Failed" / Try Again UI is only reachable when something throws outside the per-plugin try (e.g. PluginStoreSetup.getPluginDir()) or on cancellation. The practical retry surface is reopening the wizard from Window > Toolbox Setup Wizard... (BossWindow.kt:976-981) - which is also what makes the original bug reachable well past first run, on a machine with a populated pluginStates.

The sharp edge: now that a non-LOADED entry with a missing jarPath is no longer skipped, the wizard will re-download and call installPlugin for it. If that plugin is still resident in the loader - disablePlugin flips the state to DISABLED without calling pluginLoader.unloadPlugin, as AGENTS.md records - DynamicPluginLoader.kt:146-154 refuses with ALREADY_LOADED_PREFIX, and the wizard's failure branch runs finalFile.delete() (:266) on what atomicMoveFrom has by then moved over the plugin's real jar. installed.json keeps its row, so the next launch points at a file that no longer exists. Preconditions are narrow (user-disabled and an in-memory jarPath that went stale within the same session; the LOADED clause covers the common reconciler/updater case), so I'd call this a watch item rather than a blocker - but the cleanup at :266 deleting a path that an installed.json row still names is worth a guard if you touch this file again for finding 1.

3. The six tests don't pin the call site they were written for

Every assertion in WizardInstalledPredicateTest calls PluginDependencyResolution.installedAndOnDisk directly through a local helper. Revert PluginInstallService.kt:111 to dynamicPluginManager.isInstalled(plugin.id) and all six stay green - so nothing in the new file fails on the regression it documents.

And the cases are already covered: PluginDependencyResolutionTest.kt:225-300 pins the dangling entry, LOADED-with-missing-jar, incompatible-with-jar-present, and a stale marker on a LOADED plugin. The four disagreement tests here restate those with different names; what's genuinely new is the KDoc.

This matches the precedent HomeInstalledPredicateTest sets (its own KDoc admits the predicate isn't reachable from a composable), so I'm not asking for a rewrite. But here the cheap option does exist: the class already injects dependencyReporter for testability, so adding jarExists / isIncompatible (or one installedIds: () -> Set<String>) with the same defaults would let a test assert the decision, and the skip branch returns before any network. Failing that, I'd trim the duplicated cases and keep the file's docstring value.

4. warnOnIdMismatch lost its KDoc

The new function was inserted between the "The manifest id is authoritative…" KDoc and the function it documented, so PluginInstallService.kt:321-336 now stacks two orphaned doc blocks above installedAndUsable and warnOnIdMismatch (:344) has none. (The Install a plugin from GitHub block at :321 was already stray before this commit; the second one is new.) Moving the new function below warnOnIdMismatch fixes both halves. Fix this →

5. DynamicPluginManager.isInstalled has no production caller left

The PR note says leaving it is safer because "it is public and has other callers". I swept for it: after this change the only non-test reference is its own log field at DynamicPluginManager.kt:1365 ("stillInstalled" to isInstalled(pluginId)). Everything else named isInstalled is PluginPersistence.isInstalled (PluginLoaderDelegateSetup.kt:233), MissingDependencyInstaller.isInstalled (StoreMissingDependencyInstaller, PerformanceState.kt:208, MissingPluginOffer), CLIInstaller, or an unrelated private flag. So the follow-up is smaller than estimated - deleting it, or renaming it to what it measures (hasEntry), is a few lines. Leaving a public, trap-shaped isInstalled next to the correct predicate is precisely how the next call site drifts, which is the failure AGENTS.md spends a paragraph on. Your call whether that belongs here or in its own PR; I agree it doesn't block this one.

6. Nit: a fourth name for the one definition

installedAndUsable() is the same computation the reporter calls installedPluginIds() and the resolver calls installedAndOnDisk. Given how hard this file's own comment leans on there being one definition, a new name for it reads like a second predicate. installedPluginIds() would match the sibling.


Not verified: I didn't run the suite - a :composeApp:desktopTest run needs a full module compile in this environment, so I'm taking the PR's 4405-pass / ktlint / detekt report at face value. I also compared against the commit's parent rather than origin/dev: this checkout is shallow and git diff origin/dev...HEAD reports no merge base.
• branch fix/wizard-installed-predicate

@kshivang

Copy link
Copy Markdown
Contributor

Addressed the substantive result-reporting finding in 76c3c49d9: both Store and GitHub paths now pass manager results through usableWizardInstallResult before success persistence/counting. It uses the same LOADED-or-intact-compatible-file policy, so legitimate access-disabled installs remain successful. Five new tests cover incompatible registration, access-disabled success, LOADED with stale path/marker, missing disabled artifact, and preservation of the original loader failure. Also restored the displaced KDoc.

The original skip-guard fix and six tests remain @arjun28115's contribution; these repairs are separate reviewer work. The original head passed all nine hosted checks. Fresh-head CI and the shared-lock local test/ktlint/detekt run remain pending.

The tests still do not exercise the complete wizard loop; that limitation is explicit in the updated description. The user-disabled/resident/stale-path cleanup scenario is recorded as the review's nonblocking watch item. Public manager API renaming and helper naming suggestions are outside this focused change. No duplicate of this predicate fix was found in current dev/main, merged #443/#429, or excluded draft #590 (read-only context). No merge or auto-merge enabled.

@kshivang

Copy link
Copy Markdown
Contributor

@arjun28115 Your skip-guard change fixes a real problem: reopening the wizard no longer counts a rejected/deleted plugin as installed just because the manager retained an entry. The original commit and credit are preserved, along with reviewer commit 76c3c49d9, which addresses Claude finding 1 in both Store/GitHub result paths and adds five classification tests.

Final merge assessment: not ready; handing back one remaining lifecycle regression risk. This supersedes my earlier treatment of Claude finding 2 as merely a watch item. The newly authorized merge gate requires resolving substantive regression risks, and the possible artifact loss deserves a guard/test before merging:

  1. A user-disabled plugin can remain resident in DynamicPluginLoader while its manager state is DISABLED (disable does not unload it).
  2. If its recorded jarPath has become stale, the new skip predicate returns false and permits download/reinstallation.
  3. The Store path promotes the download to the final version path before loading (PluginInstallService.kt, around lines 209-219). DynamicPluginLoader.kt:146-154 refuses the resident ID with ALREADY_LOADED_PREFIX.
  4. The wizard then unconditionally deletes finalFile in its failure branch (around line 267), potentially deleting the artifact still named by installed.json. This is a static control-flow concern under narrow preconditions, not a claimed live reproduction. The cleanup behavior predates this PR; the changed guard makes this disabled/stale-path input reach it.

Please add a safe resident-plugin/reinstall decision or staged artifact ownership/rollback protection before promotion/cleanup. Preserve the current behavior for rejected/unloaded plugins, ordinary access-disabled installs, and LOADED plugins with stale paths. Merely suppressing deletion after overwriting the destination would not establish artifact preservation.

Required regression validation: seed an existing final artifact and persistence row plus a resident DISABLED plugin with stale manager path; attempt the wizard install and assert the old artifact bytes and persistence reference survive the refusal, with an honest result. Also cover rejected/unloaded retry, registration-incompatible failure, access-disabled success, and LOADED stale-path skip. Please exercise the service's decision/cleanup wiring, since the current predicate-only tests cannot detect reverting its call site. Run targeted desktop tests, ktlintCheck and detekt, and obtain green required CI on the resulting head.

Validation infrastructure is separate: original CI was 9/9 green. Current-head CI 34736682589 is still queued/running (shell/version/pgTAP passed), and local targeted tests/ktlint/detekt are waiting for the shared build lock. These are not evidence of an author defect or a failing test. No final-head pass is claimed. The PR is being converted to draft under the handoff policy, rather than merging with the remaining regression risk. No shared branch or main was changed.

@kshivang
kshivang marked this pull request as draft September 13, 2026 04:06
@arjun28115

Copy link
Copy Markdown
Contributor Author

Pushed 1d2e03d: formatting only, so the code-quality failure on this head is not sitting between you and the real work.

ktlintDesktopTestSourceSetCheck failed on run 34736682589 with:

WizardInstalledPredicateTest.kt:160:1 Unexpected blank line(s) before "}"

One blank line before the class brace, removed. No test and no behaviour touched. It arrived with 76c3c49 rather than the original submission, and I checked every other .kt file this PR touches for the same rule: that was the only instance. Everything else on that run was already green, including both build-test runners, compatibility and pgTAP.

On the lifecycle risk you handed back, I read it as: a user-disabled plugin stays resident while manager state is DISABLED, a stale jarPath makes the new predicate return false, the Store path promotes the download to the final path before the loader refuses the resident id, and the failure branch then deletes a finalFile that installed.json still names. I will take that as the guard plus a seeded regression that drives the service's decision and cleanup wiring rather than the predicate alone, keeping the rejected/unloaded, access-disabled and LOADED-stale-path cases as they behave today. Will say so here before pushing anything, in case you would rather it went somewhere else.

arjun28115 added a commit to arjun28115/BossConsole that referenced this pull request Sep 13, 2026
…its jar

The handed-back lifecycle risk on risa-labs-inc#563. `disablePlugin` unregisters panels and
sets DISABLED but never unloads, so a user-disabled plugin keeps its id in the
loader. A DISABLED entry is only counted as installed while its file exists, so
once its recorded jarPath goes stale the wizard offers it again. The Store path
then moved the download onto the installed artifact, `loadPlugin` refused the
resident id, and the failure branch deleted the file `installed.json` still
named. The plugin was left with a persistence row pointing at nothing.

Residency is now checked BEFORE the promotion, in a new `stageAndInstall`. The
refusal leaves the existing artifact untouched and removes only the download,
which this call owns. Checking after the move could not be rolled back, which
is why suppressing the delete alone would not have been enough.

Cleanup is also by ownership rather than by outcome: a failure removes the final
file only when this call created it. When the move landed on a path that already
held a jar, that jar belonged to a previous install and deleting it would strand
its persistence row in the same way.

Residency needed a query. `DynamicPluginManager.isInstalled` reports whether a
manager ENTRY exists, which is exactly what does not answer this: a rejected
binary-incompatible plugin has an entry and is not resident, and a user-disabled
one is resident whatever its entry says. Added `isPluginResident`, delegating to
the loader's own `isLoaded`.

`install` is injected so the decision and the cleanup are testable without a
DynamicPluginManager. WizardStagedInstallTest adds 9 tests over real files and a
real move: the seeded-artifact refusal, that the loader is never reached, that
the download is cleaned up, that a rejected plugin still retries, that a failure
removes a jar this call created but keeps one that was already there, and that
the original loader failure is passed through.

Unchanged on purpose: the GitHub path, which downloads straight to its final
name and never promotes over an installed artifact.

ktlint 1.8.0 clean on all three files; no added line over 120 columns.
No local test run is claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@arjun28115

Copy link
Copy Markdown
Contributor Author

Pushed 51ac06d. This is the resident-plugin guard you handed back, done the way I described above.

The decision moved ahead of the promotion. New stageAndInstall in PluginInstallService asks about residency before the download is moved onto the installed artifact. On a refusal the existing jar is untouched and only the download, which the call owns, is removed. Checking after the move could not be rolled back, which is the point you made about suppressing the delete not being enough.

Cleanup is now by ownership, not by outcome. A failure removes the final file only when this call created it. If the move landed on a path that already held a jar, that jar came from a previous install and deleting it would strand its persistence row in exactly the way this PR is about. That is the narrower half of the same bug, so it is fixed here too.

Residency needed a query, and isInstalled is not it. That reports whether a manager entry exists, which cannot answer this: a rejected binary-incompatible plugin has an entry and is not resident, and a user-disabled one is resident whatever its entry says. I added DynamicPluginManager.isPluginResident, delegating to the loader's own isLoaded. Its KDoc states the distinction, which I think also helps the drift risk you raised in finding 5 rather than adding to it.

I verified the premise rather than assuming it: disablePlugin unregisters panels, disables the sandbox and sets DISABLED, and never calls unloadPlugin. So the id stays in loadedPlugins.

On exercising the wiring. install is injected, so WizardStagedInstallTest drives the decision and the cleanup with real files and a real move, without a DynamicPluginManager. Nine tests: the seeded-artifact refusal asserting the original bytes survive, that the loader is never reached at all, that the download is cleaned up, that the message is actionable, that residency is asked about the id it is given, that a rejected plugin still retries and installs, that a failure removes a jar this call created, that a failure keeps one that was already there, and that the original loader failure is passed through rather than replaced.

That is the part your finding 3 asked for. A predicate-level test cannot notice this being reverted at its call site; these fail if the residency check moves back after the move, or if the cleanup stops distinguishing ownership.

Scope I deliberately did not take. The GitHub path is unchanged: it downloads straight to its final name and never promotes over an installed artifact, so the loss shape does not exist there. I also left the isInstalled rename and the installedAndUsable naming nit alone, since you called both non-blocking and they touch public API.

Correction on my own push. 3361357 accidentally carried two generated SQL suites, db_access_audit_test.sql and master_key_rotation_test.sql, swept in from unrelated work by a git add -A. On this branch they would have been worse than noise, since the generator that produces them arrived with #423 and is not here, so supabase test db would have run them as ordinary suites. Removed in 51ac06d. The PR's diff is three files.

ktlint 1.8.0 is clean on all three and no added line exceeds 120 columns; I now run the pinned ktlint locally rather than discovering that on CI. No local test run is claimed, so the suite result on this head is the real check.

arjun28115 added a commit to arjun28115/BossConsole that referenced this pull request Sep 13, 2026
…its jar

The handed-back lifecycle risk on risa-labs-inc#563. `disablePlugin` unregisters panels and
sets DISABLED but never unloads, so a user-disabled plugin keeps its id in the
loader. A DISABLED entry is only counted as installed while its file exists, so
once its recorded jarPath goes stale the wizard offers it again. The Store path
then moved the download onto the installed artifact, `loadPlugin` refused the
resident id, and the failure branch deleted the file `installed.json` still
named. The plugin was left with a persistence row pointing at nothing.

Residency is now checked BEFORE the promotion, in a new `stageAndInstall`. The
refusal leaves the existing artifact untouched and removes only the download,
which this call owns. Checking after the move could not be rolled back, which
is why suppressing the delete alone would not have been enough.

Cleanup is also by ownership rather than by outcome: a failure removes the final
file only when this call created it. When the move landed on a path that already
held a jar, that jar belonged to a previous install and deleting it would strand
its persistence row in the same way.

Residency needed a query. `DynamicPluginManager.isInstalled` reports whether a
manager ENTRY exists, which is exactly what does not answer this: a rejected
binary-incompatible plugin has an entry and is not resident, and a user-disabled
one is resident whatever its entry says. Added `isPluginResident`, delegating to
the loader's own `isLoaded`.

`install` is injected so the decision and the cleanup are testable without a
DynamicPluginManager. WizardStagedInstallTest adds 9 tests over real files and a
real move: the seeded-artifact refusal, that the loader is never reached, that
the download is cleaned up, that a rejected plugin still retries, that a failure
removes a jar this call created but keeps one that was already there, and that
the original loader failure is passed through.

Unchanged on purpose: the GitHub path, which downloads straight to its final
name and never promotes over an installed artifact.

ktlint 1.8.0 clean on all three files; no added line over 120 columns.
No local test run is claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@arjun28115
arjun28115 force-pushed the fix/wizard-installed-predicate branch from 51ac06d to 9cee17b Compare September 13, 2026 13:19
@arjun28115

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev at 9cee17b.

dev was force-pushed and its history rewritten, so the old tip is orphaned and every branch based on it went to dirty. Merging dev in does not work here: the branch still carries the old history, so the merge fights the rewrite across seventeen files this PR never touched. Replaying this PR's own commits onto the new base is the operation that fits, and it brings the diff back to what it should be.

Your commits are preserved in order with their authorship. The SHAs changed because a rebase rewrites them; nothing else did. I checked file by file that every file this PR touches is byte-identical to what the branch held before the rebase, so the only intended change is the base.

Flagging one thing rather than leaving you to find it: while rebasing #596 earlier I force-pushed over three of your commits on that branch before restoring them. I have since checked each branch's full history before touching it, which is what I should have done first. If you would rather I never rebase a branch you have commits on, say so and I will just tell you when one needs it.

@arjun28115

Copy link
Copy Markdown
Contributor Author

The blocker you named is cleared. Current head 9cee17b has a full passing result on all ten checks: both database and code-quality, all three platform builds, compatibility, BOSS AI contracts, shell, version-check and build-summary. Nothing is pending and nothing is red.

The dev-integration recheck you listed as the other step is also done, and it needed doing for a different reason: dev was force-pushed and its history rewritten, so this branch was sitting at dirty against the new base. It is rebased onto current dev now, so the green result above is against the base it would merge into rather than an earlier one.

Marking ready is yours under the bounded-review policy, so I have not touched the draft flag. Flagging it because the condition you set has been met rather than leaving it to a poll.

@kshivang

Copy link
Copy Markdown
Contributor

@claude Please review current head 9cee17b against dev, including the contributor residency/staging follow-up. Prior review predates that change. Focus on artifact preservation for both Store and GitHub retry paths, download failures, and what the new file tests establish. One review request for this sweep.

@claude

claude Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 6m 27s —— View job


Review: residency/staging follow-up at 9cee17b

  • Gather context (diff vs dev, PluginInstallService, DynamicPluginManager, both new tests)
  • Store path: staging, promotion, ownership-based cleanup
  • GitHub path: artifact preservation on retry and download failure
  • What the new tests establish (and what they do not)
  • Post review

Verdict: the ordering fix is right and is the substantive one - residency has to be asked before the move, because the move cannot be rolled back, and createdByThisCall is correctly computed before atomicMoveFrom runs. I also re-derived the reachability claim in the KDoc and it holds: a resident plugin is either LOADED (skipped by installedAndOnDisk), registration-incompatible (force-unloaded at DynamicPluginManager.kt:1139), or load-time-incompatible (never loaded) - so the refusal fires only for DISABLED-and-resident with a stale path, which is exactly the user-disabled and access-hidden pair. usableWizardInstallResult likewise gets the hard part right: access-disabled stays a success, registration-incompatible becomes a failure, and an already-failed result short-circuits without touching disk (the error() probes in that test are a good way to pin it).

The handed-back concern is closed for the Store path. It is not closed for the GitHub path, and that is where the three mandatory plugins install from. Findings most severe first.


1. The GitHub path destroys the installed artifact on stream open, and has none of this PR's protection

downloadFromGitHubRelease streams the release asset straight onto its final name (PluginInstallService.kt:549-554):

val targetFile = File(pluginDir, jarName)
jarConnection.inputStream.use { input ->
    targetFile.outputStream().use { output -> input.copyTo(output) }
}

File.outputStream() truncates at open. So for exactly the inputs this PR unblocked - a plugin the skip guard no longer skips because its entry is DISABLED with a stale or missing recorded path - the wizard re-downloads, the asset name is the same name the previous install wrote, and the installed jar is gone the instant the connection's stream opens. Before any residency check, any manifest read, any ownership bookkeeping.

It is a worse shape than the Store one, because it does not need a loader refusal to bite. A mid-copy network failure leaves a truncated file at a scannable .jar name, downloadFromGitHubRelease returns null without deleting it (:566-576), and DefaultPlugin.loadExternalPlugins scans *.jar in the plugin dir and installs everything untracked at the next launch (DefaultPlugin.kt:1176-1181). That is the precise failure AGENTS.md records as the reason StoreMissingDependencyInstaller streams into a .jar.part sibling whose suffix deliberately does not end in .jar.

The reasoning in the handoff comment inverts it: "it downloads straight to its final name and never promotes over an installed artifact, so the loss shape does not exist there." Writing directly onto the final name is the loss shape; promoting over it is the milder version, because at least the bytes arrive whole.

Two further gaps on the same branch: installFromGitHub calls dynamicPluginManager.installPlugin directly (:405-406) and never goes through stageAndInstall, so there is no residency check and no ownership cleanup there; and it never deletes its own jar on a failed install, so the Store and GitHub paths now disagree about cleanup as well as about staging.

Severity is set by who uses it: githubUrl is non-empty for fluckbrowser, editortab and terminaltab (PluginListProvider.kt:248-283), all isMandatory = true and all default-selected. This is the wizard's main install path, not a legacy branch.

Fix this →

2. The signature sidecar does not travel with the jar

RemotePluginRepository.downloadPlugin persists the store signature as a <targetPath>.sig sidecar beside whatever path it was handed (RemotePluginRepository.kt:352). The wizard hands it ${plugin.id}-downloading.jar (:188), and stageAndInstall moves only the jar to ${plugin.id}-$actualVersion.jar. Three consequences:

  • Every wizard store install loads unsigned. Tolerated only because PluginSignatureEnforcement.DEFAULT = false - with the comment // flips to true per BossConsole#102 (PluginSignatureEnforcement.kt:26). When that flips, verifySignatureOrThrow (DynamicPluginLoader.kt:565-588) turns today's warning into a hard refusal and the wizard's store path stops installing anything.
  • ${plugin.id}-downloading.jar.sig is orphaned in the plugin dir on every install.
  • The dangerous one: if an earlier install via StoreVersionInstaller (which does move the sidecar) left a .sig at the version-named target, the move puts fresh bytes under a stale signature - a hard load failure, which AGENTS.md calls out as "worse than being unsigned". And in that case createdByThisCall is false, so the new code deliberately keeps the jar there.

The first two predate this PR. But stageAndInstall is now the named home for "promote a download and clean up after it", so the sidecar belongs in it, and StoreVersionHooks.promoteFiles / discardFiles (StoreVersionInstaller.kt:29-37) is already the two-line answer.

Fix this →

3. The new tests establish the helper, not the wiring

All nine WizardStagedInstallTest tests call the free stageAndInstall directly. Revert the call site (PluginInstallService.kt:219-225) to the pre-PR finalFile.atomicMoveFrom(downloadedFile) + dynamicPluginManager.installPlugin(jarPath) + finalFile.delete() and all nine stay green. So the claim in the file's own KDoc and in the push comment - "these fail if the residency check moves back after the move, or if the cleanup stops distinguishing ownership" - is true only of edits inside the helper. This is finding 3 of the previous review moved up one level, not closed: the ordering and ownership rules are now encoded somewhere testable, which is real progress, but the service's use of them still is not pinned.

Concretely unpinned: that the call site passes manifest?.pluginId ?: plugin.id rather than plugin.id (the residency is asked about the id it is given test passes a literal into the parameter and asserts it comes back - that is tautological); that isResident defaults to isPluginResident; that the failure branch no longer double-deletes. install is injected but installPlugins still is not reachable from a test - it reaches PluginStoreSetup.repositoryManager, PluginStoreSetup.getPluginDir() and PluginPersistence as objects. The cheap closure is a third injected collaborator: a stage function beside dependencyReporter and isResident, with one test asserting the service calls it with the manifest id.

Fix this →

4. The refusal message misdirects the access-hidden user

An access-hidden plugin loads successfully and is only not registered - pluginLoader.loadPlugin kept it, nothing unloads it, and it is recorded DISABLED (DynamicPluginManager.kt:1152-1186). So it is resident, and with a stale recorded path it now takes the new refusal and is told to "remove it in the plugin manager" - where it is not listed for that user, since getInstalledPlugins filters out plugins they lack access to. Artifact preservation is still correct here; only the sentence sends them somewhere that cannot help. Worth either widening the wording or branching on getInaccessiblePlugins(), the same discriminator pluginSectionAbsence uses for this exact ambiguity.

5. Nits

  • The resident branch deletes before checking path equality. stageAndInstall:716-724 calls downloadedFile.delete() unconditionally. If a caller ever passes downloadedFile == finalFile - which the a download that needs no move is still cleaned up on failure test already establishes as a supported shape - the refusal deletes the artifact it exists to protect. The Store path cannot hit it today (-downloading.jar vs -$version.jar), but the function is internal and shaped for reuse. Skip the delete when the paths are equal.
  • The Store temp name is scannable (pre-existing). ${plugin.id}-downloading.jar (:188) ends in .jar, so a failure between the bytes landing and the move - extractManifestFromJar throwing, atomicMoveFrom throwing - leaves it for the next launch's directory scan to install, and the downloadedPath == null branch (:192-204) never deletes a partial. .jar.part is the house convention and is documented as deliberate for exactly this.
  • Persistence keys on plugin.id, residency on the manifest id. stageAndInstall is asked about manifest?.pluginId ?: plugin.id (correct - that is what the loader refuses on) while PluginPersistence.addInstalledPlugin still writes pluginId = plugin.id (:245-251) and warnOnIdMismatch is never called on the Store branch. Pre-existing and unchanged by this PR, but it means an id-mismatched store row can never satisfy the skip guard.
  • Naming: installedAndUsable() is still a fourth name for what the reporter calls installedPluginIds() and the resolver calls installedAndOnDisk, and DynamicPluginManager.isInstalled still has no production caller but its own log field. Both were called non-blocking last time and still are.

Not verified: I did not run the suite, ktlint or detekt - a :composeApp:desktopTest run needs a full module compile in this environment. Findings 1, 2, 4 and 5 are static control-flow and file-API reads (File.outputStream() truncation, PluginSignatureSidecar placement, the loadExternalPlugins scan filter), not live reproductions. The diff is against origin/dev as fetched in this checkout.
• branch fix/wizard-installed-predicate

@kshivang

Copy link
Copy Markdown
Contributor

@arjun28115 Thank you for the concrete follow-up: isPluginResident plus the pre-promotion Store guard now preserves the seeded resident plugin artifact, and the nine real-file tests improve on predicate-only coverage. Your formatting repair and preserved reviewer attribution are also verified. All ten checks on 9cee17bf13583daaf8f4e36b27592dc8ee8c02ea passed; CI is not the reason for this handoff.

Two artifact-preservation gaps remain after reviewing the actual call paths:

  1. GitHub is not protected by downloading directly to the final name. downloadFromGitHubRelease opens File(pluginDir, jarName).outputStream() at lines 549-553, which truncates an existing artifact before manifest extraction or the loader refusal. The new skip predicate admits the same DISABLED/stale-manager-path case into installFromGitHub; that path never consults isResident. A failed stream can leave partial bytes at the persistence path. The earlier comment that this loss shape cannot exist without promotion is incorrect. Stage GitHub downloads under unique non-scannable temporary names, validate the manifest/residency before touching a final artifact, and retain the original bytes/reference on refusal or download failure.
  2. Keeping a pathname does not preserve its artifact. In stageAndInstall, when the destination exists and residency is false, atomicMoveFrom replaces its bytes before install returns failure. createdByThisCall == false then keeps the failed replacement. The test a failed install keeps a jar that was already there only asserts isFile; asserting the original bytes would fail by inspection. Safely refuse an occupied destination or provide replacement ownership/rollback that preserves the original jar and signature sidecar on failure. Do not fix this by suppressing deletion alone.

Please add byte-for-byte assertions for both routes with an existing artifact and persistence reference; cover mid-stream failure, resident refusal, failed replacement, rejected/unloaded retry, registration-incompatible failure, and access-disabled success. Exercise the production service wiring as well as the extracted helper. Run targeted desktop tests, ktlint and detekt plus required CI on the repaired head. The Store resident-refusal test is meaningful, but it is not a full service/persistence integration test.

These are static control-flow findings, not a claimed live reproduction. I am returning the PR to draft rather than merging with unresolved artifact-loss risk; no source changes were pushed in this sweep. The latest Claude review was requested once for the new staging head and is pending. The previous review's result-classification/KDoc findings are addressed; API renaming and helper naming remain nonblocking. Issue #600 concerns separate concurrent host updates and is read-only context, not a request to take over that issue. Original contributor and reviewer credit remain intact.

@kshivang
kshivang marked this pull request as draft September 14, 2026 04:03
@arjun28115

Copy link
Copy Markdown
Contributor Author

Both findings are right, and the first one contradicts something I asserted, so let me be direct about that before the fix.

I claimed the GitHub path was safe and it was not. My words were that it "downloads straight to its final name and never promotes over an installed artifact", and I treated the absence of a promotion as the absence of a loss. Downloading straight to the final name is the overwrite. targetFile.outputStream() truncates the installed jar before the manifest is read or the loader consulted, so a refusal, or a stream that died halfway, leaves partial bytes at the path installed.json names. I checked whether there was a move and never asked what the write itself does to what is already there.

Fixed at b69aa74:

1. GitHub stages first. The download lands on a unique staging name that deliberately does not end in .jar, so a startup scan cannot mistake a half-written file for a plugin, and a failed stream deletes it. The manifest is read from the staged file, and stageAndInstall then decides whether the installed artifact is touched at all. Both wizard routes now go through one guarded promotion instead of one guarded and one not.

2. Replacement now rolls back. You are right that keeping the pathname is not preserving the artifact, and right that my test proved nothing: asserting isFile passes while the contents are wrong, which is precisely the bug. The incumbent jar and its .sig are moved aside before the replacement and restored if the install fails. They move as a pair because a sidecar asserts the store vetted those exact bytes, so a restore that cannot put the sidecar back removes it rather than leaving it describing a jar it never signed.

One more I found while doing it. A throw from the loader escaped past the cleanup entirely, leaving the incumbent overwritten with no rollback at all. It is caught now, and CancellationException is rolled back and rethrown rather than swallowed, so a wizard closed mid-install does not leave the previous artifact replaced by one that nothing loaded.

Tests go from 9 to 12 and assert bytes rather than existence: original contents restored on failure, sidecar restored with its jar, a successful replacement leaving no backup behind, and a throwing loader still restoring.

What I am not claiming. There is no service-level integration test. installPlugins reaches the network and the loader, so what these exercise is the promotion and rollback that both routes now call, not the persistence write around them. Your point that the resident-refusal test is not a full service/persistence integration test stands, and I have not pretended otherwise. There is no working Gradle path for this repo on my machine, so CI is the only verification; ktlint 1.8.0 is clean and my diff adds no line over 120 columns.

Thank you for reading the actual call paths rather than the description. The GitHub finding is one I would not have found by re-reading my own reasoning, because my reasoning was the thing that was wrong.

@arjun28115

Copy link
Copy Markdown
Contributor Author

All ten checks pass on 73f8a7a. Both artifact-preservation findings are addressed; the three commits after the first are quality-gate repairs with no behaviour change.

What the fix does, briefly, since it took four heads to land:

  • The GitHub download stages to a unique name that deliberately breaks the .jar suffix, so nothing scanning the plugin directory can mistake a half-written file for an installed plugin, and a failed stream deletes it. The manifest is read from the staged file and stageAndInstall then decides whether the installed artifact is touched at all. Both wizard routes now go through one guarded promotion.
  • The incumbent jar and its .sig are moved aside before a replacement and restored together if the install fails, so a failure restores bytes rather than a pathname. PluginJarReconciler says a .sig must never outlive its jar, so a restore that cannot put the sidecar back removes it instead.
  • A loader throw used to escape the cleanup entirely. It is caught now, and CancellationException is rolled back and rethrown rather than swallowed.

Tests are 9 to 12 and assert bytes, not existence. The one you identified, a failed install keeps a jar that was already there, is now a failed install restores the original bytes, not merely the pathname and fails against the previous implementation.

On the three extra heads. Every one was detekt, and the causes are worth recording rather than hiding:

  1. Four issues were the baseline losing its grip: it keys on the whole signature, and I had changed a return type, so suppressions stopped matching a body I had barely touched. Restoring the signature and deriving the destination from the staging suffix fixed all four.
  2. Then NestedBlockDepth and TooManyFunctions, both because I had put helpers inside a class that was already at its limit. They are top-level now, which is where a helper touching no instance state belonged anyway.
  3. Then one generic catch I had missed. writeStagedDownload catches IOException, which is what stream and file work actually throws. The suppression on stageAndInstall stays, because there the injected loader runs plugin code and what it can throw is genuinely unknowable.

I cannot run detekt on this repository locally, only ktlint, so those surfaced one head at a time instead of together. That is on me, not on the gate.

One thing for you rather than for this PR: the baseline entry for installFromGitHub records Result<Unit> while the function has returned Result<DynamicPluginInfo> for some time, so that entry is stale independently of this change.

Still not claimed: a service-level integration test, or any native reproduction of #551's symptom. What these tests exercise is the promotion and rollback both routes call.

Marking ready since the artifact-loss risk you returned it for is resolved and CI is green. If you would rather re-draft it, that is entirely fair.

@arjun28115
arjun28115 marked this pull request as ready for review September 14, 2026 04:58
@kshivang

Copy link
Copy Markdown
Contributor

@claude Please review current head 73f8a7a, focusing on the new staging/rollback repairs after your 9cee17b review, sidecar failure handling, and production wiring. This is the single review request for this sweep.

@claude

claude Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 5m 7s —— View job


Review: staging/rollback repairs at 73f8a7a

  • Gather context (diff since 9cee17b, PluginInstallService, tests)
  • GitHub staging path: name, truncation, failure cleanup
  • Store path + stageAndInstall rollback and sidecar handling
  • Production wiring: what the 12 tests pin and what they do not
  • Post review

Verdict: the two findings handed back are addressed in substance. The GitHub download no longer opens the installed name's stream (:591 stages to $jarName$STAGING_SUFFIX${System.nanoTime()}, a name that deliberately does not end in .jar), the manifest is read from the staged file, a dead stream deletes its own partial (writeStagedDownload:753), both routes now go through one guarded promotion, and the rollback is byte-level rather than pathname-level. The two additions the author found unprompted are the right ones: a loader throw used to escape past the cleanup, and CancellationException is rolled back and rethrown rather than swallowed. The previous round's displaced-KDoc finding is genuinely repaired at installedAndUsable / warnOnIdMismatch.

But the backup mechanism introduces a new artifact-loss window that did not exist before this commit, and two of its three renameTo results are unchecked. Findings most severe first.


1. A throw from atomicMoveFrom now loses the incumbent outright - this is a regression, not a pre-existing gap

Incumbent.setAside renames the installed jar to <name>.pre-install-backup (:901-916) before the move, and the move at :833 sits outside every try:

val incumbent = if (movingIntoPlace && finalFile.isFile) Incumbent.setAside(finalFile) else null
if (movingIntoPlace) {
    finalFile.atomicMoveFrom(downloadedFile)   // can throw; nothing rolls back
}
val rollBack = { ... }                          // defined after the move

atomicMoveFrom is Files.move(..., REPLACE_EXISTING, ATOMIC_MOVE) with only AtomicMoveNotSupportedException handled (AtomicFileWrite.kt:28-39); every other IOException propagates - a Windows lock on the destination, a permission change, a vanished source. When it throws:

  • the installed jar is at <name>.jar.pre-install-backup and its sidecar at <name>.jar.sig.pre-install-backup;
  • nothing is at finalFile, which installed.json still names;
  • neither name ends in .jar, so PluginJarReconciler and DefaultPlugin.loadExternalPlugins cannot find them, and nothing sweeps them either;
  • the outer catch (e: Exception) in installPlugins / installFromGitHub records a failure and returns.

Before b69aa74 a throwing move left the incumbent untouched at its own path. So the shape this PR exists to eliminate - a persistence row naming nothing - is now reachable through the code added to prevent it, with the bytes parked under a name no reader knows. The fix is small: put the move inside a try, rollBack() on throw, and rethrow.

Fix this →

2. A failed setAside inverts ownership, so the incumbent is deleted rather than merely overwritten

setAside returns null when jar.renameTo(jarBackup) fails (:903). The caller then computes createdByThisCall = incumbent == null (:828), so on a failed install rollBack() runs finalFile.delete() on an artifact this call never created. Pre-PR the discriminator was !movingIntoPlace || !finalFile.isFile, which was false for an occupied destination and suppressed the delete. So the KDoc's

A null means the caller must treat the destination as its own, which is the pre-existing behaviour and no worse than it.

is wrong on both halves: it is not the pre-existing behaviour, and deleting is worse than overwriting, because it is exactly the dangling-row outcome the PR is about.

It is worse still in combination with the sidecar. When setAside bails, the incumbent's .sig stays at <final>.jar.sig and fresh bytes are moved under it. verifySignatureOrThrow states the policy plainly - "a sidecar that is present but does not verify always fails" - independent of PluginSignatureEnforcement. So a failed setAside on a signed plugin means a guaranteed load failure followed by deletion of the artifact. Track "the destination was occupied" separately from "I hold a backup", and when the two disagree, refuse to promote at all rather than promoting without a rollback.

Fix this →

3. The incoming download's sidecar still does not travel, and now the incumbent's is actively stripped

The KDoc added this round says "The sidecar travels with its jar." That is true only of the incumbent's sidecar. The store path still downloads to ${plugin.id}-downloading.jar (:189), RemotePluginRepository.downloadPlugin persists the store signature to <targetPath>.sig (RemotePluginRepository.kt:352), and stageAndInstall moves only the jar. So:

  • every wizard store install still loads unsigned, and orphans a ${plugin.id}-downloading.jar.sig;
  • new this round, Incumbent.discard() deletes the incumbent's sidecar on success (:888-891), so reinstalling a signed plugin through the wizard silently converts it to an unsigned one - a warn today, a hard refusal once PluginSignatureEnforcement.DEFAULT flips per BossConsole#102;
  • restore() deletes <final>.jar.sig before putting the backup back, so a restore whose sidecar rename failed leaves the restored original unsigned too.

StoreVersionHooks.promoteFiles (StoreVersionInstaller.kt:29-37) is already the two-line answer, and it does the thing the hand-rolled version misses: PluginSignatureSidecar.persist(target, read(downloaded)), where a null signature deletes the target sidecar so fresh bytes can never sit under a stale one. The wizard also hardcodes the ".sig" string rather than PluginSignatureSidecar.pathFor, which is why the .nosig marker is not carried either (inert, since it is anchor-keyed, but it is the same reason to reuse the object).

Fix this →

4. restore() ignores both of its renameTo results

fun restore() {
    jar.delete()
    jarBackup.renameTo(jar)   // :879 - boolean dropped
    ...
}

It deletes first, so a failed rename leaves nothing at the path and no log line anywhere: the caller reports "install failed" and the plugin has silently vanished, recoverable only by hand from a .pre-install-backup file. This repo's own precedent is explicit that a renameTo/delete boolean is not to be trusted and the postcondition is what matters (purgeJarsFor re-lists rather than counting deletes, because a Windows lock returns false silently). At minimum log a failed restore with the two paths; better, verify jar.isFile afterwards and say so when it is not.

5. Production wiring: this round's actual repair has no test at all

All 12 WizardStagedInstallTest cases call the free stageAndInstall directly, so the previous review's finding 3 is still open - reverting either call site (:378-383 via promoteAndInstall, :217-224 in the store loop) back to inline atomicMoveFrom + installPlugin + finalFile.delete() leaves all 12 green. What is new is that the change you asked for this round is the staging, and nothing covers it:

  • writeStagedDownload (:753) - that a dying stream deletes its partial and rethrows. internal, takes an InputStream, so a throwing stream and a temp dir is the whole test.
  • finalFileForStaged (:772) - pure, and it is the function that decides where bytes get promoted, by substringBefore on a string. Untested.
  • ownerAndRepo (:733) - pure, extracted this round, untested.
  • that downloadFromGitHubRelease no longer targets the installed name, and that the staged name does not end in .jar.

Those first three are internal top-level functions with no network and no manager, which makes this the cheapest coverage in the PR. The call-site pinning still needs the third injected collaborator (a stage lambda beside dependencyReporter and isResident); note the existing residency is asked about the id it is given test remains tautological - it passes a literal into the parameter and asserts it comes back, which cannot notice the call site switching from manifest?.pluginId to plugin.id.

Fix this →

6. Nits

  • The store path never adopted the convention this PR introduced. STAGING_SUFFIX exists precisely so a half-written download is not scannable, but ${plugin.id}-downloading.jar (:189) ends in .jar. A failure between the bytes landing and the move still leaves it for the next launch's directory scan to install, and the downloadedPath == null branch never deletes a partial. The two routes now disagree about the one rule this commit added.
  • Nothing ever sweeps staged litter. A throw between extractManifestFromJar and the promotion leaves <asset>.downloading.<nanos> in the plugin dir forever; the catch in installFromGitHub does not delete it. Harmless per file, unbounded across retries.
  • The resident branch still deletes downloadedFile unconditionally (:815), and the a download that needs no move is still cleaned up on failure test establishes downloadedFile == finalFile as a supported shape. In that shape the refusal deletes the artifact it exists to protect. Not reachable now that GitHub stages, but the function is internal and shaped for reuse; skip the delete when the paths are equal.
  • Two stray KDoc blocks, one new. :477-480 still carries the old "Download a JAR from GitHub releases. Returns the path to the downloaded JAR" block stacked above the new one, and it is now wrong as well as orphaned (it returns the staged path). And the block at :726-731 documents finalFileForStaged but sits above ownerAndRepo's own KDoc, so finalFileForStaged at :772 has none. Same displaced-KDoc pattern as last round's finding 4, in a new place.
  • substringBefore takes the first occurrence, so an asset literally containing .downloading. would promote to a truncated name. Contrived, but it is the one input finalFileForStaged cannot survive and there is no test.
  • Naming and DynamicPluginManager.isInstalled (no production caller but its own log field) remain as previously agreed non-blocking.

Not verified: I did not run the suite, ktlint or detekt - a :composeApp:desktopTest run needs a full module compile in this environment. Findings 1-4 are static control-flow and file-API reads (Files.move throwing outside the try, File.renameTo returning false, verifySignatureOrThrow's present-but-invalid policy, PluginSignatureSidecar.persist placement), not live reproductions. git diff origin/dev...HEAD reports no merge base in this checkout, so I diffed 9cee17b..HEAD for the new work and read the current file whole.

• branch fix/wizard-installed-predicate •

@kshivang

Copy link
Copy Markdown
Contributor

@arjun28115 I re-read the four new commits through 73f8a7a, rather than carrying forward the previous disposition. The GitHub download now stages before touching the destination, both routes reach the guarded helper, and loader failures restore original bytes in the covered case. The updated byte assertions are useful. I downloaded the Ubuntu test artifact: all 12 staging tests and 11 predicate/result tests pass; all ten hosted checks are green. CI is not an author defect or the reason for the correctness handoff.

The new rollback has remaining failure paths that prevent merge:

  1. The incoming signature still does not travel. RemotePluginRepository persists <downloadedPath>.sig, but stageAndInstall only moves downloadedFile. Incumbent backs up/restores the OLD sidecar; it never promotes the NEW one. A successful signed Store install leaves the new sidecar at the staging name and loads the final jar without it. Refusal/failure also deletes only the download jar. This is Claude finding 2 from the previous review, still open despite the new KDoc saying the sidecar travels. Move/discard the incoming pair and verify the signature exists at the final path before invoking the loader. Cover first install, replacement, refusal, and failure with distinct old/new signature bytes.
  2. Backup/promotion failure is outside the rollback boundary. Incumbent.setAside returns null when renameTo fails; stageAndInstall interprets that as createdByThisCall = true and proceeds to replace/delete an existing artifact. A failed backup must refuse the install, not confer ownership. Conversely, after a successful backup, finalFile.atomicMoveFrom(downloadedFile) can throw BEFORE the try/rollback block, leaving the persistence path absent and the original stranded as .pre-install-backup. Cover a failing promotion after backup and a failing backup with byte-for-byte/persistence-path assertions. Use unique backups; the current fixed-name jarBackup.delete() can destroy the only original left by an earlier failed promotion.
  3. Restore ignores failure. restore deletes the replacement, ignores the boolean from jarBackup.renameTo(jar), then manipulates the signature regardless. If restoring the jar fails, the expected path remains absent while the caller receives only the loader error. Preserve recoverable backups and surface rollback failure; do not discard signature evidence when recovery fails. Test failed jar and sidecar moves independently.

The Store download still uses the fixed, scannable -downloading.jar path, and no test reaches the production service wiring. Please use unique non-scannable staging for Store as for GitHub and add an injected file-operation/service test seam to exercise these failure points. The existing 12 tests all use successful real filesystem moves and a mocked loader; they cannot establish the transaction's behavior when a move fails. A safe bounded alternative is to refuse an occupied destination before touching it, with a clear recovery path, rather than implement replacement rollback here. The same-file resident deletion and access-hidden refusal wording from Claude remain smaller follow-ups. Naming suggestions are nonblocking; the original skip predicate and result classification remain sound.

These are static control-flow findings, not a claimed native reproduction. I have not pushed a narrow cleanup patch that leaves the other transaction failures unvalidated. The host remains the correct home for this install orchestration.

Separately, GitHub reports this head CONFLICTING with current dev. That is shared-base drift, not evidence the original contribution was defective. Reconcile with latest dev while preserving changes already squash-merged to main (#649 included); the large ancestry diff must not reintroduce or revert unrelated work. Then run targeted WizardStagedInstallTest, WizardInstalledPredicateTest, service wiring/fault tests, ktlint/detekt, and required CI on the resulting head. No live database operation is needed.

I requested one current-head Claude review: #563 (comment). It is still in progress at this handoff; no approval is claimed. Returning this PR to draft for the above correctness issues. Your original work and subsequent repairs remain credited separately from earlier Codex repairs. Related wizard UX PRs #590/#623 and shared update issue #600 remain separate, read-only context.

@kshivang
kshivang marked this pull request as draft September 14, 2026 08:09
@arjun28115

Copy link
Copy Markdown
Contributor Author

Heads up before that review lands: I found a flaw in my own sidecar handling while re-reading it, and pushed f918460. The head you asked @claude to review is now one commit behind.

restore() deleted the sidecar unconditionally before putting the jar back. That is correct in one case and actively harmful in another, and the code could not tell them apart:

  • backed up - whatever sits there belongs to the failed install, so replace it with the original. Correct before.
  • there was none - anything there was written by the install that just failed, so remove it. Correct before.
  • there was one and it could not be moved aside - it never left. Deleting it destroys a valid signature for the very jar being restored, turning a signed plugin unsigned. The rollback introduced that by itself, which makes it worse than the behaviour it replaced.

hadSidecar now separates the third case from the second, and the third leaves the file alone.

Two tests, 12 to 14. The unbackable case is forced by occupying the backup path with a directory, which is the only portable way I know to make renameTo fail; the other writes a sidecar from inside the failing install and asserts it does not outlive the jar it described.

Worth saying why I looked: your review request named sidecar failure handling specifically, and that prompted me to check the branch I had the least evidence for rather than wait to be told. It was the right place to look.

@claude

claude Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 4m 51s —— View job


Review: partial promotion cleanup & destination ownership at 3f15e9c

  • Gather context (diff vs merge base, PluginInstallService, SignedArtifact, tests)
  • SignedArtifact.moveTo / promotion failure boundary
  • Destination ownership and the exists() refusal
  • What the 11 tests establish
  • Post review

Verdict: the redesign is the right call and it retires a whole class of finding. Refusing an occupied jar means there is no backup, no restore, no fixed-name backup path to destroy, and no window in which the artifact exists only under a name nothing reads - every one of the previous round's findings 1-4 stops existing rather than being patched. SignedArtifact closes the incoming-signature gap that had been open across two reviews, and the staging name is now unique and non-scannable on both routes (stagingNameFor at :720, used at :193 and :548), which is what the Store path was missing. The assertEquals("the signature", sidecarOf(finalFile).readText()) in the signature travels with the jar it signs is the assertion that had been asked for twice.

@kshivang's remaining finding is correct, and it is the one the refusal does not cover: refusing an occupied jar makes the decision atomic, not the promotion. moveTo is two or three moves and sits outside every try. Three findings, most severe first; the first is the handed-back one, confirmed with a detail that makes it worse.


1. A partial promotion leaks the jar at its installed name, and the refusal then blocks every retry

stageAndInstall:854-858 runs the promotion before the failure boundary, and SignedArtifact.moveTo:777-785 moves the jar first:

if (movingIntoPlace) {
    staged.moveTo(destination)      // :857 - outside every try
}
val result = try { install(...) } catch ...
if (result.isFailure) destination.delete()

If the jar move succeeds and a sidecar move then throws, the exception propagates past stageAndInstall entirely into the caller's generic catch (:280 on the Store loop, :429 in installFromGitHub). Neither cleans up. What is left on disk:

  • <final>.jar - the new bytes, at a scannable name;
  • <staged>.jar.downloading.<nanos>.sig - the signature, orphaned;
  • no installed.json row and no manager entry, because install never ran.

Two consequences, and the second is the part that is not in the handoff:

  • DefaultPlugin.loadExternalPlugins lists file.extension == "jar" (DefaultPlugin.kt:1185) and installs everything the manager does not know about, so the next launch loads a plugin the wizard reported as failed - unsigned, since its sidecar stayed behind.
  • The refusal at :839 then makes it permanent. The occupant has no manager entry, so installedAndUsable() never counts it, the wizard offers the plugin on every reopen, and every attempt is refused with "Remove the existing plugin in the plugin manager and try again" - where it is not listed, because getInstalledPlugins reads pluginStates. Finding 1 and the recoverability of finding 3 below are the same defect seen from two sides.

The jar-move-throws case is milder but also uncleaned: the staged jar and its sidecar stay in the plugin dir with nothing that ever sweeps them (harmless per file, unbounded across retries).

This repo already has the fix, one directory over. StoreVersionInstaller.promoteAndActivate:148-160 wraps the promotion in runCatching and discards both paths on failure, with a comment naming this exact scenario - "the move may already have succeeded: a sidecar step that then threw would leave an unvetted, never-loaded jar at a scannable name for the next launch." stageAndInstall needs the same three lines. Note that discarding both is only safe because the destination was refused if occupied - so the refusal is what makes this cleanup correct, rather than a substitute for it.

Fix this →

2. Occupancy is checked on the jar only, so the ownership claim is not true of the sidecar

The KDoc at :813-814 says "Everything at the destination after the move is this call's own, so removing it on failure cannot destroy somebody else's artifact." The guard at :839 is finalFile.exists() - the jar. Both directions of that gap are reachable:

  • An unsigned download onto a stale sidecar. A lone <final>.jar.sig with no jar beside it passes the occupancy check. moveTo only moves sidecars that exist beside the staged file, so the fresh jar lands under the old signature. DynamicPluginLoader.kt:608 fails a present-but-non-verifying signature unconditionally - PluginSignatureEnforcement gates only the absent case - so the install fails with a signature error for a jar that was just downloaded. Self-healing on the next attempt (the failure branch clears both), but the user is told their fresh download failed verification.
  • destination.delete() at :877 removes sidecars this call did not create, which is the thing the KDoc says cannot happen. Low harm - an orphan .sig should not outlive its jar anyway, and .nosig is anchor-keyed so its loss only costs a re-lookup - but it is the claim, not the consequence, that is wrong.

StoreVersionHooks.promoteFiles (StoreVersionInstaller.kt:29-33) is the shape that has no such gap, because it goes through PluginSignatureSidecar.persist, whose documented null-signature behaviour is delete the target sidecar - precisely so fresh bytes can never sit under a stale one. Reusing it also drops the hand-rolled sidecar.absolutePath.replace(jar.absolutePath, ...) at :781 in favour of PluginSignatureSidecar.pathFor, which cannot be fooled by a path that contains itself.

Fix this →

3. What the refusal does to an occupant the plugin manager cannot show

Worth stating explicitly, since the refusal is now the design and its message is the only recovery instruction the user gets. Enumerating what reaches stageAndInstall at all - installedAndOnDisk is LOADED || (jar exists && !incompatible), so the admitted cases are: no entry, non-LOADED with the jar missing, and non-LOADED-and-incompatible:

Occupant Listed in the plugin manager? Refusal message
binary-incompatible, jar intact yes (DISABLED entry) actionable
signature-verification failure no entry at all dead end
leaked by finding 1 no entry at all dead end

The middle row is not hypothetical: loadPlugin fails before registration, so installPlugin creates no entry (DynamicPluginManager.kt:969-978 records a load gate and returns failure), and DynamicPluginLoader.kt:610-612 deliberately leaves the artifact in place - "will keep being rejected until it is reinstalled or removed". Reinstalling over it was that remedy. The Toolbox route still works, because StoreMissingDependencyInstaller targets <id>_<version>.jar where the wizard targets <id>-<version>.jar, so it does not meet the same occupant - but the wizard's own sentence sends the user somewhere that cannot help. Either widen the wording to name the file path, or branch on whether the occupant is a known plugin. Replacing it instead would reintroduce the loss shape, so the wording is the fix, not the guard.

4. The refusal can be made exclusive for free, rather than documented as serialized

On the concurrency point: I do not think there is a live same-path race to reproduce. The wizard's destination is <id>-<version>.jar; StoreMissingDependencyInstaller uses <id>_<version>.jar (:219) and StoreVersionInstaller.targetFor the same underscore form, and the wizard loop is sequential - so no other installer in the tree writes the wizard's destination name. But the guarantee is currently "no caller does this", which is what drifts.

It does not need a lock or an untestable race regression: Files.move without REPLACE_EXISTING throws FileAlreadyExistsException when the destination exists, which is the atomic form of exactly this refusal. A moveIfAbsent beside atomicMoveFrom in AtomicFileWrite.kt would collapse :839's check-then-move into one operation, and its failure is then a refusal rather than a silent overwrite. atomicMoveFrom's own KDoc is the right place to say why the two exist.

5. Nits

  • The displaced KDoc has moved again, third round. :676-680 ("Marks a download still in flight… breaks the .jar suffix… carries a nonce") sits directly above private val GITHUB_URL at :681. It documents STAGING_MARKER / stagingNameFor at :718-720, which have none.
  • The resident refusal still deletes downloadedFile unconditionally (:850). movingIntoPlace is computed at :828 and is false when the paths are equal, in which case the refusal deletes the artifact it exists to protect - as does :877. Neither call site can reach it today, but the function is internal, it models the case, and the test that used to establish that shape (a download that needs no move is still cleaned up on failure) was dropped this round, so nothing records the intent any more.
  • writeStagedDownload is still untested, and it is this round's actual repair on the GitHub side. It is internal and takes an InputStream, so a throwing stream plus a temp dir is the entire test: assert the partial file is gone and the IOException propagates.
  • Call-site pinning remains open (previous review's finding 3). All 11 tests call the free stageAndInstall, so reverting either call site - :224 or :413 - back to inline atomicMoveFrom + installPlugin + finalFile.delete() leaves them green. isResident is injected; a third injected collaborator for the staging step, with one test asserting the manifest id is what gets passed, is the closure. The residency is asked about the id it is given case is not it - it passes a literal into the parameter and asserts it comes back.
  • usableWizardInstallResult turning a registration-incompatible success into a failure now means stageAndInstall deletes the jar the manager's fresh DISABLED entry names. That is consistent with installedAndOnDisk (jar absent → retried next run) and I think it is what you want, but it is an in-memory entry pointing at a deleted path and deserves the one line of comment that says so deliberately.
  • finalFileForStaged uses substringBefore, so an asset name literally containing .downloading. promotes to a truncated name. Contrived, and the round-trip test does not cover it.
  • The stale detekt baseline entry recording installFromGitHub as Result<Unit> is real and independent of this PR, as the author noted.

Not verified: I did not run the suite, ktlint or detekt - a :composeApp:desktopTest run needs a full module compile in this environment. Findings 1-4 are static control-flow and file-API reads (moveTo outside the try, Files.move semantics with and without REPLACE_EXISTING, verifySignatureOrThrow's present-but-invalid policy, the loadExternalPlugins extension filter, installer target-name comparison), not live reproductions. git diff origin/dev...HEAD reports no merge base in this checkout, so I diffed against the merge base 5a2e818 and read the current file whole.
• branch fix/wizard-installed-predicate

…print

Review found that refusing an occupied JAR does not make promotion
indivisible, and it was right. `SignedArtifact.moveTo` moved the jar and
then its sidecar, and `stageAndInstall` called it BEFORE the try/cleanup
block. If the second move failed - the final `.sig` path being a nonempty
directory is enough, and a permission change between the check and the move
will do it too - the jar was left at its scannable installed name with its
signature stranded at the staging name, nothing cleaned it up, and the
occupied-destination refusal then blocked every retry.

Three changes:

**Promotion is inside the failure boundary.** `moveTo` records each path as
it lands and deletes exactly those on failure, so a partial promotion leaves
the destination as it was found. Deleting rather than moving back is
deliberate: every path was established free beforehand, so deleting cannot
destroy an artifact this call did not create, whereas a move back has a
failure of its own that would leave the same mess. What is lost is a
download, which the retry re-fetches. The loader is never reached.

**The refusal covers the whole footprint**, not just the jar. A stale `.sig`
beside a jar removed by hand is still somebody else's file, and promoting an
unsigned download next to it would leave an old signature asserting bytes it
never vetted - which PluginSignatureSidecar's own contract calls out as the
thing never to do, and which is worse than no signature because the loader
reads it as present-and-invalid. Refusing also means every path this call
writes was established free, which is what makes the cleanup precise rather
than indiscriminate. The message names the obstructing file so the user
knows what to remove.

**The destination-ownership claim is corrected, not restated.** The check and
the move are two operations, so it holds against what is already on disk, not
against a second installer racing this one. `ATOMIC_MOVE` cannot close that
gap: on POSIX it is rename(2), which replaces the target silently - verified
with a probe on this platform rather than assumed. Promotion therefore uses a
plain non-replacing move, so a racing installer collides with
FileAlreadyExistsException and this call reverts, instead of the two
overwriting each other silently. Serialising installs is the only thing that
would make the race impossible, and that does not belong in this function;
the KDoc says so rather than implying a guarantee that is not there.

Three regression tests over real files and real moves: a blocked destination
sidecar (loader not called, no jar at the installed name, the obstruction and
its contents untouched, and the retry succeeds once it is removed); an
unsigned download meeting an old sidecar; and a promotion onto an occupied
path failing rather than replacing it.

Reported-by: kshivang
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@arjun28115

Copy link
Copy Markdown
Contributor Author

@kshivang confirmed and fixed in 3d7f888. You were right on the substance and right that refusing an occupied JAR does not make two file moves indivisible.

Promotion is now inside the failure boundary. moveTo records each path as it lands and deletes exactly those on failure, so a partial promotion leaves the destination as it was found and the loader is never reached. I delete rather than move back deliberately: every path was established free beforehand, so deleting cannot destroy an artifact this call did not create, whereas a move back has a failure mode of its own that would leave the same mess. What is lost is a download, which the retry re-fetches.

The refusal now covers the whole footprint, not just the jar — which also answers your unsigned-download case. A stale .sig beside a hand-removed jar is somebody else's file, and promoting an unsigned download next to it leaves an old signature asserting bytes it never vetted. That is worse than no signature, because the loader reads it as present-and-invalid, and it is the exact thing PluginSignatureSidecar.persist's own KDoc says never to do. Refusing also means every path this call writes was established free — which is what makes the cleanup precise instead of indiscriminate, so no pre-existing sidecar is ever deleted. The message names the obstructing file, otherwise the refusal is a dead end.

On destination ownership — you were right that the claim was too strong, and I have not restated it. I probed the actual semantics rather than reasoning about them:

ATOMIC_MOVE onto existing:  REPLACED           <- rename(2); silent
plain move onto existing:   FileAlreadyExistsException
move onto nonempty dir:     FileSystemException <- your scenario

So atomicMoveFrom was the wrong primitive for promotion: it passes REPLACE_EXISTING and ATOMIC_MOVE overwrites silently on POSIX. Promotion now uses a plain non-replacing move, so a racing installer collides loudly and this call reverts, instead of the two overwriting each other. That closes the damage but not the race — check-then-move is still two operations and NIO exposes no create-exclusive move. The KDoc now says exactly that and names serialisation as the only thing that would make it impossible, rather than implying a guarantee that is not there. Both callers are on one wizard install path today; if you want serialisation established rather than documented, say where you would like the lock to live and I will add it.

Tests — three new, over real files and real moves, all confirmed to fail against 3f15e9c:

Test Against 3f15e9c
blocked destination sidecar FileSystemException escapes stageAndInstall entirely — the missing boundary
unsigned download meets old sidecar promotes anyway; isFailure is false
promotion onto an occupied path no exception, bytes silently replaced

The blocked-sidecar test covers what you asked for end to end: loader not called, no jar at the scannable installed name, the obstruction and its contents untouched, and the retry succeeding once the obstruction is removed.

WizardStagedInstallTest 14/14, WizardInstalledPredicateTest 11/11, PluginDependencyResolutionTest 28/28, all run locally.

One process note worth recording: I got BossConsole building locally for the first time this session, and it immediately caught a test compile error in this very change that would otherwise have gone to CI. Two of my earlier CI failures on this PR were the same class of mistake. That gap is closed at my end.

No native wizard check performed, as you noted — the mixed-batch and signed-retry paths still want a manual pass in a disposable profile.

…rload

`assertContains(charSequence, other, message)` binds the message to
`ignoreCase: Boolean`, so overload resolution falls through to the Iterable
and array candidates and the call does not compile. The message moves to a
comment; the assertion is unchanged.

This was already fixed in my working tree when 3d7f888 was pushed - the
amend that should have carried it was part of a compound command that was
denied, and I pushed the pre-fix commit without re-checking. The commit, not
the working tree, is what CI builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@arjun28115

Copy link
Copy Markdown
Contributor Author

Correction on my own record, since you'd have seen 3d7f888 go red right after I posted that it was fixed.

That failure was not in the promotion change — it was a two-argument-vs-three-argument assertContains in the new test, which I had already fixed locally before pushing. The fix never made it into the commit: I had chained the amend and the push into one command, that command was refused as a whole, and I retried only the push. So I shipped the pre-fix commit while the fix sat uncommitted in my tree, and my "green locally" was green against the dirty tree rather than against the commit. Entirely my error, and a bad one to make right after saying the local gate was closed.

Corrected in f77c177. All ten checks green on that head.

I've also stopped running only the classes you named. The full desktop suite now passes locally against the committed tree — 4,932 tests across 515 classes, 0 failures, plus detekt — so the next thing I hand you should not need CI to find a compile error.

The substantive review points from your last pass are unchanged in 3d7f888: promotion inside the failure boundary with per-path revert, the refusal extended to the whole footprint, and the ownership claim bounded rather than restated. Leaving this in draft for you to decide on.

The branch was 198 commits behind dev, and its last CI ran on 2026-09-22. Merged
rather than rebased because the branch carries a commit by @kshivang, and a merge
keeps every existing SHA reviewers have referred to in this PR's thread. No
conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@arjun28115
arjun28115 marked this pull request as ready for review September 24, 2026 07:23
@arjun28115

Copy link
Copy Markdown
Contributor Author

@fluck-boss @kshivang out of draft. I had left it there on 2026-09-15 for you to decide, and it did not get decided, so here is where it stands:

  • @kshivang's last blocking finding (promotion was two moves outside the failure boundary) is fixed in 3d7f888, with the refusal widened to the whole jar-plus-sidecar footprint. That commit and f77c177 have not been reviewed yet; the last review was of 3f15e9c.
  • The artifact-preservation concern in the old "Draft handoff" section is retired by construction, not patched: every path this call writes is established free first, so a failed load can only delete what the call itself published. The description now says how.
  • Merged current dev in (198 commits, no conflicts): 6372 tests, 0 failures, including this PR's 25; lint clean. CI is running on it now.

Not done, and said in the description: a live wizard run.

@fluck-boss

Copy link
Copy Markdown
Collaborator

Merging on a green post-sync gate (CI all-green on 8fcf9a6, checks 07:23Z) with reviewer rounds verified against the head: the store-path residency ordering and the GitHub-path staged download (writeStagedDownload) are both in place.

Follow-ups carried (tracked in #1590):

  • writeStagedDownload has no test; a throwing InputStream + temp dir covers the partial-file cleanup contract.
  • Call-site pinning: all tests drive the free stageAndInstall; reverting either call site to inline atomicMoveFrom stays green. A third injected collaborator would close it.
  • The resident-refusal path deletes downloadedFile unconditionally; unreachable today, but the function is internal and models the case - guard it if touched.
  • KDoc displacement: installedAndUsable sits between the manifest-id KDoc and warnOnIdMismatch; move it below.
  • DynamicPluginManager.isInstalled has no production caller left; rename to hasEntry or delete before the next call site drifts.

@fluck-boss
fluck-boss merged commit 44e066f into risa-labs-inc:dev Sep 24, 2026
9 checks passed
fluck-boss added a commit that referenced this pull request Sep 24, 2026
…talled predicate (#1640)

- writeStagedDownload is tested on both halves of its contract: the whole stream lands,
  and a stream that dies mid-download removes its partial file and rethrows. The dying
  stream serves 16 KiB first, so bytes are on disk when it fails; a stream that failed
  on its first read would pass against a version that never deleted anything.
- Both install paths are pinned to stageAndInstall by a source test (comments stripped,
  since the KDoc names atomicMoveFrom on purpose). Reverting a call site to an inline
  atomicMoveFrom kept every behavioural test green; now it fails one.
- The resident refusal no longer deletes the download when the download IS the
  destination. stageAndInstall models that case explicitly, and in it the unconditional
  delete removed the installed jar - in the one refusal that exists because something
  is still running from that file.
- DynamicPluginManager.isInstalled is renamed hasEntry rather than deleted. It still
  had five production callers (PluginRemoval x3, DependentRestartPrompt, and the
  manager's own uninstall-cancelled log), so deleting it would have broken plugin
  removal. The name was the hazard: an entry survives a rejected, deleted jar, which is
  the reading #563 proved wrong. Its KDoc now says so and points at installedAndOnDisk.
- The KDoc displacement was already in order on the merged code; nothing to change.

Mutation-checked: each guard's removal fails exactly one named test.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
kshivang added a commit that referenced this pull request Sep 24, 2026
* fix(wizard): skip a plugin only when it is actually installed, not merely recorded

The first-run wizard's skip guard called DynamicPluginManager.isInstalled, which
is pluginStates.containsKey. An entry is not a usable plugin. installPlugin
registers a DISABLED entry for a jar it rejected as binary-incompatible and then
deletes that jar, so the wizard announced "already installed, skipping" for a
plugin that was not there, added its id to installedIds, and finished reporting
success for something it never installed. The user picked it during setup and
did not get it.

This is the trap AGENTS.md already records for the dependency prompt, at a call
site that was not converted with it. The same file was already using the correct
predicate after the loop, for the dependency report.

It now uses PluginDependencyResolution.installedAndOnDisk, the codebase's single
definition: LOADED, or the jar exists and the plugin is not recorded as binary
incompatible. Both clauses carry their weight here. Requiring only an entry is
the bug above. Requiring only the jar would refuse to skip a running plugin whose
file has moved, because neither PluginJarReconciler nor the updater repoints the
manager's jarPath, and reinstalling it fails with "already loaded".

Recomputed per call rather than hoisted, since the loop installs plugins and a
snapshot taken before it would go stale exactly when a later entry depends on an
earlier one.

Six tests, written around the cases where the two predicates disagree, since the
cases where they agree would pass either way. The last one pins the disagreement
as a set difference, so a future change to either definition shows up as a
changed disagreement rather than silently converging.

4405 composeApp tests pass. ktlint and detekt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(wizard): reject unusable install results before reporting success

* fix(wizard): refuse to overwrite an installed plugin, rather than roll back

Takes the bounded alternative offered in review, and collapses four commits of my
own CI repairs into the change they were repairing.

**Refuse an occupied destination.** Nothing already at the installed path is
replaced. The previous attempt moved over it and rolled back on failure, and that
grew a tail of its own failure paths: a backup that will not rename, a promotion
that throws between backup and install, a restore that fails silently, a
fixed-name backup that can destroy the only original left by an earlier failure.
Each could lose the artifact the rollback existed to protect, and each needed an
injected file-operation seam to test. Refusing has no tail: nothing is
overwritten, so nothing needs undoing. It also makes cleanup unambiguous, because
everything at the destination afterwards belongs to this call.

**The signature travels with the jar.** `RemotePluginRepository` writes the
sidecar beside the DOWNLOAD path, so promotion is when it has to move. Moving
only the jar left every signed store install loading unsigned while its signature
sat at the staging name. `SignedArtifact` moves and deletes the jar with
whichever sidecar exists, carrying both suffixes the loader knows.

**Both downloads stage.** The Store path used a fixed `-downloading.jar`, which a
directory scan reads as a plugin and which two installs of one id collide on.
Both paths now use a unique name whose marker breaks the `.jar` suffix, with one
constant defining it so writer and reader cannot drift.

`isPluginResident` answers what `isInstalled` cannot: that reports whether an
entry exists, while `disablePlugin` leaves a DISABLED plugin resident in the
loader, which is the case that made this reachable at all.

11 tests over real files and real moves: an occupied destination refused with its
bytes intact and the loader never reached, a resident plugin refused before
anything moves, the signature arriving with its jar, a failure removing both and
leaving no orphan sidecar, a throwing loader not escaping the cleanup, and the
staging name being unique, unscannable and reversible.

Rebased onto current dev, which has now been rewritten four times today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(wizard): make promotion all-or-nothing, and refuse the whole footprint

Review found that refusing an occupied JAR does not make promotion
indivisible, and it was right. `SignedArtifact.moveTo` moved the jar and
then its sidecar, and `stageAndInstall` called it BEFORE the try/cleanup
block. If the second move failed - the final `.sig` path being a nonempty
directory is enough, and a permission change between the check and the move
will do it too - the jar was left at its scannable installed name with its
signature stranded at the staging name, nothing cleaned it up, and the
occupied-destination refusal then blocked every retry.

Three changes:

**Promotion is inside the failure boundary.** `moveTo` records each path as
it lands and deletes exactly those on failure, so a partial promotion leaves
the destination as it was found. Deleting rather than moving back is
deliberate: every path was established free beforehand, so deleting cannot
destroy an artifact this call did not create, whereas a move back has a
failure of its own that would leave the same mess. What is lost is a
download, which the retry re-fetches. The loader is never reached.

**The refusal covers the whole footprint**, not just the jar. A stale `.sig`
beside a jar removed by hand is still somebody else's file, and promoting an
unsigned download next to it would leave an old signature asserting bytes it
never vetted - which PluginSignatureSidecar's own contract calls out as the
thing never to do, and which is worse than no signature because the loader
reads it as present-and-invalid. Refusing also means every path this call
writes was established free, which is what makes the cleanup precise rather
than indiscriminate. The message names the obstructing file so the user
knows what to remove.

**The destination-ownership claim is corrected, not restated.** The check and
the move are two operations, so it holds against what is already on disk, not
against a second installer racing this one. `ATOMIC_MOVE` cannot close that
gap: on POSIX it is rename(2), which replaces the target silently - verified
with a probe on this platform rather than assumed. Promotion therefore uses a
plain non-replacing move, so a racing installer collides with
FileAlreadyExistsException and this call reverts, instead of the two
overwriting each other silently. Serialising installs is the only thing that
would make the race impossible, and that does not belong in this function;
the KDoc says so rather than implying a guarantee that is not there.

Three regression tests over real files and real moves: a blocked destination
sidecar (loader not called, no jar at the installed name, the obstruction and
its contents untouched, and the retry succeeds once it is removed); an
unsigned download meeting an old sidecar; and a promotion onto an occupied
path failing rather than replacing it.

Reported-by: kshivang
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): call the two-argument assertContains, not the Iterable overload

`assertContains(charSequence, other, message)` binds the message to
`ignoreCase: Boolean`, so overload resolution falls through to the Iterable
and array candidates and the call does not compile. The message moves to a
comment; the assertion is unchanged.

This was already fixed in my working tree when 3d7f888 was pushed - the
amend that should have carried it was part of a compound command that was
denied, and I pushed the pre-fix commit without re-checking. The commit, not
the working tree, is what CI builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
kshivang pushed a commit that referenced this pull request Sep 24, 2026
…talled predicate (#1640)

- writeStagedDownload is tested on both halves of its contract: the whole stream lands,
  and a stream that dies mid-download removes its partial file and rethrows. The dying
  stream serves 16 KiB first, so bytes are on disk when it fails; a stream that failed
  on its first read would pass against a version that never deleted anything.
- Both install paths are pinned to stageAndInstall by a source test (comments stripped,
  since the KDoc names atomicMoveFrom on purpose). Reverting a call site to an inline
  atomicMoveFrom kept every behavioural test green; now it fails one.
- The resident refusal no longer deletes the download when the download IS the
  destination. stageAndInstall models that case explicitly, and in it the unconditional
  delete removed the installed jar - in the one refusal that exists because something
  is still running from that file.
- DynamicPluginManager.isInstalled is renamed hasEntry rather than deleted. It still
  had five production callers (PluginRemoval x3, DependentRestartPrompt, and the
  manager's own uninstall-cancelled log), so deleting it would have broken plugin
  removal. The name was the hazard: an entry survives a rejected, deleted jar, which is
  the reading #563 proved wrong. Its KDoc now says so and points at installedAndOnDisk.
- The KDoc displacement was already in order on the merged code; nothing to change.

Mutation-checked: each guard's removal fails exactly one named test.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
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.

3 participants