v0.288.0: the remove dialog and result name the household's userdata folders, which stay (decision 67, R-800)
gates / gates (push) Successful in 28s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-02 14:03:44 +02:00
parent 9821690980
commit 580b656914
116 changed files with 2099 additions and 5 deletions
+47 -4
View File
@@ -34,6 +34,9 @@ type DeleteResponse struct {
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
HDDPathsMissing []string `json:"hdd_paths_missing,omitempty"`
HDDNote string `json:"hdd_note,omitempty"`
// UserdataKept (`09` §3 decision 67, R-800): the household's own folders the app binds through ${USERDATA_PATH}
// that exist — never deleted by a remove; the page names them. Always non-nil.
UserdataKept []string `json:"userdata_kept"`
}
// RemoveResponse holds the result of removing a deployed (non-orphaned) stack. Same R-442 shape as
@@ -46,6 +49,8 @@ type RemoveResponse struct {
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
HDDPathsMissing []string `json:"hdd_paths_missing,omitempty"`
HDDNote string `json:"hdd_note,omitempty"`
// UserdataKept: as DeleteResponse.UserdataKept (decision 67, R-800).
UserdataKept []string `json:"userdata_kept"`
BackupPathsRemoved []string `json:"backup_paths_removed,omitempty"`
BackupPathsRefused []string `json:"backup_paths_refused,omitempty"`
// Verified says the teardown was CHECKED, not just requested (R-626/R-633). False means a
@@ -149,10 +154,12 @@ func (m *Manager) hddPathForRemoval(op, name, composePath string, removeHDDData
// hddNoteFor composes the one-sentence HDDNote (see DeleteResponse). Only when the data was asked
// for: a kept-data removal has nothing to explain about what was not found.
func hddNoteFor(removeHDDData bool, mounts, missing []string) string {
func hddNoteFor(removeHDDData bool, mounts, missing, userdataKept []string) string {
switch {
case !removeHDDData:
return ""
case len(mounts) == 0 && len(userdataKept) > 0:
return "" // R-800: the page names the household's kept folders (userdata_kept) — "no data on a drive" would be false
case len(mounts) == 0:
return noteNoDriveData
case len(missing) > 0:
@@ -161,6 +168,36 @@ func hddNoteFor(removeHDDData bool, mounts, missing []string) string {
return ""
}
// userdataKept lists the folders the app binds through ${USERDATA_PATH} that exist on its drive — the household's own
// files (books, videos, photos it sees in the file browser). No remove deletes them (`09` §3 decision 67, R-800): the
// removal reads ${HDD_PATH} binds only, and ProtectedHDDPaths guards the roots. This list exists so the dialog and the
// result can SAY so, by name. USERDATA_PATH is <HDD_PATH>/userdata (withUserdataPath at deploy). Pinned by
// TestRemoveStack_R800_UserdataKeptIsNamed.
func (m *Manager) userdataKept(name, composePath string) []HDDPath {
out := []HDDPath{}
hddPath, declared := m.appHDDPath(name)
if !declared {
return out
}
for _, p := range ParseComposeUserdataMounts(composePath, appbackup.UserdataDir(hddPath)) {
clean := filepath.Clean(p)
info, err := os.Stat(clean)
if err != nil || !info.IsDir() {
continue // never created (or gone): nothing of the household's is there to keep
}
out = append(out, HDDPath{Path: clean, Exists: true, SizeBytes: getDirSizeBytes(clean), SizeHuman: getDirSizeHuman(clean)})
}
return out
}
func userdataKeptStrings(ps []HDDPath) []string {
out := make([]string, 0, len(ps))
for _, p := range ps {
out = append(out, fmt.Sprintf("%s (%s)", p.Path, p.SizeHuman))
}
return out
}
// BackupDataResponse holds information about backup data associated with a stack.
type BackupDataResponse struct {
Stack string `json:"stack"`
@@ -173,6 +210,9 @@ type HDDDataResponse struct {
Stack string `json:"stack"`
HDDPaths []HDDPath `json:"hdd_paths"`
HasHDDData bool `json:"has_hdd_data"`
// UserdataKept (`09` §3 decision 67, R-800): the household's own folders the app binds through ${USERDATA_PATH}
// that exist. A remove never deletes them, "with data" included; the dialog says so and names them. Always non-nil.
UserdataKept []HDDPath `json:"userdata_kept"`
// KeepDataOnly (v0.269.0, `09` §3 decision 27, R-666): the app is held and its page says support is
// informed — the remove dialog offers only "remove the app, keep my data", and the API refuses the rest.
KeepDataOnly bool `json:"keep_data_only,omitempty"`
@@ -274,6 +314,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
Deleted: name,
HDDPathsRemoved: []string{},
HDDPathsPreserved: []string{},
UserdataKept: userdataKeptStrings(m.userdataKept(name, stack.ComposePath)),
}
// Step 1: Parse compose file for HDD bind mounts
@@ -346,7 +387,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
}
}
resp.HDDNote = hddNoteFor(removeHDDData, hddMounts, resp.HDDPathsMissing)
resp.HDDNote = hddNoteFor(removeHDDData, hddMounts, resp.HDDPathsMissing, resp.UserdataKept)
// Step 5: Remove stack directory
if m.isDebug() {
@@ -382,7 +423,8 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
// R-442: the app's own recorded HDD_PATH, not the global config (which no box sets).
hddPath, declared := m.appHDDPath(name)
resp := &HDDDataResponse{
Stack: name,
Stack: name,
UserdataKept: m.userdataKept(name, stack.ComposePath),
}
if !declared {
@@ -608,6 +650,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
Removed: name,
HDDPathsRemoved: []string{},
HDDPathsPreserved: []string{},
UserdataKept: userdataKeptStrings(m.userdataKept(name, stack.ComposePath)),
}
// Step 1: Parse compose file for HDD bind mounts
@@ -699,7 +742,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
}
}
resp.HDDNote = hddNoteFor(removeHDDData, hddMounts, resp.HDDPathsMissing)
resp.HDDNote = hddNoteFor(removeHDDData, hddMounts, resp.HDDPathsMissing, resp.UserdataKept)
// Step 5: Handle backup data cleanup. Model A: backups/ sits directly under the app's felhom-data
// namespace root. R-442: that root is resolved by the SAME rule as the data half and as the