fix(vscode): keep legacy session detection history-driven and stat-only

Split scanTaskStore into a history mode (legacy) that trusts taskHistory
and only stats each conversation file, and a discover mode (Roo) that
enumerates and parses task directories. This restores the prior legacy
behavior of not surfacing on-disk tasks missing from history and avoids
parsing every conversation file during detection.

Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
This commit is contained in:
markijbema
2026-06-18 07:06:42 +00:00
parent 45eab88930
commit 0ae925230f
4 changed files with 93 additions and 3 deletions
@@ -121,7 +121,7 @@ export async function detectLegacyData(context: vscode.ExtensionContext): Promis
async function readSessionCatalog(context: vscode.ExtensionContext) {
const items = context.globalState.get<LegacyHistoryItem[]>("taskHistory", [])
const dir = vscode.Uri.joinPath(context.globalStorageUri, "tasks").fsPath
return (await scanTaskStore(dir, items)).catalog
return (await scanTaskStore(dir, items, { mode: "history" })).catalog
}
// ---------------------------------------------------------------------------
@@ -40,11 +40,54 @@ export function resolveSession(catalog: SessionCatalog, id: string) {
return catalog.get(id)?.source
}
export type ScanMode = "history" | "discover"
export interface ScanOptions {
namespace?: string
/**
* "history" trusts the provided history items and only checks that each task's
* conversation file still exists (cheap stat, used by legacy migration).
* "discover" enumerates every task directory on disk and parses conversation
* files to recover titles (used when no history is available, e.g. Roo import).
*/
mode?: ScanMode
}
export async function scanTaskStore(
dir: string,
items: LegacyHistoryItem[] = [],
namespace?: string,
options: ScanOptions = {},
): Promise<TaskScan> {
const mode = options.mode ?? (items.length ? "history" : "discover")
return mode === "history"
? scanFromHistory(dir, items, options.namespace)
: scanFromDisk(dir, items, options.namespace)
}
/** Builds a catalog from known history items, only confirming each conversation file exists. */
async function scanFromHistory(dir: string, items: LegacyHistoryItem[], namespace?: string): Promise<TaskScan> {
const catalog: SessionCatalog = new Map()
for (const item of items) {
if (catalog.has(item.id)) continue
if (!(await exists(path.join(dir, item.id, API_FILE)))) continue
catalog.set(item.id, {
id: item.id,
session: {
id: item.id,
title: item.task?.trim() || fallbackTitle(item.id),
directory: item.workspace?.trim() || "",
time: item.ts ?? timestamp(item.id),
},
source: { id: item.id, dir, item, namespace },
})
}
return { catalog, diagnostics: [] }
}
/** Enumerates every task directory on disk and parses conversation files to recover titles. */
async function scanFromDisk(dir: string, items: LegacyHistoryItem[], namespace?: string): Promise<TaskScan> {
const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(dir)).then(
(value) => value,
() => [] as [string, vscode.FileType][],
@@ -24,7 +24,7 @@ export async function detectRooCodeSessions(context: vscode.ExtensionContext): P
for (const root of ROOTS) {
const dir = path.join(parent, root, "tasks")
const scan = await scanTaskStore(dir, [], "roo")
const scan = await scanTaskStore(dir, [], { namespace: "roo", mode: "discover" })
diagnostics.push(...scan.diagnostics)
for (const [id, entry] of [...scan.catalog].sort(([a], [b]) => a.localeCompare(b))) {
if (!catalog.has(id)) catalog.set(id, entry)
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it } from "bun:test"
import * as vscode from "vscode"
import { listSessions, resolveSession, scanTaskStore } from "../../../src/legacy-migration/task-store"
type Fs = typeof vscode.workspace.fs
const fs = vscode.workspace.fs as Fs
const original = { readDirectory: fs.readDirectory, readFile: fs.readFile, stat: fs.stat }
const dir = "/storage/kilocode.kilo-code/tasks"
const api = (id: string) => `${dir}/${id}/api_conversation_history.json`
describe("task store history scan", () => {
afterEach(() => {
fs.readDirectory = original.readDirectory
fs.readFile = original.readFile
fs.stat = original.stat
})
it("includes only history items whose conversation file exists, without scanning or parsing disk", async () => {
let listed = false
let read = false
fs.readDirectory = async () => {
listed = true
return []
}
fs.readFile = async () => {
read = true
throw new Error("history scan must not read files")
}
fs.stat = async (uri) => {
if (uri.fsPath === api("keep")) return { type: vscode.FileType.File, ctime: 0, mtime: 0, size: 1 }
throw new Error(`missing ${uri.fsPath}`)
}
const items = [
{ id: "keep", task: "Keep me", workspace: "/repo", ts: 1700000000000 },
{ id: "gone", task: "Deleted on disk", workspace: "/repo", ts: 1700000000001 },
]
const scan = await scanTaskStore(dir, items, { mode: "history" })
// "gone" is dropped (no file) and on-disk orphans are never considered (no enumeration).
expect(listSessions(scan.catalog)).toEqual([{ id: "keep", title: "Keep me", directory: "/repo", time: 1700000000000 }])
expect(resolveSession(scan.catalog, "keep")).toMatchObject({ id: "keep", dir })
expect(scan.diagnostics).toEqual([])
expect(listed).toBe(false)
expect(read).toBe(false)
})
})