Skip to content

Commit a48b923

Browse files
authored
fix(task-persistence): use pure-JS tar fallback for Windows task history backup (#1354)
On Windows machines without tar.exe the PowerShell Compress-Archive fallback only supports .zip and failed when writing .tar.gz backups (NotSupportedArchiveFileExtension). Use the pure-JS 'tar' package to always produce a real .tar.gz, fall back to tar.x on restore, and decode GBK stderr from Windows commands.
1 parent d947265 commit a48b923

5 files changed

Lines changed: 306 additions & 25 deletions

File tree

.changeset/silly-pandas-backup.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"zgsm": patch
3+
---
4+
5+
fix(task-persistence): use pure-JS `tar` package as Windows fallback for task history backup/restore
6+
7+
On Windows machines without `tar.exe`, the previous PowerShell `Compress-Archive`
8+
fallback only supports `.zip` and failed when writing a `.tar.gz` backup
9+
(`NotSupportedArchiveFileExtension`). The fallback now uses the pure-JS `tar`
10+
package to produce a real `.tar.gz` on every platform, and the restore path falls
11+
back to `tar.x` when the system `tar` is unavailable. GBK-encoded stderr from
12+
Windows commands is also decoded (via iconv-lite) so error messages are readable.

pnpm-lock.yaml

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// 针对 Windows 平台的回退逻辑测试:当系统 tar.exe 不可用时,
2+
// 使用纯 JS `tar` 包兜底生成/解压 .tar.gz(避免 PowerShell Compress-Archive
3+
// 只支持 .zip 导致备份失败的问题)。
4+
// 运行:cd src && npx vitest run core/task-persistence/__tests__/backupRestore.windows.spec.ts
5+
6+
import * as fs from "fs/promises"
7+
import * as os from "os"
8+
import * as path from "path"
9+
import { execFileSync } from "child_process"
10+
import iconv from "iconv-lite"
11+
12+
import type { HistoryItem } from "@roo-code/types"
13+
14+
import { createTasksBackup, formatCommandError, restoreTasksBackup, type RestoreResult } from "../backupRestore"
15+
import { GlobalFileNames } from "../../../shared/globalFileNames"
16+
17+
// ─────────────────────────── Module-level mocks ───────────────────────────
18+
// 模拟一台没有 tar.exe 的 Windows 机器:child_process.execFile 对 `tar` 命令
19+
// 一律失败(并携带 GBK 编码的 stderr),`xcopy` 允许成功以覆盖 restore 路径。
20+
// 在模块加载时把 process.platform 置为 win32,使 backupRestore.ts 进入
21+
// Windows 分支(isWindows 在模块顶层求值)。
22+
vi.mock("child_process", async (importOriginal) => {
23+
const actual = await importOriginal<typeof import("child_process")>()
24+
const realFs = await import("fs/promises")
25+
26+
Object.defineProperty(process, "platform", { value: "win32" })
27+
28+
const execFileMock = vi.fn(
29+
(
30+
cmd: string,
31+
args: string[],
32+
_opts: unknown,
33+
cb: (err: Error | null, stdout?: Buffer, stderr?: Buffer) => void,
34+
) => {
35+
if (cmd === "tar") {
36+
const err = Object.assign(new Error(`Command failed: ${cmd} is not available`), {
37+
code: "ENOENT",
38+
stderr: iconv.encode("tar.exe 不是可用的命令", "gbk"),
39+
stdout: Buffer.alloc(0),
40+
})
41+
cb(err)
42+
return
43+
}
44+
if (cmd === "xcopy") {
45+
// Simulate `xcopy /E /I /Y` with a real recursive copy so that
46+
// restore actually produces files in the destination.
47+
const [src, dest] = args
48+
realFs.cp(src, dest, { recursive: true }).then(
49+
() => cb(null, Buffer.alloc(0), Buffer.alloc(0)),
50+
(err) => cb(err instanceof Error ? err : new Error(String(err)), Buffer.alloc(0), Buffer.alloc(0)),
51+
)
52+
return
53+
}
54+
const err = Object.assign(new Error(`Unexpected command: ${cmd}`), {
55+
stderr: Buffer.alloc(0),
56+
stdout: Buffer.alloc(0),
57+
})
58+
cb(err)
59+
},
60+
) as unknown as typeof actual.execFile
61+
62+
return { ...actual, execFile: execFileMock }
63+
})
64+
65+
// ─────────────────────────── Helpers ───────────────────────────
66+
67+
function makeHistoryItem(id: string, ts?: number): HistoryItem {
68+
return {
69+
id,
70+
number: 1,
71+
ts: ts ?? Date.now(),
72+
task: `Task ${id}`,
73+
tokensIn: 100,
74+
tokensOut: 50,
75+
totalCost: 0.001,
76+
workspace: "/test/workspace",
77+
}
78+
}
79+
80+
async function createTaskDirectory(tasksDir: string, item: HistoryItem): Promise<void> {
81+
const taskDir = path.join(tasksDir, item.id)
82+
await fs.mkdir(taskDir, { recursive: true })
83+
await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(item), "utf8")
84+
await fs.writeFile(path.join(taskDir, GlobalFileNames.apiConversationHistory), "[]", "utf8")
85+
await fs.writeFile(path.join(taskDir, GlobalFileNames.uiMessages), "[]", "utf8")
86+
}
87+
88+
interface HistoryIndex {
89+
version: number
90+
updatedAt: number
91+
entries: HistoryItem[]
92+
}
93+
94+
async function writeIndex(tasksDir: string, entries: HistoryItem[]): Promise<void> {
95+
const index: HistoryIndex = { version: 1, updatedAt: Date.now(), entries }
96+
await fs.writeFile(path.join(tasksDir, GlobalFileNames.historyIndex), JSON.stringify(index), "utf8")
97+
}
98+
99+
// ─────────────────────────── Tests ───────────────────────────
100+
101+
describe("createTasksBackup (Windows without system tar)", () => {
102+
it("falls back to the pure-JS tar package and produces a valid .tar.gz", async () => {
103+
const basePath = await fs.mkdtemp(path.join(os.tmpdir(), "win-bk-src-"))
104+
const destDir = await fs.mkdtemp(path.join(os.tmpdir(), "win-bk-dst-"))
105+
try {
106+
const tasksDir = path.join(basePath, "tasks")
107+
const item = makeHistoryItem("win-task-001", 1_000_000)
108+
await createTaskDirectory(tasksDir, item)
109+
await writeIndex(tasksDir, [item])
110+
111+
const destPath = path.join(destDir, "backup.tar.gz")
112+
await createTasksBackup(basePath, destPath)
113+
114+
// Archive exists and is non-empty
115+
const stat = await fs.stat(destPath)
116+
expect(stat.size).toBeGreaterThan(0)
117+
118+
// It is a real .tar.gz: the system tar can list its contents
119+
const stdout = execFileSync("tar", ["-tzf", destPath], { encoding: "utf8" }) as string
120+
expect(stdout).toContain("tasks/")
121+
expect(stdout).toContain("win-task-001/")
122+
expect(stdout).toContain(GlobalFileNames.historyItem)
123+
} finally {
124+
await fs.rm(basePath, { recursive: true, force: true }).catch(() => {})
125+
await fs.rm(destDir, { recursive: true, force: true }).catch(() => {})
126+
}
127+
})
128+
})
129+
130+
describe("restoreTasksBackup (Windows without system tar)", () => {
131+
it("restores via the pure-JS tar fallback", async () => {
132+
const srcBase = await fs.mkdtemp(path.join(os.tmpdir(), "win-rs-src-"))
133+
const dstBase = await fs.mkdtemp(path.join(os.tmpdir(), "win-rs-dst-"))
134+
try {
135+
const tasksDir = path.join(srcBase, "tasks")
136+
const item = makeHistoryItem("win-task-002", 2_000_000)
137+
await createTaskDirectory(tasksDir, item)
138+
await writeIndex(tasksDir, [item])
139+
140+
// createTasksBackup also takes the JS fallback on this platform
141+
const archivePath = path.join(srcBase, "backup.tar.gz")
142+
await createTasksBackup(srcBase, archivePath)
143+
144+
const result: RestoreResult = await restoreTasksBackup(dstBase, archivePath)
145+
expect(result.imported).toBe(1)
146+
expect(result.skipped).toBe(0)
147+
expect(result.errors).toHaveLength(0)
148+
149+
// Task directory was copied into the destination via xcopy
150+
const hiPath = path.join(dstBase, "tasks", "win-task-002", GlobalFileNames.historyItem)
151+
await expect(fs.readFile(hiPath, "utf8")).resolves.toContain("win-task-002")
152+
} finally {
153+
await fs.rm(srcBase, { recursive: true, force: true }).catch(() => {})
154+
await fs.rm(dstBase, { recursive: true, force: true }).catch(() => {})
155+
}
156+
})
157+
})
158+
159+
describe("formatCommandError", () => {
160+
it("decodes GBK-encoded stderr from Windows commands", () => {
161+
const err = Object.assign(new Error("Command failed: powershell"), {
162+
stderr: iconv.encode("不支持的存档文件格式", "gbk"),
163+
stdout: Buffer.alloc(0),
164+
})
165+
expect(formatCommandError(err)).toContain("不支持的存档文件格式")
166+
})
167+
168+
it("keeps UTF-8 stderr unchanged", () => {
169+
const err = Object.assign(new Error("Command failed: tar"), {
170+
stderr: Buffer.from("tar: unrecognized option"),
171+
stdout: Buffer.alloc(0),
172+
})
173+
expect(formatCommandError(err)).toContain("tar: unrecognized option")
174+
})
175+
176+
it("falls back to the message when stderr is not a buffer", () => {
177+
expect(formatCommandError(new Error("plain error"))).toBe("plain error")
178+
})
179+
})

0 commit comments

Comments
 (0)