diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 0a21c72e..30cbdeda 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -95,8 +95,8 @@ refresh_cache_tree() { backup_skill_dir() { victim="$1" [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } - # Prune before creating this run's archive, so the backup the caller is about - # to depend on for rollback can never be a prune candidate. + # Prune before creating this run's archive; every stamp this run has already + # created is registered, so pruning can only remove earlier-run batches. prune_skill_backups stamp="$(date -u +%Y%m%d-%H%M%S)" name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" @@ -110,18 +110,34 @@ backup_skill_dir() { return 1 fi done + record_current_run_backup_stamp "$backup_root" mkdir -p "$backup_root" || return 1 mv "$victim" "$backup" || return 1 printf '%s\n' "$backup" } # prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup -# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / -# pruneSkillBackups. Glob order is lexicographic and the stamps are UTC -# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is -# reported and never fatal. +# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's +# skillBackupKeep / pruneSkillBackups. Stamp directories created by the +# current process are never pruned (mirroring Go's run-root registry and +# install.js's currentRunBackupRoots), so a migration that retires more than +# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and +# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: +# a removal failure is reported and never fatal. SKILL_BACKUP_KEEP=5 +# Stamp directories created by this run, recorded by backup_skill_dir so +# pruning can never delete a backup the running migration may still need to +# roll back to. +CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" +} + # is_skill_backup_stamp accepts only directory names DWS itself writes: UTC # YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in # the backup root is foreign (user data, unrelated tooling) and is preserved @@ -153,13 +169,15 @@ prune_skill_backups() { done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) - prune_seen=0 for prune_entry in "$prune_root"/*; do + [ "$prune_drop" -gt 0 ] || break [ -d "$prune_entry" ] || continue is_skill_backup_stamp "${prune_entry##*/}" || continue - prune_seen=$((prune_seen + 1)) - [ "$prune_seen" -le "$prune_drop" ] || break + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $prune_entry "*) continue ;; + esac rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + prune_drop=$((prune_drop - 1)) done } diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 93a3d392..bca8bb25 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -162,8 +162,8 @@ refresh_cache_tree() { backup_skill_dir() { victim="$1" [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } - # Prune before creating this run's archive, so the backup the caller is about - # to depend on for rollback can never be a prune candidate. + # Prune before creating this run's archive; every stamp this run has already + # created is registered, so pruning can only remove earlier-run batches. prune_skill_backups stamp="$(date -u +%Y%m%d-%H%M%S)" name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" @@ -179,18 +179,34 @@ backup_skill_dir() { return 1 fi done + record_current_run_backup_stamp "$backup_root" mkdir -p "$backup_root" || return 1 mv "$victim" "$backup" || return 1 printf '%s\n' "$backup" } # prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup -# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / -# pruneSkillBackups. Glob order is lexicographic and the stamps are UTC -# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is -# reported and never fatal. +# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's +# skillBackupKeep / pruneSkillBackups. Stamp directories created by the +# current process are never pruned (mirroring Go's run-root registry and +# install.js's currentRunBackupRoots), so a migration that retires more than +# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and +# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: +# a removal failure is reported and never fatal. SKILL_BACKUP_KEEP=5 +# Stamp directories created by this run, recorded by backup_skill_dir so +# pruning can never delete a backup the running migration may still need to +# roll back to. +CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" +} + # is_skill_backup_stamp accepts only directory names DWS itself writes: UTC # YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in # the backup root is foreign (user data, unrelated tooling) and is preserved @@ -222,13 +238,15 @@ prune_skill_backups() { done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) - prune_seen=0 for prune_entry in "$prune_root"/*; do + [ "$prune_drop" -gt 0 ] || break [ -d "$prune_entry" ] || continue is_skill_backup_stamp "${prune_entry##*/}" || continue - prune_seen=$((prune_seen + 1)) - [ "$prune_seen" -le "$prune_drop" ] || break + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $prune_entry "*) continue ;; + esac rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + prune_drop=$((prune_drop - 1)) done } diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index bd48c7a3..88c58ad4 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -67,6 +67,7 @@ backup_and_remove_skill_dir() { return 1 fi done + record_current_run_backup_stamp "$(dirname "$_bed_target")" mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir" return 1 @@ -80,13 +81,27 @@ backup_and_remove_skill_dir() { return 1 } -# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup -# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / -# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal. -# Only safe to call once every backup manifest of this run has been consumed, -# because a single transaction can span more than one timestamp. +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped +# backup directories from earlier runs under $HOME/.dws/skill-backups, +# matching Go's skillBackupKeep / pruneSkillBackups. Stamp directories created +# by the current process are never pruned (mirroring Go's run-root registry +# and install.js's currentRunBackupRoots), so a migration that retires more +# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal +# failure is reported, never fatal. DWS_SKILL_BACKUP_KEEP=5 +# Stamp directories created by this run, recorded by +# backup_and_remove_skill_dir so pruning can never delete a backup the +# running migration may still need to roll back to. +DWS_CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" +} + # is_skill_backup_stamp accepts only directory names DWS itself writes: UTC # YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in # the backup root is foreign (user data, unrelated tooling) and is preserved @@ -118,13 +133,15 @@ prune_skill_backups() { done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) - _psb_seen=0 for _psb_entry in "$_psb_root"/*; do + [ "$_psb_drop" -gt 0 ] || break [ -d "$_psb_entry" ] || continue is_skill_backup_stamp "${_psb_entry##*/}" || continue - _psb_seen=$((_psb_seen + 1)) - [ "$_psb_seen" -le "$_psb_drop" ] || break + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $_psb_entry "*) continue ;; + esac rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry" + _psb_drop=$((_psb_drop - 1)) done } diff --git a/scripts/install.sh b/scripts/install.sh index 0ca489e4..62d23fbd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -85,6 +85,7 @@ backup_and_remove_skill_dir() { return 1 fi done + record_current_run_backup_stamp "$(dirname "$_bed_target")" mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir" return 1 @@ -98,13 +99,27 @@ backup_and_remove_skill_dir() { return 1 } -# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup -# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / -# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal. -# Only safe to call once every backup manifest of this run has been consumed, -# because a single transaction can span more than one timestamp. +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped +# backup directories from earlier runs under $HOME/.dws/skill-backups, +# matching Go's skillBackupKeep / pruneSkillBackups. Stamp directories created +# by the current process are never pruned (mirroring Go's run-root registry +# and install.js's currentRunBackupRoots), so a migration that retires more +# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal +# failure is reported, never fatal. DWS_SKILL_BACKUP_KEEP=5 +# Stamp directories created by this run, recorded by +# backup_and_remove_skill_dir so pruning can never delete a backup the +# running migration may still need to roll back to. +DWS_CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" +} + # is_skill_backup_stamp accepts only directory names DWS itself writes: UTC # YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in # the backup root is foreign (user data, unrelated tooling) and is preserved @@ -136,13 +151,15 @@ prune_skill_backups() { done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) - _psb_seen=0 for _psb_entry in "$_psb_root"/*; do + [ "$_psb_drop" -gt 0 ] || break [ -d "$_psb_entry" ] || continue is_skill_backup_stamp "${_psb_entry##*/}" || continue - _psb_seen=$((_psb_seen + 1)) - [ "$_psb_seen" -le "$_psb_drop" ] || break + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $_psb_entry "*) continue ;; + esac rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry" + _psb_drop=$((_psb_drop - 1)) done } diff --git a/test/scripts/skill_backup_prune_test.go b/test/scripts/skill_backup_prune_test.go new file mode 100644 index 00000000..6defc75b --- /dev/null +++ b/test/scripts/skill_backup_prune_test.go @@ -0,0 +1,199 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// shellPruneScriptTargets are the standalone installers that embed their own +// copy of prune_skill_backups. Each must keep every backup stamp the running +// process created, mirroring Go's run-root registry and install.js's +// currentRunBackupRoots, so a migration retiring more than the keep limit +// stays reversible. +var shellPruneScriptTargets = []string{ + "install.sh", + "install-skills.sh", + "install-event.sh", + "install-devapp.sh", +} + +// extractShellFunction pulls a top-level `name() { ... }` definition out of a +// script so the shipped logic — not a copy — is what the test exercises. +func extractShellFunction(t *testing.T, scriptPath, name string) string { + t.Helper() + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("read %s: %v", scriptPath, err) + } + text := string(data) + start := strings.Index(text, "\n"+name+"() {") + if start < 0 { + t.Fatalf("function %s not found in %s", name, scriptPath) + } + start++ + end := strings.Index(text[start:], "\n}\n") + if end < 0 { + t.Fatalf("function %s has no top-level closing brace in %s", name, scriptPath) + } + return text[start : start+end+3] +} + +// runShellPruneScenario builds a sandbox HOME with the given pre-existing +// (foreign) stamps, registers the given stamps as created by the current run, +// runs the script's own prune_skill_backups, and returns the surviving stamp +// directories. +func runShellPruneScenario(t *testing.T, scriptPath string, foreignStamps, runStamps []string) []string { + t.Helper() + home := t.TempDir() + backupRoot := filepath.Join(home, ".dws", "skill-backups") + for _, stamp := range foreignStamps { + if err := os.MkdirAll(filepath.Join(backupRoot, stamp), 0o755); err != nil { + t.Fatal(err) + } + } + + var driver strings.Builder + driver.WriteString("#!/bin/sh\n") + driver.WriteString("say() { :; }\n") + // The keep limit is a script-level variable outside the extracted + // functions; set both spellings the four installers use. + driver.WriteString("DWS_SKILL_BACKUP_KEEP=5\n") + driver.WriteString("SKILL_BACKUP_KEEP=5\n") + driver.WriteString(extractShellFunction(t, scriptPath, "is_skill_backup_stamp")) + driver.WriteString("\n") + driver.WriteString(extractShellFunction(t, scriptPath, "record_current_run_backup_stamp")) + driver.WriteString("\n") + driver.WriteString(extractShellFunction(t, scriptPath, "prune_skill_backups")) + driver.WriteString("\n") + for _, stamp := range runStamps { + if err := os.MkdirAll(filepath.Join(backupRoot, stamp), 0o755); err != nil { + t.Fatal(err) + } + driver.WriteString("record_current_run_backup_stamp \"$HOME/.dws/skill-backups/" + stamp + "\"\n") + } + driver.WriteString("prune_skill_backups\n") + + driverPath := filepath.Join(t.TempDir(), "prune-driver.sh") + if err := os.WriteFile(driverPath, []byte(driver.String()), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command("sh", driverPath) + cmd.Env = append(os.Environ(), "HOME="+home) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("prune driver failed: %v\n%s", err, out) + } + + entries, err := os.ReadDir(backupRoot) + if err != nil { + t.Fatal(err) + } + var surviving []string + for _, entry := range entries { + if entry.IsDir() { + surviving = append(surviving, entry.Name()) + } + } + return surviving +} + +func assertStampsSurvive(t *testing.T, surviving []string, want ...string) { + t.Helper() + left := make(map[string]bool) + for _, stamp := range surviving { + left[stamp] = true + } + for _, stamp := range want { + if !left[stamp] { + t.Fatalf("stamp %s must survive pruning, surviving=%v", stamp, surviving) + } + delete(left, stamp) + } + for stamp := range left { + t.Fatalf("stamp %s must have been pruned, surviving=%v", stamp, surviving) + } +} + +// TestInstallShellPruneKeepsCurrentRunBackups pins the reversibility contract +// the changelog states for every install surface: pruning may only remove +// batches from earlier runs. The old shell logic deleted the oldest excess +// stamps regardless of origin, so a single migration retiring more than five +// batches silently destroyed its own rollback material. +func TestInstallShellPruneKeepsCurrentRunBackups(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("shell installer tests do not run on %s", runtime.GOOS) + } + for _, name := range shellPruneScriptTargets { + t.Run(name, func(t *testing.T) { + scriptPath := filepath.Join("..", "..", "scripts", name) + if _, err := os.Stat(scriptPath); err != nil { + t.Fatalf("stat %s: %v", scriptPath, err) + } + + t.Run("run that exceeds the keep limit loses nothing", func(t *testing.T) { + var runStamps []string + for i := 1; i <= 8; i++ { + runStamps = append(runStamps, stampName(i)) + } + surviving := runShellPruneScenario(t, scriptPath, nil, runStamps) + assertStampsSurvive(t, surviving, runStamps...) + }) + + t.Run("only earlier-run batches are pruned", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 6; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + runStamps := []string{stampName(1), stampName(2)} + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, runStamps) + // Total 8, keep 5: three oldest foreign batches go, the + // current run keeps both of its own. + assertStampsSurvive(t, surviving, + oldStampName(4), oldStampName(5), oldStampName(6), + stampName(1), stampName(2)) + }) + + t.Run("foreign-only history still prunes to the keep limit", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 7; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, nil) + assertStampsSurvive(t, surviving, + oldStampName(3), oldStampName(4), oldStampName(5), + oldStampName(6), oldStampName(7)) + }) + }) + } +} + +func stampName(i int) string { + return "20260817-0000" + twoDigits(i) +} + +func oldStampName(i int) string { + return "20200101-0000" + twoDigits(i) +} + +func twoDigits(i int) string { + if i < 10 { + return "0" + string(rune('0'+i)) + } + return string(rune('0'+i/10)) + string(rune('0'+i%10)) +}