Skip to content

Commit d128bf1

Browse files
committed
docs: design spec for #1288 auto-backup
1 parent 976d501 commit d128bf1

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
# Auto-backup edited entities (closes #1288)
2+
3+
**Status:** approved 2026-05-21 (user override on spec-review-subagent and writing-plans gates — direct directive to execute to draft PR)
4+
**Issue:** [#1288](https://github.com/homeassistant-ai/ha-mcp/issues/1288)
5+
**Branch:** `1288-auto-backup` (worktree `worktree/1288-auto-backup/`)
6+
7+
## Goal
8+
9+
Capture per-entity backups before every write/destructive MCP tool call,
10+
expose them for LLM-driven and UI-driven inspection, restore, and delete.
11+
Works in every ha-mcp deployment mode (addon, Docker, uvx) with no custom
12+
component dependency and no addon manifest privilege change.
13+
14+
## Non-goals
15+
16+
- Full HA snapshot rollback — `ha_backup_create` / `ha_backup_restore` already cover that
17+
- Cross-entity transactional restore (each restore is one entity)
18+
- Preserving user comments / whitespace from source YAML files (API-derived format)
19+
20+
## Settings
21+
22+
| Name | Env var | Default | Purpose |
23+
|---|---|---|---|
24+
| `enable_auto_backup` | `ENABLE_AUTO_BACKUP` | `False` | Master toggle |
25+
| `auto_backup_throttle_minutes` | `AUTO_BACKUP_THROTTLE_MINUTES` | `0` | Per-entity throttle window; 0 = no throttle, backup every write |
26+
| `auto_backup_retain_per_entity` | `AUTO_BACKUP_RETAIN_PER_ENTITY` | `20` | Max snapshots kept per entity; oldest rotated out |
27+
| `auto_backup_dir` | `HAMCP_BACKUP_DIR` | `""` (auto) | Override backup directory |
28+
29+
Auto-default for `auto_backup_dir`:
30+
- Addon (`SUPERVISOR_TOKEN` set + `/data` exists): `/data/ha_mcp_backups/`
31+
- Else (Docker/uvx): `${XDG_DATA_HOME:-~/.local/share}/ha_mcp/backups/`
32+
33+
## Architecture
34+
35+
```
36+
write/destructive tool → @with_auto_backup → BackupManager.maybe_snapshot
37+
38+
├── DomainHandler.fetch (HA REST/WS)
39+
└── write yaml, rotate, return path
40+
41+
ha_manage_auto_backup tool ─┐
42+
/api/settings/backups ├──→ BackupManager (list/read/diff/restore/delete)
43+
Settings UI Backups tab ─┘
44+
```
45+
46+
**`BackupManager`** (single instance per server, cached on the client object) owns:
47+
- Backup dir resolution and creation
48+
- Per-entity-key `asyncio.Lock` map to serialize fetch+write
49+
- Per-entity-key `last_snapshot_ts` for throttle
50+
- Domain handler registry: `{domain: DomainHandler}`
51+
- File operations (list, read, diff, write, delete, rotate)
52+
53+
**`DomainHandler`** is a frozen dataclass per backup-domain:
54+
```python
55+
@dataclass(frozen=True)
56+
class DomainHandler:
57+
domain: str # "automation", "helper_input_boolean", "dashboard", ...
58+
fetch: Callable[[Client, str, dict], Awaitable[Any]] # (client, entity_id, tool_kwargs) -> current config
59+
restore: Callable[[Client, str, Any, dict], Awaitable[Any]] # (client, entity_id, config, restore_kwargs) -> result
60+
```
61+
62+
One handler per backed-up domain; helper types each get their own handler keyed `helper_<type>` so each helper type lists/restores independently.
63+
64+
## Write flow (the decorator)
65+
66+
```python
67+
@with_auto_backup(domain="automation", id_param="identifier")
68+
@mcp.tool(...)
69+
@log_tool_usage
70+
async def ha_config_set_automation(self, identifier: str, ...):
71+
...
72+
```
73+
74+
`id_param` names the kwarg that carries the entity ID. For helpers, the
75+
decorator takes `domain_fn` instead — a callable that computes the domain
76+
key from kwargs (so `helper_type="timer"` → domain `helper_timer`).
77+
78+
Decorator behavior (best-effort, never raises):
79+
1. Look up manager off `self._client._auto_backup_manager` (lazy-built on first use).
80+
2. If `enable_auto_backup` is False → call wrapped function and return.
81+
3. Resolve `entity_key = f"{domain}:{entity_id}"`. If entity ID is `None`/empty (a *create* call with no ID) → skip backup, call wrapped function.
82+
4. Inside per-key lock: check throttle; if elapsed, fetch + write + rotate.
83+
5. All exceptions from steps 3-4 are logged as WARNING and swallowed.
84+
6. Call wrapped function with original args.
85+
86+
**Why this shape:** the original tool body is fully decoupled from backup;
87+
removing the decorator should not change tool behavior. Backup is never
88+
on the critical path of the write.
89+
90+
## File format
91+
92+
```yaml
93+
# ha_mcp_backup
94+
schema_version: 1
95+
domain: automation
96+
entity_id: kitchen_lights
97+
captured: 2026-05-21T15:30:00+00:00
98+
tool: ha_config_set_automation
99+
config:
100+
alias: Kitchen lights
101+
trigger: ...
102+
action: ...
103+
```
104+
105+
Filename: `<domain>.<safe_entity_id>.<YYYYMMDD_HHMMSS>.yaml`. `safe_entity_id`
106+
replaces `os.sep` and any non-`[A-Za-z0-9._-]` character with `_`. Backup
107+
dir is flat (no per-domain subdirs) so glob retention is simple.
108+
109+
## Restore flow
110+
111+
User-facing entry points (both call `BackupManager.restore`):
112+
- LLM: `ha_manage_auto_backup(action="restore", name=...)`
113+
- UI: `POST /api/settings/backups/<name>/restore`
114+
115+
`BackupManager.restore(name)`:
116+
1. Load file, parse YAML, validate schema (version, required keys).
117+
2. Resolve `DomainHandler` from `domain` field; if missing → return RESTORE_FAILED with suggestions.
118+
3. Take a fresh *safety backup* of the entity's CURRENT state via the same path as the decorator (`maybe_snapshot` with throttle disabled). The safety backup is recorded so the user can undo the restore.
119+
4. Call `handler.restore(client, entity_id, config)` (which under the hood calls the same HA REST/WS endpoint the original `set_*` tool uses).
120+
5. Return `{success, entity_key, safety_backup, restored_from}`.
121+
122+
Restore is upsert: re-creates entities that were deleted between backup and restore. If HA rejects (validation error, etc.), error propagates with structured suggestions.
123+
124+
## List / view / diff / delete flow
125+
126+
Each operation is a thin method on `BackupManager`, exposed via:
127+
128+
| MCP tool | UI endpoint | UI element |
129+
|---|---|---|
130+
| `ha_manage_auto_backup(action="list", domain?, entity_id?, since?, limit?)` | `GET /api/settings/backups?...` | Backup list table with filters |
131+
| `ha_manage_auto_backup(action="view", name=...)` | `GET /api/settings/backups/<name>` | "View" button → modal showing YAML |
132+
| `ha_manage_auto_backup(action="diff", name=...)` | `GET /api/settings/backups/<name>/diff` | "Diff" button → modal with unified diff vs current state |
133+
| `ha_manage_auto_backup(action="restore", name=...)` | `POST /api/settings/backups/<name>/restore` | "Restore" button (confirmation modal) |
134+
| `ha_manage_auto_backup(action="delete", name=...)` *or* `(action="delete_bulk", domain?, entity_id?, older_than?)` | `DELETE /api/settings/backups/<name>`, `DELETE /api/settings/backups?...` | Per-row "Delete" + bulk "Delete all matching filters" |
135+
136+
**List item shape:**
137+
```json
138+
{
139+
"name": "automation.kitchen_lights.20260521_153000.yaml",
140+
"domain": "automation",
141+
"entity_id": "kitchen_lights",
142+
"captured": "2026-05-21T15:30:00+00:00",
143+
"tool": "ha_config_set_automation",
144+
"size": 412
145+
}
146+
```
147+
148+
**Diff:** `difflib.unified_diff` against the current entity's config (fetched via the same handler.fetch as backup), yielding text diff.
149+
150+
## Settings UI changes
151+
152+
`settings_ui.py` gains:
153+
- 5 new routes (list / view / diff / restore / delete + bulk delete)
154+
- A "Backups" tab in the existing `/settings` HTML page — list table with filter inputs (domain, entity, since), each row with View / Diff / Restore / Delete buttons, plus a "Bulk delete matching" action
155+
- A small JS section at the bottom of the existing inline script tag
156+
157+
## Tool surface (28 wrapped)
158+
159+
`ha_config_set_automation`, `ha_config_remove_automation`,
160+
`ha_config_set_script`, `ha_config_remove_script`,
161+
`ha_config_set_scene`, `ha_config_remove_scene`,
162+
`ha_config_set_helper` (one DomainHandler per helper_type, dispatched via `domain_fn`),
163+
`ha_config_set_dashboard`, `ha_config_delete_dashboard`,
164+
`ha_config_set_dashboard_resource`, `ha_config_delete_dashboard_resource`,
165+
`ha_config_set_label`, `ha_config_remove_label`,
166+
`ha_config_set_category`, `ha_config_remove_category`,
167+
`ha_config_set_group`, `ha_config_remove_group`,
168+
`ha_config_set_calendar_event`, `ha_config_remove_calendar_event`,
169+
`ha_set_zone`, `ha_remove_zone`,
170+
`ha_set_area_or_floor`, `ha_remove_area_or_floor`,
171+
`ha_set_todo_item`, `ha_remove_todo_item`,
172+
`ha_set_entity`,
173+
`ha_set_integration_enabled`, `ha_delete_helpers_integrations`.
174+
175+
**Explicitly NOT wrapped:** `ha_call_service`, `ha_call_event`, `ha_restart`, `ha_reload_core`, `ha_check_config`, `ha_eval_template`, `ha_delete_file`, `ha_remove_entity`, `ha_remove_device`, `ha_update_device`, `ha_install_mcp_tools`, `ha_hacs_*`, blueprint/import ops.
176+
177+
## Error handling
178+
179+
- Backup write errors: WARNING log, original write proceeds.
180+
- Restore errors: structured `RESTORE_FAILED` error response with suggestions.
181+
- Schema-version mismatch on restore: structured `BACKUP_INCOMPATIBLE` error.
182+
- Filesystem init errors (backup dir uncreatable at startup): single startup WARNING, manager runs with snapshots silently skipped until restart.
183+
- Concurrent writes to same entity: per-entity `asyncio.Lock` serializes; no duplicate snapshots within one window.
184+
185+
## Testing strategy
186+
187+
**Unit (`tests/src/unit/test_backup_manager.py`):**
188+
- Throttle math (boundary at `auto_backup_throttle_minutes=0` always snapshots; >0 enforces window)
189+
- Retention rotation (`auto_backup_retain_per_entity` enforced; oldest deleted)
190+
- Filename safety (path separators, special chars, unicode)
191+
- Diff output format
192+
- Schema version validation on restore
193+
- Best-effort error handling (mocked fetch raises → wrapped tool still runs)
194+
195+
**E2E (`tests/src/e2e/workflows/auto_backup/`):**
196+
One test file per backed-up domain (`test_automation.py`, `test_script.py`, ..., one per `DomainHandler`). Each test:
197+
1. Toggle on
198+
2. Create/exist entity
199+
3. Edit via the wrapped tool → backup file appears
200+
4. Edit again → another backup file appears (with throttle off) OR no new file (with throttle on, within window)
201+
5. Restore first backup via `ha_manage_auto_backup(action="restore", ...)` — entity returns to original state
202+
6. Verify a safety backup was created
203+
7. Delete via `ha_manage_auto_backup(action="delete", ...)` — file removed
204+
8. Toggle off → next edit produces no backup
205+
206+
## Files
207+
208+
**New:**
209+
- `src/ha_mcp/backup_manager.py`
210+
- `src/ha_mcp/tools/auto_backup.py` (decorator)
211+
- `src/ha_mcp/tools/tools_auto_backup.py` (`ha_manage_auto_backup` tool)
212+
- `tests/src/unit/test_backup_manager.py`
213+
- `tests/src/e2e/workflows/auto_backup/` (one test file per domain)
214+
215+
**Modified:**
216+
- `src/ha_mcp/config.py` (4 settings)
217+
- `src/ha_mcp/settings_ui.py` (5 routes + Backups tab)
218+
- `homeassistant-addon/config.yaml` + `homeassistant-addon-dev/config.yaml` (4 options + schema)
219+
- `homeassistant-addon-dev/translations/en.yaml` (4 translations)
220+
- 28 `tools_*.py` modules (one `@with_auto_backup(...)` line each above existing `@mcp.tool(...)`)

0 commit comments

Comments
 (0)