Skip to content

Commit d2d1b1c

Browse files
committed
fix: harden Roo history import
1 parent c5f27f2 commit d2d1b1c

5 files changed

Lines changed: 187 additions & 44 deletions

File tree

src/core/task-persistence/__tests__/importRooTaskHistory.spec.ts

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ describe("importRooTaskHistory", () => {
146146
})
147147
})
148148

149-
it("skips Roo roots that resolve to the Zoo storage root and ignores hidden task entries", async () => {
149+
it("imports only top-level task history files and skips checkpoint directories", async () => {
150150
const zooGlobalStoragePath = path.join(tempRoot, "globalStorage", "zoocodeorganization.zoo-code")
151151
const rooDefaultStorageRoot = path.join(tempRoot, "globalStorage", "rooveterinaryinc.roo-cline")
152152
const zooCustomStorageRoot = path.join(tempRoot, "shared-storage")
@@ -156,35 +156,62 @@ describe("importRooTaskHistory", () => {
156156
zoo: zooCustomStorageRoot,
157157
})
158158

159-
await fs.mkdir(path.join(rooDefaultStorageRoot, "tasks", "task-visible", "nested"), { recursive: true })
159+
await fs.mkdir(path.join(rooDefaultStorageRoot, "tasks", "task-visible", "checkpoints", ".git", "objects"), {
160+
recursive: true,
161+
})
160162
await fs.mkdir(path.join(rooDefaultStorageRoot, "tasks", ".task-hidden"), { recursive: true })
161163
await fs.mkdir(path.join(rooDefaultStorageRoot, "tasks", "_task-hidden"), { recursive: true })
162-
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "task-visible", "history_item.json"), "visible")
163164
await fs.writeFile(
164-
path.join(rooDefaultStorageRoot, "tasks", "task-visible", "nested", "ui_messages.json"),
165-
"nested",
165+
path.join(rooDefaultStorageRoot, "tasks", "task-visible", "history_item.json"),
166+
JSON.stringify({ id: "task-visible" }),
167+
)
168+
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "task-visible", "ui_messages.json"), "visible-ui")
169+
await fs.writeFile(
170+
path.join(rooDefaultStorageRoot, "tasks", "task-visible", "api_conversation_history.json"),
171+
"visible-api",
166172
)
173+
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "task-visible", "task_metadata.json"), "metadata")
167174
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "loose.json"), "loose")
168-
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", ".task-hidden", "history_item.json"), "hidden")
169-
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "_task-hidden", "history_item.json"), "hidden")
175+
await fs.writeFile(
176+
path.join(rooDefaultStorageRoot, "tasks", "task-visible", "checkpoints", ".git", "objects", "object"),
177+
"git-object",
178+
)
179+
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", ".task-hidden", "history_item.json"), "hidden-dir")
180+
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "_task-hidden", "history_item.json"), "hidden-dir")
170181

171182
const result = await importRooTaskHistory(zooGlobalStoragePath)
172183

173184
expect(result.rooStorageRoots).toEqual([rooDefaultStorageRoot])
174185
expect(result.importedTaskCount).toBe(1)
175-
expect(result.importedFileCount).toBe(2)
186+
expect(result.importedFileCount).toBe(4)
187+
expect(
188+
await fs.readFile(path.join(zooCustomStorageRoot, "tasks", "task-visible", "ui_messages.json"), "utf8"),
189+
).toBe("visible-ui")
176190
expect(
177191
await fs.readFile(
178-
path.join(zooCustomStorageRoot, "tasks", "task-visible", "nested", "ui_messages.json"),
192+
path.join(zooCustomStorageRoot, "tasks", "task-visible", "api_conversation_history.json"),
179193
"utf8",
180194
),
181-
).toBe("nested")
195+
).toBe("visible-api")
196+
expect(
197+
await fs.readFile(path.join(zooCustomStorageRoot, "tasks", "task-visible", "task_metadata.json"), "utf8"),
198+
).toBe("metadata")
182199
await expect(fs.access(path.join(zooCustomStorageRoot, "tasks", ".task-hidden"))).rejects.toMatchObject({
183200
code: "ENOENT",
184201
})
185202
await expect(fs.access(path.join(zooCustomStorageRoot, "tasks", "_task-hidden"))).rejects.toMatchObject({
186203
code: "ENOENT",
187204
})
205+
await expect(
206+
fs.access(
207+
path.join(zooCustomStorageRoot, "tasks", "task-visible", "checkpoints", ".git", "objects", "object"),
208+
),
209+
).rejects.toMatchObject({
210+
code: "ENOENT",
211+
})
212+
await expect(fs.access(path.join(zooCustomStorageRoot, "tasks", "loose.json"))).rejects.toMatchObject({
213+
code: "ENOENT",
214+
})
188215
})
189216

