Skip to content

Refactor task history persistence to use file-based storage #3785

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
921 changes: 921 additions & 0 deletions src/core/task-persistence/__tests__/taskHistory.test.ts

Large diffs are not rendered by default.

438 changes: 438 additions & 0 deletions src/core/task-persistence/taskHistory.ts

Large diffs are not rendered by default.

124 changes: 55 additions & 69 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { findLast } from "../../shared/array"
import { supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { HistoryItem } from "../../shared/HistoryItem"
import { getTaskDirectoryPath } from "../../shared/storagePathManager"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { experimentDefault } from "../../shared/experiments"
Expand All @@ -43,6 +44,12 @@ import { telemetryService } from "../../services/telemetry/TelemetryService"
import { getWorkspacePath } from "../../utils/path"
import { webviewMessageHandler } from "./webviewMessageHandler"
import { WebviewMessage } from "../../shared/WebviewMessage"
import {
getHistoryItem,
setHistoryItems,
deleteHistoryItem,
getAvailableHistoryMonths,
} from "../task-persistence/taskHistory"

/**
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
Expand Down Expand Up @@ -1066,11 +1073,9 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
uiMessagesFilePath: string
apiConversationHistory: Anthropic.MessageParam[]
}> {
const history = this.getGlobalState("taskHistory") ?? []
const historyItem = history.find((item) => item.id === id)
const historyItem = await getHistoryItem(id)

if (historyItem) {
const { getTaskDirectoryPath } = await import("../../shared/storagePathManager")
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id)
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
Expand All @@ -1091,8 +1096,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
}

// if we tried to get a task that doesn't exist, remove it from state
// FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason
await this.deleteTaskFromState(id)
// No need to call deleteTaskFromState here as getHistoryItem handles not found.
throw new Error("Task not found")
}

Expand All @@ -1113,57 +1117,49 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements

// this function deletes a task from task hidtory, and deletes it's checkpoints and delete the task folder
async deleteTaskWithId(id: string) {
try {
// get the task directory full path
const { taskDirPath } = await this.getTaskWithId(id)

// remove task from stack if it's the current task
if (id === this.getCurrentCline()?.taskId) {
// if we found the taskid to delete - call finish to abort this task and allow a new task to be started,
// if we are deleting a subtask and parent task is still waiting for subtask to finish - it allows the parent to resume (this case should neve exist)
await this.finishSubTask(t("common:tasks.deleted"))
}
const itemToDelete = await getHistoryItem(id)
if (!itemToDelete) {
// If item doesn't exist, nothing to delete.
// Consider if we need to remove from old state if it was there.
// For now, assume taskHistory service is the source of truth.
console.warn(`[deleteTaskWithId] Task ${id} not found in history service.`)
return
}
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id)

// delete task from the task history state
await this.deleteTaskFromState(id)
// remove task from stack if it's the current task
if (id === this.getCurrentCline()?.taskId) {
// if we found the taskid to delete - call finish to abort this task and allow a new task to be started,
// if we are deleting a subtask and parent task is still waiting for subtask to finish - it allows the parent to resume (this case should neve exist)
await this.finishSubTask(t("common:tasks.deleted"))
}

// Delete associated shadow repository or branch.
// TODO: Store `workspaceDir` in the `HistoryItem` object.
const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
const workspaceDir = this.cwd
// Call the new service to delete the item
await deleteHistoryItem(id)

try {
await ShadowCheckpointService.deleteTask({ taskId: id, globalStorageDir, workspaceDir })
} catch (error) {
console.error(
`[deleteTaskWithId${id}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`,
)
}
// Delete associated shadow repository or branch.
// TODO: Store `workspaceDir` in the `HistoryItem` object.
const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
const workspaceDir = this.cwd

// delete the entire task directory including checkpoints and all content
try {
await fs.rm(taskDirPath, { recursive: true, force: true })
console.log(`[deleteTaskWithId${id}] removed task directory`)
} catch (error) {
console.error(
`[deleteTaskWithId${id}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`,
)
}
try {
await ShadowCheckpointService.deleteTask({ taskId: id, globalStorageDir, workspaceDir })
} catch (error) {
// If task is not found, just remove it from state
if (error instanceof Error && error.message === "Task not found") {
await this.deleteTaskFromState(id)
return
}
throw error
console.error(
`[deleteTaskWithId${id}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`,
)
}
}

async deleteTaskFromState(id: string) {
const taskHistory = this.getGlobalState("taskHistory") ?? []
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
await this.updateGlobalState("taskHistory", updatedTaskHistory)
await this.postStateToWebview()
// delete the entire task directory including checkpoints and all content
try {
await fs.rm(taskDirPath, { recursive: true, force: true })
console.log(`[deleteTaskWithId${id}] removed task directory`)
} catch (error) {
console.error(
`[deleteTaskWithId${id}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`,
)
}
}

async postStateToWebview() {
Expand Down Expand Up @@ -1199,7 +1195,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
ttsSpeed,
diffEnabled,
enableCheckpoints,
taskHistory,
soundVolume,
browserViewportSize,
screenshotQuality,
Expand Down Expand Up @@ -1266,13 +1261,14 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
allowedMaxRequests: allowedMaxRequests ?? Infinity,
uriScheme: vscode.env.uriScheme,
currentTaskItem: this.getCurrentCline()?.taskId
? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId)
: undefined,
currentTaskItem: await (async () => {
const currentCline = this.getCurrentCline()
if (currentCline?.taskId) {
return await getHistoryItem(currentCline.taskId)
}
return undefined
})(),
clineMessages: this.getCurrentCline()?.clineMessages || [],
taskHistory: (taskHistory || [])
.filter((item: HistoryItem) => item.ts && item.task)
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
soundEnabled: soundEnabled ?? false,
ttsEnabled: ttsEnabled ?? false,
ttsSpeed: ttsSpeed ?? 1.0,
Expand Down Expand Up @@ -1328,6 +1324,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
terminalCompressProgressBar: terminalCompressProgressBar ?? true,
hasSystemPromptOverride,
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
availableHistoryMonths: await getAvailableHistoryMonths(),
}
}

Expand Down Expand Up @@ -1368,7 +1365,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false,
alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false,
allowedMaxRequests: stateValues.allowedMaxRequests ?? Infinity,
taskHistory: stateValues.taskHistory,
allowedCommands: stateValues.allowedCommands,
soundEnabled: stateValues.soundEnabled ?? false,
ttsEnabled: stateValues.ttsEnabled ?? false,
Expand Down Expand Up @@ -1421,18 +1417,8 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
}
}

async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
const history = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || []
const existingItemIndex = history.findIndex((h) => h.id === item.id)

if (existingItemIndex !== -1) {
history[existingItemIndex] = item
} else {
history.push(item)
}

await this.updateGlobalState("taskHistory", history)
return history
async updateTaskHistory(item: HistoryItem): Promise<void> {
await setHistoryItems([item])
}

// ContextProxy
Expand Down
2 changes: 0 additions & 2 deletions src/core/webview/__tests__/ClineProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,6 @@ describe("ClineProvider", () => {
const mockState: ExtensionState = {
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
apiConfiguration: {
apiProvider: "openrouter",
Expand Down Expand Up @@ -487,7 +486,6 @@ describe("ClineProvider", () => {
expect(state).toHaveProperty("alwaysAllowWrite")
expect(state).toHaveProperty("alwaysAllowExecute")
expect(state).toHaveProperty("alwaysAllowBrowser")
expect(state).toHaveProperty("taskHistory")
expect(state).toHaveProperty("soundEnabled")
expect(state).toHaveProperty("ttsEnabled")
expect(state).toHaveProperty("diffEnabled")
Expand Down
24 changes: 23 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import { telemetryService } from "../../services/telemetry/TelemetryService"
import { TelemetrySetting } from "../../shared/TelemetrySetting"
import { getWorkspacePath } from "../../utils/path"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { GetHistoryByMonthPayload } from "../../shared/WebviewMessage"
import { getHistoryItemsForMonth, getHistoryItemsForSearch } from "../task-persistence/taskHistory"
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
import { generateSystemPrompt } from "./generateSystemPrompt"

Expand Down Expand Up @@ -891,7 +893,27 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
await updateGlobalState("maxReadFileLine", message.value)
await provider.postStateToWebview()
break
case "setHistoryPreviewCollapsed": // Add the new case handler
case "getHistoryByMonth": {
// Ensure payload is correctly typed if this new handler is used.
// The task description mentions `message.payload as GetHistoryByMonthPayload`
// or `message.values`. Current file uses `message.values`.
const payload = message.payload as GetHistoryByMonthPayload
if (payload && typeof payload.year === "number" && typeof payload.month === "number") {
const historyItems = await getHistoryItemsForMonth(payload.year, payload.month)
await provider.postMessageToWebview({ type: "historyByMonthResults", historyItems })
} else {
console.warn("getHistoryByMonth: Invalid payload", message.payload)
// Optionally send an error back to the webview
}
break
}
case "searchHistory": {
const query = message.text || ""
const historyItems = await getHistoryItemsForSearch(query)
await provider.postMessageToWebview({ type: "historySearchResults", historyItems })
break
}
case "setHistoryPreviewCollapsed":
await updateGlobalState("historyPreviewCollapsed", message.bool ?? false)
// No need to call postStateToWebview here as the UI already updated optimistically
break
Expand Down
18 changes: 18 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
CodeActionProvider,
} from "./activate"
import { initializeI18n } from "./i18n"
import { migrateTaskHistoryStorage } from "./core/task-persistence/taskHistory"

/**
* Built using https://github.com/microsoft/vscode-webview-ui-toolkit
Expand All @@ -44,6 +45,17 @@ import { initializeI18n } from "./i18n"
let outputChannel: vscode.OutputChannel
let extensionContext: vscode.ExtensionContext

/**
* Returns the extension context.
* Throws an error if the context has not been initialized (i.e., activate has not been called).
*/
export function getExtensionContext(): vscode.ExtensionContext {
if (!extensionContext) {
throw new Error("Extension context is not available. Activate function may not have been called.")
}
return extensionContext
}

// This method is called when your extension is activated.
// Your extension is activated the very first time the command is executed.
export async function activate(context: vscode.ExtensionContext) {
Expand All @@ -52,6 +64,12 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(outputChannel)
outputChannel.appendLine("Roo-Code extension activated")

// Initialize and migrate task history storage
// (migrateTaskHistoryStorage also calls initializeTaskHistory internally)
outputChannel.appendLine("Starting task history data format check/migration...")
await migrateTaskHistoryStorage()
outputChannel.appendLine("Task history data format check/migration finished.")

// Migrate old settings to new
await migrateSettings(context, outputChannel)

Expand Down
9 changes: 5 additions & 4 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { McpServer } from "./mcp"
import { Mode } from "./modes"
import { RouterModels } from "./api"

export type { ProviderSettingsEntry, ToolProgressStatus }
export type { ProviderSettingsEntry, ToolProgressStatus, HistoryItem }

export interface LanguageModelChatSelector {
vendor?: string
Expand Down Expand Up @@ -69,6 +69,8 @@ export interface ExtensionMessage {
| "setHistoryPreviewCollapsed"
| "commandExecutionStatus"
| "vsCodeSetting"
| "historyByMonthResults"
| "historySearchResults"
text?: string
action?:
| "chatButtonClicked"
Expand Down Expand Up @@ -96,6 +98,7 @@ export interface ExtensionMessage {
mcpServers?: McpServer[]
commits?: GitCommit[]
listApiConfig?: ProviderSettingsEntry[]
historyItems?: HistoryItem[]
mode?: Mode
customMode?: ModeConfig
slug?: string
Expand All @@ -116,7 +119,6 @@ export type ExtensionState = Pick<
| "pinnedApiConfigs"
// | "lastShownAnnouncementId"
| "customInstructions"
// | "taskHistory" // Optional in GlobalSettings, required here.
| "autoApprovalEnabled"
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
Expand Down Expand Up @@ -177,8 +179,6 @@ export type ExtensionState = Pick<
uriScheme?: string
shouldShowAnnouncement: boolean

taskHistory: HistoryItem[]

writeDelayMs: number
requestDelaySeconds: number

Expand All @@ -205,6 +205,7 @@ export type ExtensionState = Pick<
renderContext: "sidebar" | "editor"
settingsImportedAt?: number
historyPreviewCollapsed?: boolean
availableHistoryMonths?: Array<{ year: number; month: number }>
}

export type { ClineMessage, ClineAsk, ClineSay }
Expand Down
9 changes: 8 additions & 1 deletion src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ export interface WebviewMessage {
| "searchFiles"
| "toggleApiConfigPin"
| "setHistoryPreviewCollapsed"
| "getHistoryByMonth"
| "searchHistory"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
Expand Down Expand Up @@ -162,6 +164,11 @@ export interface WebviewMessage {
historyPreviewCollapsed?: boolean
}

export interface GetHistoryByMonthPayload {
year: number
month: number
}

export const checkoutDiffPayloadSchema = z.object({
ts: z.number(),
previousCommitHash: z.string().optional(),
Expand All @@ -179,4 +186,4 @@ export const checkoutRestorePayloadSchema = z.object({

export type CheckpointRestorePayload = z.infer<typeof checkoutRestorePayloadSchema>

export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload
export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload | GetHistoryByMonthPayload
Loading