Skip to content

fix(bundler): re-read the step registry when rolling back a failed step refresh - #4139

Open
jawwad-ali wants to merge 2 commits into
github:mainfrom
jawwad-ali:fix/bundler-step-refresh-rollback
Open

fix(bundler): re-read the step registry when rolling back a failed step refresh#4139
jawwad-ali wants to merge 2 commits into
github:mainfrom
jawwad-ali:fix/bundler-step-refresh-rollback

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Problem

_StepKindManager.refresh documents its own contract:

Preserve an existing step until we've validated we can perform refresh. For already-installed steps, keep a backup and restore it if the remove+reinstall path fails.

The package half of that rollback works. The registry half is dead code:

except BundlerError:
    if backup_dir.exists():
        shutil.copytree(backup_dir, step_dir, dirs_exist_ok=True)
    if metadata is not None and not self._registry.is_installed(component.id):
        self._registry.add(component.id, metadata)
    raise

StepRegistry.__init__ snapshots the file once (self.data = self._load()) and is_installed reads only that snapshot.

Reproduction on current main (bf88c9f)

snapshot at construction:            is_installed('my-step') = True
after the entry is deleted on disk:  same object             = True   <-- stale
a fresh StepRegistry reads truth:                            = False

By the time the rollback runs, self.remove(component) has already deleted the entry from disk via workflow_step_remove — but self._registry's snapshot still contains it. So not self._registry.is_installed(...) is always False, and the restore never executes, in precisely the failure case it exists for.

Why it matters

After a failed specify bundle update, the user is left in a broken half-state: the step package is back on disk but unregistered.

  • specify workflow step list no longer shows it
  • the workflow engine cannot resolve the step type
  • a later specify workflow step add <id> refuses with "Step directory already exists"

…leaving them to clean up by hand.

Fix

Read the registry fresh at rollback time, so is_installed reflects what remove() actually did. The lazy import matches this class's own style — __init__ imports StepRegistry the same way.

No breaking change. Nothing on any success path is touched; only the already-failing rollback branch changes, and it changes from silently doing nothing to doing what it says.

Verification

  • Fail-before / pass-after: the new test fails on unpatched src (assert StepRegistry(tmp_path).is_installed("my-step") with the on-disk registry showing "steps": {}) and passes with the fix — 1 failed → 21 passed.
  • The test exercises the real removal path and fails only the re-install, which is what a catalog 404 / size-limit / type_key mismatch produces.
  • Scoped regression over tests/unit: no new failures vs a clean-main baseline captured on bf88c9f9.
  • uvx ruff@0.15.0 check src tests → clean

Written with assistance from Claude Code. Bug found, reproduced, and verified by me on current main.

…fresh

`_StepKindManager.refresh` documents that it keeps a backup and restores it
"if the remove+reinstall path fails". The package half of that rollback
works; the registry half was unreachable.

`StepRegistry.__init__` snapshots the file once (`self.data = self._load()`)
and `is_installed` consults only that snapshot. Measured:

  snapshot at construction:          is_installed('my-step') = True
  after the entry is deleted on disk: same object            = True   <-- stale
  a fresh StepRegistry:                                      = False

By rollback time `self.remove()` has already deleted the entry from disk,
but `self._registry`'s snapshot still contains it — so
`not self._registry.is_installed(...)` was always False and the restore
never ran, in exactly the failure case it was written for.

The user was left with the step package back on disk but unregistered:
`workflow step list` no longer shows it, the engine cannot resolve it, and
a later `workflow step add <id>` refuses with "Step directory already
exists".

Read the registry fresh at rollback time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jawwad-ali
jawwad-ali requested a review from mnriem as a code owner August 15, 2026 14:17
@mnriem
mnriem requested a balanced review from Copilot August 20, 2026 17:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes failed step-refresh rollback by reloading the on-disk registry.

Changes:

  • Re-reads the registry before restoring an entry.
  • Adds regression coverage for failed reinstalls.
Show a summary per file
File Description
src/specify_cli/bundler/services/primitives.py Restores registry state during rollback.
tests/unit/test_bundler_primitives.py Tests failed-refresh registry restoration.

Review details

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/specify_cli/bundler/services/primitives.py Outdated
…lback

Addresses review feedback: the rollback used `StepRegistry.add()`, which does
not restore the saved metadata verbatim.

The rollback deliberately constructs a *fresh* `StepRegistry` after
`self.remove()` has deleted the entry from disk (that re-read is this PR's
actual fix). `add()` therefore finds no existing record:

    metadata_to_store["installed_at"] = existing.get(
        "installed_at", datetime.now(timezone.utc).isoformat()
    )
    metadata_to_store["updated_at"] = datetime.now(timezone.utc).isoformat()

`existing` is `{}`, so `installed_at` falls through to `now`, and `updated_at`
is overwritten unconditionally. A failed refresh still mutated the
installation metadata instead of rolling it back:

    seeded      : installed_at 2020-01-01..., updated_at 2020-02-02...
    after add() : installed_at 2026-08-27..., updated_at 2026-08-27...
    verbatim via add()          -> False
    verbatim via direct restore -> True

Restore the entry directly and save, matching the existing rollback in
`workflow_step_remove`, whose comment names this very hazard: "Restore the
original registry entry verbatim (bypass add() which would overwrite
timestamps)."

The regression test now seeds distinctive past timestamps and asserts the
restored entry equals the seeded one, rather than only asserting presence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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