fix(skills): guard pruneSkillBackups against non-DWS directories
Restrict backup pruning to directories whose names match the DWS stamp format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in ~/.dws/skill-backups are now preserved. Also fixes Windows coverage test portability and covers the remaining macOS changed-code gap (retire warning loop in runUpgrade).
This commit is contained in:
@@ -512,6 +512,10 @@ jobs:
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
- name: Test npm installer smoke (prune, backup, publish)
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: node test/scripts/install_js_smoke.mjs
|
||||
|
||||
test-focused:
|
||||
name: "Test (focused: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
|
||||
+17
-3
@@ -163,9 +163,21 @@ const SKILL_BACKUP_KEEP = 5;
|
||||
// transaction still needs them to roll back.
|
||||
const currentRunBackupRoots = new Set();
|
||||
|
||||
// skillBackupStampPattern matches 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 must be
|
||||
// left untouched, so pruning is restricted to names it can prove DWS created.
|
||||
const skillBackupStampPattern = /^[0-9]{8}-[0-9]{6}(-[0-9]+)?$/;
|
||||
|
||||
function isSkillBackupStamp(name) {
|
||||
return skillBackupStampPattern.test(name);
|
||||
}
|
||||
|
||||
// pruneSkillBackups removes the oldest stamped backup directories once more
|
||||
// than SKILL_BACKUP_KEEP remain. Stamps sort lexicographically in
|
||||
// chronological order (`YYYYmmdd-HHMMSS`), so name order is age order.
|
||||
// than SKILL_BACKUP_KEEP remain. Only directories whose names match the DWS
|
||||
// backup stamp format are candidates; unknown directories are preserved.
|
||||
// Stamps sort lexicographically in chronological order (`YYYYmmdd-HHMMSS`),
|
||||
// so name order is age order.
|
||||
function pruneSkillBackups(homeDir) {
|
||||
const root = path.join(homeDir, ".dws", "skill-backups");
|
||||
let entries;
|
||||
@@ -176,7 +188,7 @@ function pruneSkillBackups(homeDir) {
|
||||
throw err;
|
||||
}
|
||||
const names = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.filter((entry) => entry.isDirectory() && isSkillBackupStamp(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => Buffer.from(a).compare(Buffer.from(b)));
|
||||
let excess = names.length - SKILL_BACKUP_KEEP;
|
||||
@@ -1401,6 +1413,8 @@ module.exports = {
|
||||
UPSTREAM_AGENTS,
|
||||
resolvedAgentTargets,
|
||||
agentTargetDetected,
|
||||
isSkillBackupStamp,
|
||||
pruneSkillBackups,
|
||||
backupAndRemoveSkillDir,
|
||||
copyPathLexicallySync,
|
||||
movePathRecoverablySync,
|
||||
|
||||
@@ -292,6 +292,12 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
|
||||
if stage == "install-failed-dir" {
|
||||
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil
|
||||
}
|
||||
if stage == "install-retire-warning" {
|
||||
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{
|
||||
{Dir: "/ok", Status: upgradepkg.SkillDirOK},
|
||||
{Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")},
|
||||
}}, nil
|
||||
}
|
||||
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil
|
||||
}
|
||||
}
|
||||
@@ -300,7 +306,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
|
||||
"ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both",
|
||||
"backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills",
|
||||
"extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir",
|
||||
"success", "success-no-skills",
|
||||
"success", "success-no-skills", "install-retire-warning",
|
||||
} {
|
||||
t.Run(stage, func(t *testing.T) {
|
||||
configure(stage)
|
||||
@@ -330,14 +336,14 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
|
||||
opts.skipSkills = true
|
||||
}
|
||||
err := runUpgrade(context.Background(), opts)
|
||||
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills"
|
||||
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning"
|
||||
if wantError && err == nil {
|
||||
t.Fatalf("stage %s succeeded", stage)
|
||||
}
|
||||
if !wantError && err != nil {
|
||||
t.Fatalf("stage %s failed: %v", stage, err)
|
||||
}
|
||||
if (stage == "success" || stage == "success-no-skills") && !rb.cleaned {
|
||||
if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned {
|
||||
t.Fatal("successful upgrade did not clean backups")
|
||||
}
|
||||
if stage == "success" {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -383,9 +384,22 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string)
|
||||
// backups are kept.
|
||||
const skillBackupKeep = 5
|
||||
|
||||
// skillBackupStampRegexp matches 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 must be
|
||||
// left untouched, so pruning is restricted to names it can prove DWS created.
|
||||
var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`)
|
||||
|
||||
// isSkillBackupStamp reports whether name is a DWS-created backup stamp.
|
||||
func isSkillBackupStamp(name string) bool {
|
||||
return skillBackupStampRegexp.MatchString(name)
|
||||
}
|
||||
|
||||
// pruneSkillBackups removes the oldest backup directories when more than
|
||||
// skillBackupKeep remain. Best-effort: a removal failure never aborts, but
|
||||
// pruning failures are reported so callers can warn the user.
|
||||
// skillBackupKeep remain. Only directories whose names match the DWS backup
|
||||
// stamp format are candidates; unknown directories are preserved. Best-effort:
|
||||
// a removal failure never aborts, but pruning failures are reported so
|
||||
// callers can warn the user.
|
||||
func pruneSkillBackups(homeDir string) error {
|
||||
root := filepath.Join(homeDir, skillBackupSubdir)
|
||||
entries, err := upgradeReadDir(root)
|
||||
@@ -397,7 +411,7 @@ func pruneSkillBackups(homeDir string) error {
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
if e.IsDir() && isSkillBackupStamp(e.Name()) {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,6 +877,60 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
|
||||
testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll)
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that
|
||||
// pruneSkillBackups only retires directories whose names match the DWS backup
|
||||
// stamp format. Any foreign directory in the backup root must survive even when
|
||||
// more than skillBackupKeep stamped backups exist.
|
||||
func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
root := filepath.Join(home, skillBackupSubdir)
|
||||
for i := 0; i < skillBackupKeep+2; i++ {
|
||||
if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A collision-suffixed stamp is still a valid DWS backup and is eligible.
|
||||
if err := os.MkdirAll(filepath.Join(root, "20260910-000000-7"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unknown := []string{
|
||||
"user-personal-backup",
|
||||
"20260910-000000-abc", // stamp-shaped but non-numeric suffix
|
||||
"20260910-00000", // too short
|
||||
"notes",
|
||||
}
|
||||
for _, name := range unknown {
|
||||
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := pruneSkillBackups(home); err != nil {
|
||||
t.Fatalf("pruneSkillBackups() error = %v", err)
|
||||
}
|
||||
|
||||
for _, name := range unknown {
|
||||
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
|
||||
t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err)
|
||||
}
|
||||
}
|
||||
// Only stamped backups beyond skillBackupKeep are pruned; foreign entries
|
||||
// are excluded from the count, so the stamp total stays at skillBackupKeep.
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stamps := 0
|
||||
for _, e := range entries {
|
||||
if isSkillBackupStamp(e.Name()) {
|
||||
stamps++
|
||||
}
|
||||
}
|
||||
if stamps != skillBackupKeep {
|
||||
t.Errorf("stamped backups after prune = %d, want %d", stamps, skillBackupKeep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of
|
||||
// the mono-source resolver and the multi-bundle child lookup.
|
||||
func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
|
||||
|
||||
@@ -10,3 +10,7 @@ import (
|
||||
func testCrossDeviceError() error {
|
||||
return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV}
|
||||
}
|
||||
|
||||
func testNoReplaceUnsupportedErrors() []error {
|
||||
return []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ func testCrossDeviceError() error {
|
||||
return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE}
|
||||
}
|
||||
|
||||
func testNoReplaceUnsupportedErrors() []error {
|
||||
return []error{errNoReplaceRenameUnsupported}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) {
|
||||
if !isCrossDeviceError(testCrossDeviceError()) {
|
||||
t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback")
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
@@ -15,7 +14,7 @@ import (
|
||||
// Those installs must still publish, and must still refuse to clobber an
|
||||
// occupied destination.
|
||||
func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) {
|
||||
unsupported := []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported}
|
||||
unsupported := testNoReplaceUnsupportedErrors()
|
||||
|
||||
t.Run("publishes when the filesystem rejects the flag", func(t *testing.T) {
|
||||
for _, unsupportedErr := range unsupported {
|
||||
@@ -47,7 +46,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return syscall.EINVAL })
|
||||
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
|
||||
err := renameSkillPathNoReplace(source, destination)
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
t.Fatalf("occupied destination must report ErrExist, got %v", err)
|
||||
@@ -80,8 +79,9 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) {
|
||||
if isNoReplaceRenameUnsupported(syscall.EXDEV) {
|
||||
t.Fatal("EXDEV must not be treated as an unsupported flag")
|
||||
crossDevice := testCrossDeviceError()
|
||||
if isNoReplaceRenameUnsupported(crossDevice) {
|
||||
t.Fatal("cross-device error must not be treated as an unsupported flag")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source")
|
||||
@@ -95,7 +95,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) {
|
||||
original := skillPathRenameNoReplaceAtomic
|
||||
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error {
|
||||
if from == source {
|
||||
return syscall.EXDEV
|
||||
return crossDevice
|
||||
}
|
||||
return original(from, to)
|
||||
})
|
||||
|
||||
@@ -290,7 +290,11 @@ $script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]
|
||||
function Remove-OldDevSkillBackups {
|
||||
$root = Join-Path $HOME ".dws\skill-backups"
|
||||
if (!(Test-Path -LiteralPath $root -PathType Container)) { return }
|
||||
# Only directories whose names match the DWS backup stamp format (UTC
|
||||
# yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any
|
||||
# other entry is foreign data and is preserved.
|
||||
$dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } |
|
||||
Sort-Object -Property Name)
|
||||
$excess = $dirs.Count - $DevSkillBackupKeep
|
||||
foreach ($dir in $dirs) {
|
||||
|
||||
@@ -121,12 +121,34 @@ backup_skill_dir() {
|
||||
# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is
|
||||
# reported and never fatal.
|
||||
SKILL_BACKUP_KEEP=5
|
||||
|
||||
# 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
|
||||
# so pruning can never remove a directory it cannot prove DWS created.
|
||||
is_skill_backup_stamp() {
|
||||
case "$1" in
|
||||
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
|
||||
return 0 ;;
|
||||
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
|
||||
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
|
||||
# those 16 chars and require the remainder to be all digits.
|
||||
_isbs_suffix="${1#????????????????}"
|
||||
case "$_isbs_suffix" in
|
||||
""|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
prune_skill_backups() {
|
||||
prune_root="$HOME/.dws/skill-backups"
|
||||
[ -d "$prune_root" ] || return 0
|
||||
prune_total=0
|
||||
for prune_entry in "$prune_root"/*; do
|
||||
[ -d "$prune_entry" ] || continue
|
||||
is_skill_backup_stamp "${prune_entry##*/}" || continue
|
||||
prune_total=$((prune_total + 1))
|
||||
done
|
||||
[ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
|
||||
@@ -134,6 +156,7 @@ prune_skill_backups() {
|
||||
prune_seen=0
|
||||
for prune_entry in "$prune_root"/*; do
|
||||
[ -d "$prune_entry" ] || continue
|
||||
is_skill_backup_stamp "${prune_entry##*/}" || continue
|
||||
prune_seen=$((prune_seen + 1))
|
||||
[ "$prune_seen" -le "$prune_drop" ] || break
|
||||
rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
|
||||
|
||||
@@ -190,12 +190,34 @@ backup_skill_dir() {
|
||||
# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is
|
||||
# reported and never fatal.
|
||||
SKILL_BACKUP_KEEP=5
|
||||
|
||||
# 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
|
||||
# so pruning can never remove a directory it cannot prove DWS created.
|
||||
is_skill_backup_stamp() {
|
||||
case "$1" in
|
||||
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
|
||||
return 0 ;;
|
||||
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
|
||||
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
|
||||
# those 16 chars and require the remainder to be all digits.
|
||||
_isbs_suffix="${1#????????????????}"
|
||||
case "$_isbs_suffix" in
|
||||
""|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
prune_skill_backups() {
|
||||
prune_root="$HOME/.dws/skill-backups"
|
||||
[ -d "$prune_root" ] || return 0
|
||||
prune_total=0
|
||||
for prune_entry in "$prune_root"/*; do
|
||||
[ -d "$prune_entry" ] || continue
|
||||
is_skill_backup_stamp "${prune_entry##*/}" || continue
|
||||
prune_total=$((prune_total + 1))
|
||||
done
|
||||
[ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
|
||||
@@ -203,6 +225,7 @@ prune_skill_backups() {
|
||||
prune_seen=0
|
||||
for prune_entry in "$prune_root"/*; do
|
||||
[ -d "$prune_entry" ] || continue
|
||||
is_skill_backup_stamp "${prune_entry##*/}" || continue
|
||||
prune_seen=$((prune_seen + 1))
|
||||
[ "$prune_seen" -le "$prune_drop" ] || break
|
||||
rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
|
||||
|
||||
@@ -86,12 +86,34 @@ backup_and_remove_skill_dir() {
|
||||
# Only safe to call once every backup manifest of this run has been consumed,
|
||||
# because a single transaction can span more than one timestamp.
|
||||
DWS_SKILL_BACKUP_KEEP=5
|
||||
|
||||
# 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
|
||||
# so pruning can never remove a directory it cannot prove DWS created.
|
||||
is_skill_backup_stamp() {
|
||||
case "$1" in
|
||||
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
|
||||
return 0 ;;
|
||||
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
|
||||
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
|
||||
# those 16 chars and require the remainder to be all digits.
|
||||
_isbs_suffix="${1#????????????????}"
|
||||
case "$_isbs_suffix" in
|
||||
""|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
prune_skill_backups() {
|
||||
_psb_root="${HOME}/.dws/skill-backups"
|
||||
[ -d "$_psb_root" ] || return 0
|
||||
_psb_total=0
|
||||
for _psb_entry in "$_psb_root"/*; do
|
||||
[ -d "$_psb_entry" ] || continue
|
||||
is_skill_backup_stamp "${_psb_entry##*/}" || continue
|
||||
_psb_total=$((_psb_total + 1))
|
||||
done
|
||||
[ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0
|
||||
@@ -99,6 +121,7 @@ prune_skill_backups() {
|
||||
_psb_seen=0
|
||||
for _psb_entry in "$_psb_root"/*; do
|
||||
[ -d "$_psb_entry" ] || continue
|
||||
is_skill_backup_stamp "${_psb_entry##*/}" || continue
|
||||
_psb_seen=$((_psb_seen + 1))
|
||||
[ "$_psb_seen" -le "$_psb_drop" ] || break
|
||||
rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry"
|
||||
|
||||
@@ -744,7 +744,11 @@ $script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::
|
||||
function Remove-OldSkillBackups {
|
||||
$root = Join-Path $HOME ".dws\skill-backups"
|
||||
if (!(Test-Path -LiteralPath $root -PathType Container)) { return }
|
||||
# Only directories whose names match the DWS backup stamp format (UTC
|
||||
# yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any
|
||||
# other entry is foreign data and is preserved.
|
||||
$dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } |
|
||||
Sort-Object -Property Name)
|
||||
$excess = $dirs.Count - $SkillBackupKeep
|
||||
foreach ($dir in $dirs) {
|
||||
|
||||
@@ -104,12 +104,34 @@ backup_and_remove_skill_dir() {
|
||||
# Only safe to call once every backup manifest of this run has been consumed,
|
||||
# because a single transaction can span more than one timestamp.
|
||||
DWS_SKILL_BACKUP_KEEP=5
|
||||
|
||||
# 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
|
||||
# so pruning can never remove a directory it cannot prove DWS created.
|
||||
is_skill_backup_stamp() {
|
||||
case "$1" in
|
||||
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
|
||||
return 0 ;;
|
||||
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
|
||||
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
|
||||
# those 16 chars and require the remainder to be all digits.
|
||||
_isbs_suffix="${1#????????????????}"
|
||||
case "$_isbs_suffix" in
|
||||
""|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
prune_skill_backups() {
|
||||
_psb_root="${HOME}/.dws/skill-backups"
|
||||
[ -d "$_psb_root" ] || return 0
|
||||
_psb_total=0
|
||||
for _psb_entry in "$_psb_root"/*; do
|
||||
[ -d "$_psb_entry" ] || continue
|
||||
is_skill_backup_stamp "${_psb_entry##*/}" || continue
|
||||
_psb_total=$((_psb_total + 1))
|
||||
done
|
||||
[ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0
|
||||
@@ -117,6 +139,7 @@ prune_skill_backups() {
|
||||
_psb_seen=0
|
||||
for _psb_entry in "$_psb_root"/*; do
|
||||
[ -d "$_psb_entry" ] || continue
|
||||
is_skill_backup_stamp "${_psb_entry##*/}" || continue
|
||||
_psb_seen=$((_psb_seen + 1))
|
||||
[ "$_psb_seen" -le "$_psb_drop" ] || break
|
||||
rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry"
|
||||
|
||||
@@ -44,9 +44,10 @@
|
||||
* guard, exercised by swapping process.platform
|
||||
* instead of requiring a Windows host.
|
||||
* 14. backup retention — ~/.dws/skill-backups keeps the newest 5 stamps.
|
||||
* 15. multi publish failure — restores the complete previous Skill set.
|
||||
* 16. multi backup failure — restores every earlier successful backup.
|
||||
* 17. mono transaction failure — restores every managed multi Skill after
|
||||
* 15. unknown preservation — prune never removes non-DWS directories in skill-backups.
|
||||
* 16. multi publish failure — restores the complete previous Skill set.
|
||||
* 17. multi backup failure — restores every earlier successful backup.
|
||||
* 18. mono transaction failure — restores every managed multi Skill after
|
||||
* later backup or mono publish failure.
|
||||
*
|
||||
* Requirements: unix host with tar/zip/unzip on PATH (the same tools
|
||||
@@ -80,6 +81,8 @@ const {
|
||||
UPSTREAM_AGENTS,
|
||||
resolvedAgentTargets,
|
||||
agentTargetDetected,
|
||||
isSkillBackupStamp,
|
||||
pruneSkillBackups,
|
||||
backupAndRemoveSkillDir,
|
||||
movePathRecoverablySync,
|
||||
publishCacheAtomically,
|
||||
@@ -1003,6 +1006,49 @@ scenario("skill backups stay bounded to the newest five stamps", () => {
|
||||
}
|
||||
});
|
||||
|
||||
scenario("prune preserves unknown directories in skill-backups", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unknown-"));
|
||||
const home = path.join(tmp, "home");
|
||||
const backupRoot = path.join(home, ".dws", "skill-backups");
|
||||
try {
|
||||
for (const stamp of [
|
||||
"20260101-000001", "20260101-000002", "20260101-000003",
|
||||
"20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007",
|
||||
]) {
|
||||
writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`);
|
||||
}
|
||||
const unknowns = [
|
||||
"user-personal-backup",
|
||||
"20260101-00000", // too few digits in the time part
|
||||
"20260101-000000-abc", // non-numeric suffix
|
||||
"notes",
|
||||
"20260101", // missing time portion
|
||||
];
|
||||
for (const name of unknowns) {
|
||||
writeFile(path.join(backupRoot, name, "marker.txt"), `unknown:${name}\n`);
|
||||
}
|
||||
|
||||
pruneSkillBackups(home);
|
||||
|
||||
const remaining = fs.readdirSync(backupRoot).sort();
|
||||
const stamped = remaining.filter((n) => isSkillBackupStamp(n));
|
||||
assert.equal(stamped.length, 5, `stamped backups = ${stamped.length}, want 5`);
|
||||
assert.ok(!stamped.includes("20260101-000001"), "oldest stamps pruned first");
|
||||
assert.ok(!stamped.includes("20260101-000002"), "second-oldest pruned");
|
||||
assert.ok(stamped.includes("20260101-000005"), "newest kept stamps retained");
|
||||
for (const name of unknowns) {
|
||||
assert.ok(remaining.includes(name), `unknown directory ${name} must survive pruning`);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(backupRoot, name, "marker.txt"), "utf8"),
|
||||
`unknown:${name}\n`,
|
||||
`unknown directory ${name} contents must be intact`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("multi set publish failure restores the complete previous set", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-"));
|
||||
const home = path.join(tmp, "home");
|
||||
|
||||
Reference in New Issue
Block a user