190217
it("ignores missing Roo task roots while still importing from available roots", async () => {
@@ -207,6 +234,48 @@ describe("importRooTaskHistory", () => {
207234
).toBe("default")
208235
})
209236

237+
it("skips tasks that do not have an importable history_item.json", async () => {
238+
const zooGlobalStoragePath = path.join(tempRoot, "globalStorage", "zoocodeorganization.zoo-code")
239+
const rooDefaultStorageRoot = path.join(tempRoot, "globalStorage", "rooveterinaryinc.roo-cline")
240+
241+
mockStorageConfiguration()
242+
243+
await fs.mkdir(path.join(rooDefaultStorageRoot, "tasks", "task-missing-history"), { recursive: true })
244+
await fs.writeFile(
245+
path.join(rooDefaultStorageRoot, "tasks", "task-missing-history", "ui_messages.json"),
246+
"ui only",
247+
)
248+
249+
const result = await importRooTaskHistory(zooGlobalStoragePath)
250+
251+
expect(result.importedTaskCount).toBe(0)
252+
expect(result.importedFileCount).toBe(0)
253+
await expect(fs.access(path.join(zooGlobalStoragePath, "tasks", "task-missing-history"))).rejects.toMatchObject(
254+
{
255+
code: "ENOENT",
256+
},
257+
)
258+
})
259+
260+
it("does not delete an existing Zoo task when the Roo task is missing history_item.json", async () => {
261+
const zooGlobalStoragePath = path.join(tempRoot, "globalStorage", "zoocodeorganization.zoo-code")
262+
const rooDefaultStorageRoot = path.join(tempRoot, "globalStorage", "rooveterinaryinc.roo-cline")
263+
const existingZooTaskDirectory = path.join(zooGlobalStoragePath, "tasks", "task-existing")
264+
265+
mockStorageConfiguration()
266+
267+
await fs.mkdir(path.join(rooDefaultStorageRoot, "tasks", "task-existing"), { recursive: true })
268+
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "task-existing", "ui_messages.json"), "ui only")
269+
await fs.mkdir(existingZooTaskDirectory, { recursive: true })
270+
await fs.writeFile(path.join(existingZooTaskDirectory, "history_item.json"), "existing")
271+
272+
const result = await importRooTaskHistory(zooGlobalStoragePath)
273+
274+
expect(result.importedTaskCount).toBe(0)
275+
expect(result.importedFileCount).toBe(0)
276+
expect(await fs.readFile(path.join(existingZooTaskDirectory, "history_item.json"), "utf8")).toBe("existing")
277+
})
278+
210279
it("rethrows unexpected task-root errors while importing Roo history", async () => {
211280
const zooGlobalStoragePath = path.join(tempRoot, "globalStorage", "zoocodeorganization.zoo-code")
212281
const rooDefaultStorageRoot = path.join(tempRoot, "globalStorage", "rooveterinaryinc.roo-cline")

src/core/task-persistence/importRooTaskHistory.ts

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ import * as fs from "fs/promises"
33
import * as path from "path"
44
import * as vscode from "vscode"
55

6+
import { GlobalFileNames } from "../../shared/globalFileNames"
67
import { Package } from "../../shared/package"
78
import { getStorageBasePath } from "../../utils/storage"
89

910
const ROO_EXTENSION_DOMAIN = "RooVeterinaryInc.roo-cline"
1011
const ROO_STORAGE_DIRECTORY = ROO_EXTENSION_DOMAIN.toLowerCase()
1112
const ROO_CONFIGURATION_SECTION = "roo-cline"
13+
const IMPORTABLE_TASK_FILE_NAMES = [
14+
GlobalFileNames.historyItem,
15+
GlobalFileNames.uiMessages,
16+
GlobalFileNames.apiConversationHistory,
17+
GlobalFileNames.taskMetadata,
18+
]
1219

1320
export interface RooHistoryImportPaths {
1421
rooExtensionDomain: string
@@ -51,20 +58,36 @@ const getConfiguredCustomStoragePath = (configurationSection: string) => {
5158
}
5259
}
5360

54-
const countFiles = async (directoryPath: string): Promise<number> => {
55-
const entries = await fs.readdir(directoryPath, { withFileTypes: true })
56-
let fileCount = 0
61+
const isSkippableImportError = (error: unknown) => {
62+
const nodeError = error as NodeJS.ErrnoException
63+
return nodeError.code === "ENOENT" || nodeError.code === "EACCES" || nodeError.code === "EPERM"
64+
}
5765

58-
for (const entry of entries) {
59-
const entryPath = path.join(directoryPath, entry.name)
60-
if (entry.isDirectory()) {
61-
fileCount += await countFiles(entryPath)
62-
} else if (entry.isFile()) {
63-
fileCount += 1
66+
const copyTaskFileIfPresent = async (
67+
sourceTaskDirectory: string,
68+
destinationTaskDirectory: string,
69+
fileName: string,
70+
) => {
71+
try {
72+
await fs.mkdir(destinationTaskDirectory, { recursive: true })
73+
await fs.copyFile(path.join(sourceTaskDirectory, fileName), path.join(destinationTaskDirectory, fileName))
74+
return true
75+
} catch (error) {
76+
if (isSkippableImportError(error)) {
77+
return false
6478
}
79+
80+
throw error
6581
}
82+
}
6683

67-
return fileCount
84+
const pathExists = async (candidatePath: string) => {
85+
try {
86+
await fs.access(candidatePath)
87+
return true
88+
} catch {
89+
return false
90+
}
6891
}
6992

7093
export const resolveRooHistoryImportPaths = async (globalStoragePath: string): Promise<RooHistoryImportPaths> => {
@@ -114,14 +137,32 @@ export const importRooTaskHistory = async (globalStoragePath: string): Promise<R
114137

115138
const sourceTaskDirectory = path.join(sourceTasksRoot, entry.name)
116139
const destinationTaskDirectory = path.join(destinationTasksRoot, entry.name)
140+
const destinationTaskDirectoryExisted = await pathExists(destinationTaskDirectory)
141+
const historyItemCopied = await copyTaskFileIfPresent(
142+
sourceTaskDirectory,
143+
destinationTaskDirectory,
144+
GlobalFileNames.historyItem,
145+
)
146+
147+
if (!historyItemCopied) {
148+
if (!destinationTaskDirectoryExisted) {
149+
await fs.rm(destinationTaskDirectory, { recursive: true, force: true })
150+
}
151+
continue
152+
}
117153

118154
importedTaskIds.add(entry.name)
119-
importedFileCount += await countFiles(sourceTaskDirectory)
155+
importedFileCount += 1
156+
157+
for (const fileName of IMPORTABLE_TASK_FILE_NAMES) {
158+
if (fileName === GlobalFileNames.historyItem) {
159+
continue
160+
}
120161

121-
await fs.cp(sourceTaskDirectory, destinationTaskDirectory, {
122-
recursive: true,
123-
force: true,
124-
})
162+
if (await copyTaskFileIfPresent(sourceTaskDirectory, destinationTaskDirectory, fileName)) {
163+
importedFileCount += 1
164+
}
165+
}
125166
}
126167
}
127168

src/core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ describe("webviewMessageHandler - importRooHistory", () => {
5050
flushIndex: ReturnType<typeof vi.fn>
5151
}
5252
postStateToWebview: ReturnType<typeof vi.fn>
53+
log: ReturnType<typeof vi.fn>
5354
}
5455

5556
beforeEach(() => {
@@ -67,6 +68,7 @@ describe("webviewMessageHandler - importRooHistory", () => {
6768
flushIndex: vi.fn().mockResolvedValue(undefined),
6869
},
6970
postStateToWebview: vi.fn().mockResolvedValue(undefined),
71+
log: vi.fn(),
7072
} as any
7173
})
7274

@@ -133,4 +135,21 @@ describe("webviewMessageHandler - importRooHistory", () => {
133135
)
134136
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
135137
})
138+
139+
it("shows an error without refreshing task history when the import throws", async () => {
140+
importRooTaskHistoryMock.mockRejectedValue(new Error("permission denied"))
141+
142+
await webviewMessageHandler(mockProvider as any, { type: "importRooHistory" } as any)
143+
144+
expect(mockProvider.taskHistoryStore.invalidateAll).not.toHaveBeenCalled()
145+
expect(mockProvider.taskHistoryStore.reconcile).not.toHaveBeenCalled()
146+
expect(mockProvider.taskHistoryStore.flushIndex).not.toHaveBeenCalled()
147+
expect(mockProvider.postStateToWebview).not.toHaveBeenCalled()
148+
expect(mockProvider.log).toHaveBeenCalledWith("[importRooHistory] failed: permission denied")
149+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
150+
"Failed to import Roo Code history: permission denied",
151+
)
152+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
153+
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled()
154+
})
136155
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -893,23 +893,29 @@ export const webviewMessageHandler = async (
893893
break
894894
}
895895
case "importRooHistory": {
896-
const result = await importRooTaskHistory(provider.contextProxy.globalStorageUri.fsPath)
896+
try {
897+
const result = await importRooTaskHistory(provider.contextProxy.globalStorageUri.fsPath)
897898

898-
if (result.importedTaskCount === 0) {
899-
vscode.window.showWarningMessage(
900-
`No Roo Code task history was found to import from ${result.rooExtensionDomain}.`,
901-
)
902-
break
903-
}
899+
if (result.importedTaskCount === 0) {
900+
vscode.window.showWarningMessage(
901+
`No Roo Code task history was found to import from ${result.rooExtensionDomain}.`,
902+
)
903+
break
904+
}
904905

905-
provider.taskHistoryStore.invalidateAll()
906-
await provider.taskHistoryStore.reconcile()
907-
await provider.taskHistoryStore.flushIndex()
908-
await provider.postStateToWebview()
906+
provider.taskHistoryStore.invalidateAll()
907+
await provider.taskHistoryStore.reconcile()
908+
await provider.taskHistoryStore.flushIndex()
909+
await provider.postStateToWebview()
909910

910-
vscode.window.showInformationMessage(
911-
`Imported ${result.importedTaskCount} Roo Code task ${result.importedTaskCount === 1 ? "history" : "histories"} into Zoo Code.`,
912-
)
911+
vscode.window.showInformationMessage(
912+
`Imported ${result.importedTaskCount} Roo Code task ${result.importedTaskCount === 1 ? "history" : "histories"} into Zoo Code.`,
913+
)
914+
} catch (error) {
915+
const message = error instanceof Error ? error.message : String(error)
916+
provider.log(`[importRooHistory] failed: ${message}`)
917+
vscode.window.showErrorMessage(`Failed to import Roo Code history: ${message}`)
918+
}
913919
break
914920
}
915921
case "exportSettings":

webview-ui/src/components/settings/About.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,15 +155,23 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,
155155
settingId="about-import-roo-history"
156156
section="about"
157157
label="Import history from Roo Code">
158-
<div className="rounded-md border border-vscode-focusBorder/50 bg-vscode-editorWidget-background/40 p-2">
158+
<div className="space-y-3 rounded-lg border border-vscode-focusBorder/40 bg-vscode-editorWidget-background/40 p-3">
159+
<div className="flex items-start gap-3">
160+
<div className="rounded-md border border-vscode-focusBorder/30 bg-vscode-button-background/15 p-2 text-vscode-button-background">
161+
<ArrowRightLeft className="size-4" />
162+
</div>
163+
<div className="min-w-0">
164+
<div className="text-sm font-medium text-vscode-foreground">Roo Code history</div>
165+
<div className="text-sm leading-5 text-vscode-descriptionForeground">
166+
Copy saved Roo Code conversations into Zoo Code.
167+
</div>
168+
</div>
169+
</div>
159170
<VSCodeButton
160171
appearance="primary"
161172
onClick={() => vscode.postMessage({ type: "importRooHistory" })}
162173
style={{ width: "100%" }}>
163-
<span className="inline-flex items-center justify-center gap-2 font-semibold">
164-
<ArrowRightLeft className="size-4" />
165-
<span>Import history from Roo Code</span>
166-
</span>
174+
Import history from Roo Code
167175
</VSCodeButton>
168176
</div>
169177
</SearchableSetting>

0 commit comments

Comments
 (0)