diff --git a/docs/dev_control_center.md b/docs/dev_control_center.md index ea6d4966..20fd21ce 100644 --- a/docs/dev_control_center.md +++ b/docs/dev_control_center.md @@ -87,8 +87,12 @@ Progress update (2026-03-02): - `managed-load (optional: entry_id)` - `managed-exit` - service wrappers and tests added +- Nostr maintenance parity landed in TUI v2: + - `nostr-reset-sync-state` + - `nostr-fresh-namespace` + - service wrappers and tests added - Current next implementation target: - - explicit Nostr sync-state reset/fresh-namespace parity commands + - archived-only entry view/filter parity ## 6) Working Rule diff --git a/docs/tui_v2_legacy_parity_matrix.md b/docs/tui_v2_legacy_parity_matrix.md index 4d69de65..bcf27de3 100644 --- a/docs/tui_v2_legacy_parity_matrix.md +++ b/docs/tui_v2_legacy_parity_matrix.md @@ -52,18 +52,16 @@ Purpose: map legacy interactive workflows to Textual TUI v2 equivalents and isol | Backup/reveal parent seed | Settings | `parent-seed-backup [path] [password]` | Implemented | | | Display npub | Main utility flow | No direct dedicated command | Partial | Possible via entry reveal for nostr entry; direct utility-style command absent. | | View archived-only list | Settings/List flow | No dedicated archived-only pane/command | Gap | Archived state visible in list; archive-only view affordance missing. | -| Reset Nostr sync state | Settings > Nostr | No explicit command in v2 palette | Gap | Legacy has explicit reset/fresh namespace operations. | -| Start fresh Nostr namespace | Settings > Nostr | No explicit command in v2 palette | Gap | | +| Reset Nostr sync state | Settings > Nostr | `nostr-reset-sync-state` | Implemented | Added to TUI v2 palette + tests. | +| Start fresh Nostr namespace | Settings > Nostr | `nostr-fresh-namespace` | Implemented | Added to TUI v2 palette + tests. | | Load managed-account session | Retrieve managed account action | `managed-load (optional: entry_id)` | Implemented | Added in TUI v2 palette + tests. | | Exit managed-account session | Main loop when in child profile | `managed-exit` | Implemented | Added in TUI v2 palette + tests. | ## Prioritized Gap Queue -1. Medium: explicit Nostr maintenance actions (`reset sync state`, `fresh namespace`) in TUI v2 palette. -2. Medium: archived-only list/view affordance parity. -3. Low: dedicated utility-style npub display command in v2. +1. Medium: archived-only list/view affordance parity. +2. Low: dedicated utility-style npub display command in v2. ## Suggested implementation order -1. Nostr sync-state reset and fresh-namespace palette commands + tests. -2. Archived-only view filter/command + tests. +1. Archived-only view filter/command + tests. diff --git a/memory-update.md b/memory-update.md index 675b2b94..179712b9 100644 --- a/memory-update.md +++ b/memory-update.md @@ -598,3 +598,22 @@ - `pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py src/tests/test_core_api_services.py` - Result: `86 passed`. - Updated matrix priorities: next parity target is explicit Nostr maintenance commands (`reset sync state`, `fresh namespace`). + +## 2026-03-02 Next gap landed: Nostr maintenance parity in TUI v2 +- Implemented two legacy Nostr maintenance equivalents in TUI v2 palette: + - `nostr-reset-sync-state` + - `nostr-fresh-namespace` +- Added service-layer methods in `src/seedpass/core/api.py` (`NostrService`): + - `reset_sync_state()` + - `start_fresh_namespace()` + - includes runtime sync-state clearing and account index management. +- Added/updated tests: + - `src/tests/test_tui_v2_textual_interactions.py` (happy path + unavailable/usage validation) + - `src/tests/test_tui_v2_action_matrix.py` (command matrix coverage) + - `src/tests/test_core_api_services.py` (service method behavior) +- Validation: + - `pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py src/tests/test_core_api_services.py` + - Result: `88 passed`. +- Updated planning docs: + - `docs/tui_v2_legacy_parity_matrix.md` marks Nostr maintenance parity as implemented. + - `docs/dev_control_center.md` now points to archived-only view/filter as the next parity target. diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 84fee86b..63026b8a 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -917,3 +917,53 @@ class NostrService: self._manager.state_manager._save(state) self._manager.nostr_client.relays = list(relays) return relays + + def _clear_runtime_sync_state(self) -> None: + self._manager.manifest_id = None + self._manager.delta_since = 0 + self._manager.last_sync_ts = 0 + client = getattr(self._manager, "nostr_client", None) + if client is None: + return + if hasattr(client, "last_error"): + client.last_error = None + if hasattr(client, "current_manifest_id"): + client.current_manifest_id = None + if hasattr(client, "current_manifest"): + client.current_manifest = None + if hasattr(client, "_delta_events"): + client._delta_events = [] + + def reset_sync_state(self) -> int: + """Reset manifest/delta sync metadata for the active profile.""" + with self._lock: + state_mgr = getattr(self._manager, "state_manager", None) + if state_mgr is None: + raise ValueError("State manager unavailable for current profile.") + state = getattr(state_mgr, "state", {}) or {} + state_mgr.update_state(manifest_id=None, delta_since=0, last_sync_ts=0) + self._clear_runtime_sync_state() + idx = int(state.get("nostr_account_idx", 0)) + self._manager.nostr_account_idx = idx + return idx + + def start_fresh_namespace(self) -> int: + """Advance deterministic Nostr account index and reset sync metadata.""" + with self._lock: + state_mgr = getattr(self._manager, "state_manager", None) + if state_mgr is None: + raise ValueError("State manager unavailable for current profile.") + state = getattr(state_mgr, "state", {}) or {} + next_idx = int(state.get("nostr_account_idx", 0)) + 1 + state_mgr.update_state( + manifest_id=None, + delta_since=0, + last_sync_ts=0, + nostr_account_idx=next_idx, + ) + self._clear_runtime_sync_state() + self._manager.nostr_account_idx = next_idx + reinit = getattr(self._manager, "_initialize_nostr_client", None) + if callable(reinit): + reinit() + return next_idx diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 6cebba4f..4bb8b04f 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -298,7 +298,8 @@ def launch_tui2( "setting-quick-unlock | setting-timeout | " "setting-kdf-iterations | setting-kdf-mode | " "relay-list | relay-add | relay-rm | " - "relay-reset | sync-now | sync-bg | " + "relay-reset | nostr-reset-sync-state | nostr-fresh-namespace | " + "sync-now | sync-bg | " "checksum-verify | checksum-update | db-export | db-import | " "totp-export | parent-seed-backup [path] [password] | " "managed-load [entry_id] | managed-exit | " @@ -617,6 +618,7 @@ def launch_tui2( "- docs: edit/save/export", "- 6 toggle dedicated 2FA board", "- profile/relay/sync/settings", + "- nostr reset/fresh namespace", "- checksum/db/totp/seed backup", "- managed-load/managed-exit session", "- l cycle link relation", @@ -1384,7 +1386,9 @@ def launch_tui2( "profiles-list, profile-switch, profile-add, profile-remove, profile-rename, " "setting-secret, setting-offline, setting-quick-unlock, setting-timeout, " "setting-kdf-iterations, setting-kdf-mode, " - "relay-list, relay-add, relay-rm, relay-reset, sync-now, sync-bg, " + "relay-list, relay-add, relay-rm, relay-reset, " + "nostr-reset-sync-state, nostr-fresh-namespace, " + "sync-now, sync-bg, " "checksum-verify, checksum-update, db-export, db-import, " "totp-export, parent-seed-backup, managed-load, managed-exit, " "reveal [confirm], qr [public|private] [confirm], link-filter, " @@ -2555,6 +2559,60 @@ def launch_tui2( ) return + if cmd == "nostr-reset-sync-state": + if self._nostr_service is None: + self._set_status("Nostr service unavailable") + return + if args: + self._set_status("Usage: nostr-reset-sync-state") + return + reset_sync = getattr(self._nostr_service, "reset_sync_state", None) + if not callable(reset_sync): + self._set_status("Nostr service does not support sync-state reset") + return + try: + current_idx = int(reset_sync()) + self._clear_failure() + self._set_status( + f"Nostr sync state reset (account index {current_idx})" + ) + except Exception as exc: + self._record_failure( + "nostr-reset-sync-state failed", + exc, + retry=lambda: self._run_palette_command(raw), + hint="Press 'x' to retry.", + ) + return + + if cmd == "nostr-fresh-namespace": + if self._nostr_service is None: + self._set_status("Nostr service unavailable") + return + if args: + self._set_status("Usage: nostr-fresh-namespace") + return + fresh_ns = getattr(self._nostr_service, "start_fresh_namespace", None) + if not callable(fresh_ns): + self._set_status( + "Nostr service does not support fresh namespace rotation" + ) + return + try: + next_idx = int(fresh_ns()) + self._clear_failure() + self._set_status( + f"Started fresh Nostr namespace at account index {next_idx}" + ) + except Exception as exc: + self._record_failure( + "nostr-fresh-namespace failed", + exc, + retry=lambda: self._run_palette_command(raw), + hint="Press 'x' to retry.", + ) + return + if cmd == "sync-now": if self._sync_service is None: self._set_status("Sync service unavailable") diff --git a/src/tests/test_core_api_services.py b/src/tests/test_core_api_services.py index 1f12f0ad..9e095661 100644 --- a/src/tests/test_core_api_services.py +++ b/src/tests/test_core_api_services.py @@ -597,3 +597,31 @@ class TestNostrService: mock_manager.state_manager.remove_relay.assert_called_once_with(0) assert mock_manager.nostr_client.relays == [] + + def test_reset_sync_state(self, service, mock_manager): + mock_manager.state_manager.state = {"nostr_account_idx": 3} + idx = service.reset_sync_state() + + mock_manager.state_manager.update_state.assert_called_once_with( + manifest_id=None, delta_since=0, last_sync_ts=0 + ) + assert idx == 3 + assert mock_manager.nostr_account_idx == 3 + assert mock_manager.manifest_id is None + assert mock_manager.delta_since == 0 + assert mock_manager.last_sync_ts == 0 + + def test_start_fresh_namespace(self, service, mock_manager): + mock_manager.state_manager.state = {"nostr_account_idx": 4} + + next_idx = service.start_fresh_namespace() + + mock_manager.state_manager.update_state.assert_called_once_with( + manifest_id=None, + delta_since=0, + last_sync_ts=0, + nostr_account_idx=5, + ) + mock_manager._initialize_nostr_client.assert_called_once() + assert next_idx == 5 + assert mock_manager.nostr_account_idx == 5 diff --git a/src/tests/test_tui_v2_action_matrix.py b/src/tests/test_tui_v2_action_matrix.py index a001a1ab..a5b9cf11 100644 --- a/src/tests/test_tui_v2_action_matrix.py +++ b/src/tests/test_tui_v2_action_matrix.py @@ -543,6 +543,8 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None: app._run_palette_command("relay-add wss://relay.example") app._run_palette_command("relay-rm 1") app._run_palette_command("relay-reset") + app._run_palette_command("nostr-reset-sync-state") + app._run_palette_command("nostr-fresh-namespace") app._run_palette_command("sync-now") app._run_palette_command("sync-bg") app._run_palette_command("checksum-verify") diff --git a/src/tests/test_tui_v2_textual_interactions.py b/src/tests/test_tui_v2_textual_interactions.py index 7c3e1274..114e9862 100644 --- a/src/tests/test_tui_v2_textual_interactions.py +++ b/src/tests/test_tui_v2_textual_interactions.py @@ -444,6 +444,7 @@ class FakeConfigService: class FakeNostrService: def __init__(self, relays: list[str]) -> None: self.relays = list(relays) + self.account_idx = 0 def list_relays(self) -> list[str]: return list(self.relays) @@ -462,6 +463,13 @@ class FakeNostrService: self.relays = ["wss://default-1", "wss://default-2"] return list(self.relays) + def reset_sync_state(self) -> int: + return int(self.account_idx) + + def start_fresh_namespace(self) -> int: + self.account_idx += 1 + return int(self.account_idx) + class FakeSyncResult: def __init__(self, manifest_id: str) -> None: @@ -1369,6 +1377,17 @@ async def test_tui2_textual_profiles_and_settings_palette_commands() -> None: assert "Relays reset (2)" in _status_text(app) assert nostr.relays == ["wss://default-1", "wss://default-2"] + app._run_palette_command("nostr-reset-sync-state") + await pilot.pause() + assert "Nostr sync state reset (account index 0)" in _status_text(app) + + app._run_palette_command("nostr-fresh-namespace") + await pilot.pause() + assert ( + "Started fresh Nostr namespace at account index 1" in _status_text(app) + ) + assert nostr.account_idx == 1 + app._run_palette_command("sync-now") await pilot.pause() assert "Sync completed manifest manifest-1" in _status_text(app) @@ -1525,6 +1544,14 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None: await pilot.pause() assert "Nostr service unavailable" in _status_text(app) + app._run_palette_command("nostr-reset-sync-state") + await pilot.pause() + assert "Nostr service unavailable" in _status_text(app) + + app._run_palette_command("nostr-fresh-namespace") + await pilot.pause() + assert "Nostr service unavailable" in _status_text(app) + app._run_palette_command("sync-now") await pilot.pause() assert "Sync service unavailable" in _status_text(app) @@ -1576,6 +1603,14 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None: in _status_text(app) ) + app._run_palette_command("nostr-reset-sync-state now") + await pilot.pause() + assert "Usage: nostr-reset-sync-state" in _status_text(app) + + app._run_palette_command("nostr-fresh-namespace now") + await pilot.pause() + assert "Usage: nostr-fresh-namespace" in _status_text(app) + app._run_palette_command("managed-load 1 2") await pilot.pause() assert "Usage: managed-load (optional: entry_id)" in _status_text(app)