Skip to content

fix: make StepRegistry atomic and raise on corruption - #3922

Open
Quratulain-bilal wants to merge 6 commits into
github:mainfrom
Quratulain-bilal:fix/step-registry-atomic-save
Open

Quratulain-bilal wants to merge 6 commits into
github:mainfrom
Quratulain-bilal:fix/step-registry-atomic-save

Conversation

@Quratulain-bilal

Copy link
Copy Markdown
Contributor

Problem

StepRegistry._load() silently returned empty defaults on corruption, causing installed steps to appear to vanish. save() wrote directly to the target file risking data loss.

Fix

_load() now raises OSError on symlinked paths and corrupted files. save() now uses atomic writes via mkstemp + os.replace.

…nt data loss

1. _load() now raises OSError on symlinked paths and corrupted files
   instead of silently returning empty defaults (matches WorkflowRegistry)
2. save() now writes to a temp file and atomically replaces, preventing
   data loss from truncated writes on crash

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

Makes StepRegistry fail closed on corruption and persist data atomically.

Changes:

  • Raises OSError for unsafe or corrupted registries.
  • Writes through a temporary file using os.replace.
  • Cleans up temporary files after failures.
Show a summary per file
File Description
src/specify_cli/workflows/catalog.py Hardens registry loading and saving.

Review details

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

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

Comment thread src/specify_cli/workflows/catalog.py Outdated
Comment thread src/specify_cli/workflows/catalog.py Outdated
Comment thread src/specify_cli/workflows/catalog.py Outdated

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please address Copilot feedback and fix test and lint errors

Verifies:
- StepRegistry raises OSError on symlinked registry files
- StepRegistry raises OSError on corrupted JSON
- StepRegistry.save() uses atomic write via mkstemp + os.replace

Addresses Copilot review feedback on github#3922.
…mic-save

Resolve conflicts by taking main's split of workflows/catalog.py: the
StepRegistry code in this branch belongs in workflows/step/catalog/_domain.py,
so main's workflows/catalog/_domain.py is taken as-is instead of carrying a
duplicate StepRegistry, and tests/test_workflows.py takes main's version with
the branch's new tests re-added on top.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)
Port the branch's StepRegistry hardening to where main now keeps the class
(workflows/step/catalog/_domain.py): _load raises OSError for a symlinked
path, an unreadable file, unparseable JSON, or a non-registry shape instead
of silently returning an empty registry, and save writes through a
temp file plus os.replace so a crash cannot leave a half-written registry.

The new constructor errors would otherwise surface as tracebacks, and the
step commands used to reach their directory-symlink check only because the
registry swallowed the fault. A shared _load_step_registry_or_exit helper now
prints a clean error, and add/remove resolve the steps base directory first so
the symlinked-directory message keeps its specific wording.

Five tests that pinned the silent-reset behaviour are updated to the fail-loud
contract, and the branch's three regression tests (symlink, corrupted JSON,
atomic save) come along.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)
…mic-save

Take main's refactor in the conflicted step files: the registry hardening in
this branch lives in workflows/step/catalog/_domain.py, while main has moved
the CLI-coupled logic into workflows/step/installer.py (resolve_steps_base_dir,
StepInstallError, the install transaction) and rewritten workflow_step_remove
around _remove_step_locked. tests/test_workflows.py merges with the branch's
registry tests kept on top of main's version.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)
StepRegistry._load returned an empty registry for a symlinked path, an
unreadable file, unparseable JSON, or a non-registry shape, so corruption
reached callers as a silently reset registry. It now raises OSError with the
registry path in the message; save() already writes atomically on main.

Because the constructor now raises, translate the error where it surfaces:
installer._load_step_registry maps OSError onto StepInstallError so the
existing command handlers keep printing a clean error, and the new
_helpers._load_step_registry_or_exit does the same for the step commands.
workflow_step_remove resolves the steps base directory before loading the
registry so the symlinked-directory message keeps its specific wording.

Tests: the three fail-loud regression tests (symlinked file, corrupted JSON,
atomic save) are kept, and five tests that pinned the silent-reset behaviour
now assert the raised OSError instead.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)

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.

Comment on lines +60 to +64
try:
return StepRegistry(project_root)
except OSError as exc:
cli.console.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}")
raise cli.typer.Exit(1) from exc
Comment on lines +176 to +179
try:
return StepRegistry(project_root)
except OSError as exc:
raise StepInstallError(str(exc)) from exc
Comment thread tests/test_workflows.py
Comment on lines +11449 to +11462
def test_save_uses_atomic_write(self, tmp_path):
from specify_cli.workflows.step.catalog import StepRegistry

registry = StepRegistry(tmp_path)
registry.add("test-step", {"type": "command", "config": {}})
registry.save()

registry_path = (
tmp_path / ".specify" / "workflows" / "steps" / StepRegistry.REGISTRY_FILE
)
assert registry_path.exists()
data = json.loads(registry_path.read_text(encoding="utf-8"))
assert "test-step" in data.get("steps", {})
assert not list(registry_path.parent.glob(".*.tmp"))
specify_dir = project_root / ".specify"

# Read installed custom steps from registry only — no dynamic imports
# Read installed custom steps from registry only �?" no dynamic imports
@mnriem

mnriem commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

This branch has not been deployed

No deployments
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