fix(skills): never prune shell installers' current-run backups

The four standalone installers pruned the oldest excess stamp
directories regardless of origin, so a migration retiring more than
five batches destroyed its own rollback material mid-run — the same
data loss already fixed for Go via the run-root registry and present
in install.js/install.ps1 as currentRunBackupRoots. Every installer now
records the stamp directories it creates and pruning only removes
earlier-run batches, which is what the changelog already promises.
This commit is contained in:
玉澜 committed 2026-08-17 21:24:46 +08:00
1 parent 1d1aca5fd1
commit f7a3e606f7
5 files changed
+303 -34

No files matched your search

+27 -9
View File
@@ -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
}
+27 -9
View File
@@ -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
}
+25 -8
View File
@@ -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
}
+25 -8
View File
@@ -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
}
+199
View File
@@ -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))
}