mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat(cli): add session resume/history and upgrade command (#11768)
feat(cli): add session history/resume and upgrade command
This commit is contained in:
parent
e25b1f2768
commit
de0e3632d2
17 changed files with 846 additions and 17 deletions
|
|
@ -41,6 +41,12 @@ Re-run the install script to update to the latest version:
|
|||
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
|
||||
```
|
||||
|
||||
Or run:
|
||||
|
||||
```bash
|
||||
roo upgrade
|
||||
```
|
||||
|
||||
### Uninstalling
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -502,6 +502,37 @@ describe("ExtensionHost", () => {
|
|||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" })
|
||||
})
|
||||
|
||||
it("should include taskId when provided", async () => {
|
||||
const host = createTestHost()
|
||||
host.markWebviewReady()
|
||||
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
const client = getPrivate(host, "client") as ExtensionClient
|
||||
|
||||
const taskPromise = host.runTask("test prompt", "task-123")
|
||||
|
||||
const taskCompletedEvent = {
|
||||
success: true,
|
||||
stateInfo: {
|
||||
state: AgentLoopState.IDLE,
|
||||
isWaitingForInput: false,
|
||||
isRunning: false,
|
||||
isStreaming: false,
|
||||
requiredAction: "start_task" as const,
|
||||
description: "Task completed",
|
||||
},
|
||||
}
|
||||
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
|
||||
|
||||
await taskPromise
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", {
|
||||
type: "newTask",
|
||||
text: "test prompt",
|
||||
taskId: "task-123",
|
||||
})
|
||||
})
|
||||
|
||||
it("should resolve when taskCompleted is emitted on client", async () => {
|
||||
const host = createTestHost()
|
||||
host.markWebviewReady()
|
||||
|
|
@ -525,6 +556,33 @@ describe("ExtensionHost", () => {
|
|||
|
||||
await expect(taskPromise).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("should send showTaskWithId for resumeTask and resolve on completion", async () => {
|
||||
const host = createTestHost()
|
||||
host.markWebviewReady()
|
||||
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
const client = getPrivate(host, "client") as ExtensionClient
|
||||
|
||||
const taskPromise = host.resumeTask("task-abc")
|
||||
|
||||
const taskCompletedEvent = {
|
||||
success: true,
|
||||
stateInfo: {
|
||||
state: AgentLoopState.IDLE,
|
||||
isWaitingForInput: false,
|
||||
isRunning: false,
|
||||
isStreaming: false,
|
||||
requiredAction: "start_task" as const,
|
||||
description: "Task completed",
|
||||
},
|
||||
}
|
||||
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
|
||||
|
||||
await taskPromise
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "showTaskWithId", text: "task-abc" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("initial settings", () => {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEven
|
|||
client: ExtensionClient
|
||||
activate(): Promise<void>
|
||||
runTask(prompt: string, taskId?: string): Promise<void>
|
||||
resumeTask(taskId: string): Promise<void>
|
||||
sendToExtension(message: WebviewMessage): void
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
|
@ -466,9 +467,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
|||
// Task Management
|
||||
// ==========================================================================
|
||||
|
||||
public async runTask(prompt: string, taskId?: string): Promise<void> {
|
||||
this.sendToExtension({ type: "newTask", text: prompt, taskId })
|
||||
|
||||
private waitForTaskCompletion(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const completeHandler = () => {
|
||||
cleanup()
|
||||
|
|
@ -509,6 +508,16 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
|||
})
|
||||
}
|
||||
|
||||
public async runTask(prompt: string, taskId?: string): Promise<void> {
|
||||
this.sendToExtension({ type: "newTask", text: prompt, taskId })
|
||||
return this.waitForTaskCompletion()
|
||||
}
|
||||
|
||||
public async resumeTask(taskId: string): Promise<void> {
|
||||
this.sendToExtension({ type: "showTaskWithId", text: taskId })
|
||||
return this.waitForTaskCompletion()
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public Agent State API
|
||||
// ==========================================================================
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
import { parseFormat } from "../list.js"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
|
||||
import { readTaskSessionsFromStoragePath } from "@roo-code/core/cli"
|
||||
|
||||
import { listSessions, parseFormat } from "../list.js"
|
||||
|
||||
vi.mock("@roo-code/core/cli", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@roo-code/core/cli")>()
|
||||
return {
|
||||
...actual,
|
||||
readTaskSessionsFromStoragePath: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe("parseFormat", () => {
|
||||
it("defaults to json when undefined", () => {
|
||||
|
|
@ -27,3 +40,47 @@ describe("parseFormat", () => {
|
|||
expect(() => parseFormat("")).toThrow("Invalid format")
|
||||
})
|
||||
})
|
||||
|
||||
describe("listSessions", () => {
|
||||
const storagePath = path.join(os.homedir(), ".vscode-mock", "global-storage")
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const captureStdout = async (fn: () => Promise<void>): Promise<string> => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
|
||||
try {
|
||||
await fn()
|
||||
return stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join("")
|
||||
} finally {
|
||||
stdoutSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
it("uses the CLI runtime storage path and prints JSON output", async () => {
|
||||
vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([
|
||||
{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" },
|
||||
])
|
||||
|
||||
const output = await captureStdout(() => listSessions({ format: "json" }))
|
||||
|
||||
expect(readTaskSessionsFromStoragePath).toHaveBeenCalledWith(storagePath)
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
sessions: [{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" }],
|
||||
})
|
||||
})
|
||||
|
||||
it("prints tab-delimited text output with ISO timestamps and formatted titles", async () => {
|
||||
vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([
|
||||
{ id: "s1", task: "Task 1", ts: Date.UTC(2024, 0, 1, 0, 0, 0) },
|
||||
{ id: "s2", task: " ", ts: Date.UTC(2024, 0, 1, 1, 0, 0) },
|
||||
])
|
||||
|
||||
const output = await captureStdout(() => listSessions({ format: "text" }))
|
||||
const lines = output.trim().split("\n")
|
||||
|
||||
expect(lines).toEqual(["s1\t2024-01-01T00:00:00.000Z\tTask 1", "s2\t2024-01-01T01:00:00.000Z\t(untitled)"])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
88
apps/cli/src/commands/cli/__tests__/upgrade.test.ts
Normal file
88
apps/cli/src/commands/cli/__tests__/upgrade.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { compareVersions, getLatestCliVersion, upgrade } from "../upgrade.js"
|
||||
|
||||
function createFetchResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response {
|
||||
const { ok = true, status = 200 } = init
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe("compareVersions", () => {
|
||||
it("returns 1 when first version is newer", () => {
|
||||
expect(compareVersions("0.2.0", "0.1.9")).toBe(1)
|
||||
})
|
||||
|
||||
it("returns -1 when first version is older", () => {
|
||||
expect(compareVersions("0.1.4", "0.1.5")).toBe(-1)
|
||||
})
|
||||
|
||||
it("returns 0 when versions are equivalent", () => {
|
||||
expect(compareVersions("v1.2.0", "1.2")).toBe(0)
|
||||
})
|
||||
|
||||
it("supports cli tag prefixes and prerelease metadata", () => {
|
||||
expect(compareVersions("cli-v1.2.3", "1.2.2")).toBe(1)
|
||||
expect(compareVersions("1.2.3-beta.1", "1.2.3")).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLatestCliVersion", () => {
|
||||
it("returns the first cli-v release tag from GitHub releases", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
createFetchResponse([
|
||||
{ tag_name: "v9.9.9" },
|
||||
{ tag_name: "cli-v0.3.1" },
|
||||
{ tag_name: "cli-v0.3.0" },
|
||||
])) as typeof fetch
|
||||
|
||||
await expect(getLatestCliVersion(fetchImpl)).resolves.toBe("0.3.1")
|
||||
})
|
||||
|
||||
it("throws when release check fails", async () => {
|
||||
const fetchImpl = (async () => createFetchResponse({}, { ok: false, status: 503 })) as typeof fetch
|
||||
|
||||
await expect(getLatestCliVersion(fetchImpl)).rejects.toThrow("Failed to check latest version")
|
||||
})
|
||||
})
|
||||
|
||||
describe("upgrade", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("does not run installer when already up to date", async () => {
|
||||
const runInstaller = vi.fn(async () => undefined)
|
||||
const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.1.4" }])) as typeof fetch
|
||||
|
||||
await upgrade({
|
||||
currentVersion: "0.1.4",
|
||||
fetchImpl,
|
||||
runInstaller,
|
||||
})
|
||||
|
||||
expect(runInstaller).not.toHaveBeenCalled()
|
||||
expect(logSpy).toHaveBeenCalledWith("Roo CLI is already up to date.")
|
||||
})
|
||||
|
||||
it("runs installer when a newer version is available", async () => {
|
||||
const runInstaller = vi.fn(async () => undefined)
|
||||
const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.2.0" }])) as typeof fetch
|
||||
|
||||
await upgrade({
|
||||
currentVersion: "0.1.4",
|
||||
fetchImpl,
|
||||
runInstaller,
|
||||
})
|
||||
|
||||
expect(runInstaller).toHaveBeenCalledTimes(1)
|
||||
expect(logSpy).toHaveBeenCalledWith("✓ Upgrade completed.")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./run.js"
|
||||
export * from "./list.js"
|
||||
export * from "./upgrade.js"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import pWaitFor from "p-wait-for"
|
||||
|
||||
import { readTaskSessionsFromStoragePath, type TaskSessionEntry } from "@roo-code/core/cli"
|
||||
import type { Command, ModelRecord, WebviewMessage } from "@roo-code/types"
|
||||
import { getProviderDefaultModelId } from "@roo-code/types"
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ import { getApiKeyFromEnv } from "@/lib/utils/provider.js"
|
|||
import { isRecord } from "@/lib/utils/guards.js"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
|
||||
type ListFormat = "json" | "text"
|
||||
|
|
@ -28,6 +31,9 @@ type BaseListOptions = {
|
|||
|
||||
type CommandLike = Pick<Command, "name" | "source" | "filePath" | "description" | "argumentHint">
|
||||
type ModeLike = { slug: string; name: string }
|
||||
type SessionLike = TaskSessionEntry
|
||||
type ListHostOptions = { ephemeral: boolean }
|
||||
const DEFAULT_CLI_TASK_STORAGE_PATH = path.join(os.homedir(), ".vscode-mock", "global-storage")
|
||||
|
||||
export function parseFormat(rawFormat: string | undefined): ListFormat {
|
||||
const format = (rawFormat ?? "json").toLowerCase()
|
||||
|
|
@ -81,7 +87,24 @@ function outputModelsText(models: ModelRecord): void {
|
|||
}
|
||||
}
|
||||
|
||||
async function createListHost(options: BaseListOptions): Promise<ExtensionHost> {
|
||||
function formatSessionTitle(task: string): string {
|
||||
const compact = task.replace(/\s+/g, " ").trim()
|
||||
|
||||
if (!compact) {
|
||||
return "(untitled)"
|
||||
}
|
||||
|
||||
return compact.length <= 120 ? compact : `${compact.slice(0, 117)}...`
|
||||
}
|
||||
|
||||
function outputSessionsText(sessions: SessionLike[]): void {
|
||||
for (const session of sessions) {
|
||||
const startedAt = Number.isFinite(session.ts) ? new Date(session.ts).toISOString() : "unknown-time"
|
||||
process.stdout.write(`${session.id}\t${startedAt}\t${formatSessionTitle(session.task)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
async function createListHost(options: BaseListOptions, hostOptions: ListHostOptions): Promise<ExtensionHost> {
|
||||
const workspacePath = resolveWorkspacePath(options.workspace)
|
||||
const extensionPath = resolveExtensionPath(options.extension)
|
||||
const apiKey = options.apiKey || (await loadToken()) || getApiKeyFromEnv("roo")
|
||||
|
|
@ -96,7 +119,7 @@ async function createListHost(options: BaseListOptions): Promise<ExtensionHost>
|
|||
workspacePath,
|
||||
extensionPath,
|
||||
nonInteractive: true,
|
||||
ephemeral: true,
|
||||
ephemeral: hostOptions.ephemeral,
|
||||
debug: options.debug ?? false,
|
||||
exitOnComplete: true,
|
||||
exitOnError: false,
|
||||
|
|
@ -104,6 +127,7 @@ async function createListHost(options: BaseListOptions): Promise<ExtensionHost>
|
|||
}
|
||||
|
||||
const host = new ExtensionHost(extensionHostOptions)
|
||||
|
||||
await host.activate()
|
||||
|
||||
// Best effort wait; mode/commands requests can still succeed without this.
|
||||
|
|
@ -217,9 +241,10 @@ function requestRooModels(host: ExtensionHost): Promise<ModelRecord> {
|
|||
|
||||
async function withHostAndSignalHandlers<T>(
|
||||
options: BaseListOptions,
|
||||
hostOptions: ListHostOptions,
|
||||
fn: (host: ExtensionHost) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const host = await createListHost(options)
|
||||
const host = await createListHost(options, hostOptions)
|
||||
|
||||
const shutdown = async (exitCode: number) => {
|
||||
await host.dispose()
|
||||
|
|
@ -244,7 +269,7 @@ async function withHostAndSignalHandlers<T>(
|
|||
export async function listCommands(options: BaseListOptions): Promise<void> {
|
||||
const format = parseFormat(options.format)
|
||||
|
||||
await withHostAndSignalHandlers(options, async (host) => {
|
||||
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
|
||||
const commands = await requestCommands(host)
|
||||
|
||||
if (format === "json") {
|
||||
|
|
@ -259,7 +284,7 @@ export async function listCommands(options: BaseListOptions): Promise<void> {
|
|||
export async function listModes(options: BaseListOptions): Promise<void> {
|
||||
const format = parseFormat(options.format)
|
||||
|
||||
await withHostAndSignalHandlers(options, async (host) => {
|
||||
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
|
||||
const modes = await requestModes(host)
|
||||
|
||||
if (format === "json") {
|
||||
|
|
@ -274,7 +299,7 @@ export async function listModes(options: BaseListOptions): Promise<void> {
|
|||
export async function listModels(options: BaseListOptions): Promise<void> {
|
||||
const format = parseFormat(options.format)
|
||||
|
||||
await withHostAndSignalHandlers(options, async (host) => {
|
||||
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
|
||||
const models = await requestRooModels(host)
|
||||
|
||||
if (format === "json") {
|
||||
|
|
@ -285,3 +310,15 @@ export async function listModels(options: BaseListOptions): Promise<void> {
|
|||
outputModelsText(models)
|
||||
})
|
||||
}
|
||||
|
||||
export async function listSessions(options: BaseListOptions): Promise<void> {
|
||||
const format = parseFormat(options.format)
|
||||
const sessions = await readTaskSessionsFromStoragePath(DEFAULT_CLI_TASK_STORAGE_PATH)
|
||||
|
||||
if (format === "json") {
|
||||
outputJson({ sessions })
|
||||
return
|
||||
}
|
||||
|
||||
outputSessionsText(sessions)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import path from "path"
|
|||
import { fileURLToPath } from "url"
|
||||
|
||||
import { createElement } from "react"
|
||||
import type { HistoryItem } from "@roo-code/types"
|
||||
|
||||
import { setLogger } from "@roo-code/vscode-shim"
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ import { JsonEventEmitter } from "@/agent/json-event-emitter.js"
|
|||
import { createClient } from "@/lib/sdk/index.js"
|
||||
import { loadToken, loadSettings } from "@/lib/storage/index.js"
|
||||
import { isRecord } from "@/lib/utils/guards.js"
|
||||
import { arePathsEqual } from "@/lib/utils/path.js"
|
||||
import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
|
||||
import { runOnboarding } from "@/lib/utils/onboarding.js"
|
||||
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
|
||||
|
|
@ -91,6 +93,38 @@ async function warmRooModels(host: ExtensionHost): Promise<void> {
|
|||
})
|
||||
}
|
||||
|
||||
function extractTaskHistoryFromMessage(message: unknown): HistoryItem[] | undefined {
|
||||
if (!isRecord(message)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (message.type === "state") {
|
||||
const state = isRecord(message.state) ? message.state : undefined
|
||||
if (Array.isArray(state?.taskHistory)) {
|
||||
return state.taskHistory as HistoryItem[]
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === "taskHistoryUpdated" && Array.isArray(message.taskHistory)) {
|
||||
return message.taskHistory as HistoryItem[]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getMostRecentTaskIdInWorkspace(taskHistory: HistoryItem[], workspacePath: string): string | undefined {
|
||||
const workspaceTasks = taskHistory.filter(
|
||||
(item) => typeof item.workspace === "string" && arePathsEqual(item.workspace, workspacePath),
|
||||
)
|
||||
|
||||
if (workspaceTasks.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sorted = [...workspaceTasks].sort((a, b) => b.ts - a.ts)
|
||||
return sorted[0]?.id
|
||||
}
|
||||
|
||||
export async function run(promptArg: string | undefined, flagOptions: FlagOptions) {
|
||||
setLogger({
|
||||
info: () => {},
|
||||
|
|
@ -110,6 +144,26 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
prompt = fs.readFileSync(flagOptions.promptFile, "utf-8")
|
||||
}
|
||||
|
||||
const requestedSessionId = flagOptions.sessionId?.trim()
|
||||
const shouldContinueSession = flagOptions.continue
|
||||
const isResumeRequested = Boolean(requestedSessionId || shouldContinueSession)
|
||||
|
||||
if (flagOptions.sessionId !== undefined && !requestedSessionId) {
|
||||
console.error("[CLI] Error: --session-id requires a non-empty task id")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (requestedSessionId && shouldContinueSession) {
|
||||
console.error("[CLI] Error: cannot use --session-id with --continue")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (isResumeRequested && prompt) {
|
||||
console.error("[CLI] Error: cannot use prompt or --prompt-file with --session-id/--continue")
|
||||
console.error("[CLI] Usage: roo [--session-id <task-id> | --continue] [options]")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Options
|
||||
|
||||
let rooToken = await loadToken()
|
||||
|
|
@ -282,10 +336,16 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
if (flagOptions.stdinPromptStream && isResumeRequested) {
|
||||
console.error("[CLI] Error: cannot use --session-id/--continue with --stdin-prompt-stream")
|
||||
console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const useStdinPromptStream = flagOptions.stdinPromptStream
|
||||
|
||||
if (!isTuiEnabled) {
|
||||
if (!prompt && !useStdinPromptStream) {
|
||||
if (!prompt && !useStdinPromptStream && !isResumeRequested) {
|
||||
if (flagOptions.print) {
|
||||
console.error("[CLI] Error: no prompt provided")
|
||||
console.error("[CLI] Usage: roo --print [options] <prompt>")
|
||||
|
|
@ -317,6 +377,8 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
createElement(App, {
|
||||
...extensionHostOptions,
|
||||
initialPrompt: prompt,
|
||||
initialSessionId: requestedSessionId,
|
||||
continueSession: shouldContinueSession,
|
||||
version: VERSION,
|
||||
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
|
||||
}),
|
||||
|
|
@ -343,6 +405,16 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
let keepAliveInterval: NodeJS.Timeout | undefined
|
||||
let isShuttingDown = false
|
||||
let hostDisposed = false
|
||||
let taskHistorySnapshot: HistoryItem[] = []
|
||||
|
||||
const onExtensionMessage = (message: unknown) => {
|
||||
const taskHistory = extractTaskHistoryFromMessage(message)
|
||||
if (taskHistory) {
|
||||
taskHistorySnapshot = taskHistory
|
||||
}
|
||||
}
|
||||
|
||||
host.on("extensionWebviewMessage", onExtensionMessage)
|
||||
|
||||
const jsonEmitter = useJsonOutput
|
||||
? new JsonEventEmitter({
|
||||
|
|
@ -387,6 +459,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
}
|
||||
|
||||
hostDisposed = true
|
||||
host.off("extensionWebviewMessage", onExtensionMessage)
|
||||
jsonEmitter?.detach()
|
||||
await host.dispose()
|
||||
}
|
||||
|
|
@ -486,7 +559,26 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
},
|
||||
})
|
||||
} else {
|
||||
await host.runTask(prompt!)
|
||||
if (isResumeRequested) {
|
||||
const resolvedSessionId =
|
||||
requestedSessionId ||
|
||||
getMostRecentTaskIdInWorkspace(taskHistorySnapshot, effectiveWorkspacePath)
|
||||
|
||||
if (requestedSessionId && taskHistorySnapshot.length > 0) {
|
||||
const hasRequestedTask = taskHistorySnapshot.some((item) => item.id === requestedSessionId)
|
||||
if (!hasRequestedTask) {
|
||||
throw new Error(`Session not found in task history: ${requestedSessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedSessionId) {
|
||||
throw new Error("No previous tasks found to continue in this workspace.")
|
||||
}
|
||||
|
||||
await host.resumeTask(resolvedSessionId)
|
||||
} else {
|
||||
await host.runTask(prompt!)
|
||||
}
|
||||
}
|
||||
|
||||
await disposeHost()
|
||||
|
|
|
|||
137
apps/cli/src/commands/cli/upgrade.ts
Normal file
137
apps/cli/src/commands/cli/upgrade.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { spawn } from "child_process"
|
||||
|
||||
import { VERSION } from "@/lib/utils/version.js"
|
||||
import { isRecord } from "@/lib/utils/guards.js"
|
||||
|
||||
const RELEASES_URL = "https://api.github.com/repos/RooCodeInc/Roo-Code/releases?per_page=100"
|
||||
export const INSTALL_SCRIPT_COMMAND =
|
||||
"curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh"
|
||||
|
||||
export interface UpgradeOptions {
|
||||
currentVersion?: string
|
||||
fetchImpl?: typeof fetch
|
||||
runInstaller?: () => Promise<void>
|
||||
}
|
||||
|
||||
function parseVersion(version: string): number[] {
|
||||
const cleaned = version
|
||||
.trim()
|
||||
.replace(/^cli-v/, "")
|
||||
.replace(/^v/, "")
|
||||
const core = cleaned.split("+", 1)[0]?.split("-", 1)[0]
|
||||
|
||||
if (!core) {
|
||||
throw new Error(`Invalid version: ${version}`)
|
||||
}
|
||||
|
||||
const parts = core.split(".")
|
||||
if (parts.length === 0) {
|
||||
throw new Error(`Invalid version: ${version}`)
|
||||
}
|
||||
|
||||
return parts.map((part) => {
|
||||
if (!/^\d+$/.test(part)) {
|
||||
throw new Error(`Invalid version: ${version}`)
|
||||
}
|
||||
|
||||
return Number.parseInt(part, 10)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns:
|
||||
* - 1 when `a > b`
|
||||
* - 0 when `a === b`
|
||||
* - -1 when `a < b`
|
||||
*/
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const aParts = parseVersion(a)
|
||||
const bParts = parseVersion(b)
|
||||
const maxLength = Math.max(aParts.length, bParts.length)
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const aPart = aParts[i] ?? 0
|
||||
const bPart = bParts[i] ?? 0
|
||||
|
||||
if (aPart > bPart) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (aPart < bPart) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export async function getLatestCliVersion(fetchImpl: typeof fetch = fetch): Promise<string> {
|
||||
const response = await fetchImpl(RELEASES_URL, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "roo-cli",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to check latest version (HTTP ${response.status})`)
|
||||
}
|
||||
|
||||
const releases = await response.json()
|
||||
if (!Array.isArray(releases)) {
|
||||
throw new Error("Invalid release response from GitHub.")
|
||||
}
|
||||
|
||||
for (const release of releases) {
|
||||
if (!isRecord(release)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const tagName = release.tag_name
|
||||
if (typeof tagName === "string" && tagName.startsWith("cli-v")) {
|
||||
return tagName.slice("cli-v".length)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not determine the latest CLI release version.")
|
||||
}
|
||||
|
||||
export function runUpgradeInstaller(spawnImpl: typeof spawn = spawn): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnImpl("sh", ["-c", INSTALL_SCRIPT_COMMAND], { stdio: "inherit" })
|
||||
|
||||
child.once("error", (error) => {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.once("close", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`
|
||||
reject(new Error(`Upgrade installer failed (${reason}).`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function upgrade(options: UpgradeOptions = {}): Promise<void> {
|
||||
const currentVersion = options.currentVersion ?? VERSION
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const runInstaller = options.runInstaller ?? (() => runUpgradeInstaller())
|
||||
|
||||
console.log(`Current version: ${currentVersion}`)
|
||||
|
||||
const latestVersion = await getLatestCliVersion(fetchImpl)
|
||||
console.log(`Latest version: ${latestVersion}`)
|
||||
|
||||
if (compareVersions(latestVersion, currentVersion) <= 0) {
|
||||
console.log("Roo CLI is already up to date.")
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Upgrading Roo CLI from ${currentVersion} to ${latestVersion}...`)
|
||||
await runInstaller()
|
||||
console.log("✓ Upgrade completed.")
|
||||
}
|
||||
|
|
@ -2,7 +2,17 @@ import { Command } from "commander"
|
|||
|
||||
import { DEFAULT_FLAGS } from "@/types/constants.js"
|
||||
import { VERSION } from "@/lib/utils/version.js"
|
||||
import { run, login, logout, status, listCommands, listModes, listModels } from "@/commands/index.js"
|
||||
import {
|
||||
run,
|
||||
login,
|
||||
logout,
|
||||
status,
|
||||
listCommands,
|
||||
listModes,
|
||||
listModels,
|
||||
listSessions,
|
||||
upgrade,
|
||||
} from "@/commands/index.js"
|
||||
|
||||
const program = new Command()
|
||||
|
||||
|
|
@ -14,6 +24,8 @@ program
|
|||
program
|
||||
.argument("[prompt]", "Your prompt")
|
||||
.option("--prompt-file <path>", "Read prompt from a file instead of command line argument")
|
||||
.option("--session-id <task-id>", "Resume a specific task by task ID")
|
||||
.option("-c, --continue", "Resume the most recent task in the current workspace", false)
|
||||
.option("-w, --workspace <path>", "Workspace directory path (defaults to current working directory)")
|
||||
.option("-p, --print", "Print response and exit (non-interactive mode)", false)
|
||||
.option(
|
||||
|
|
@ -48,7 +60,7 @@ program
|
|||
)
|
||||
.action(run)
|
||||
|
||||
const listCommand = program.command("list").description("List commands, modes, or models")
|
||||
const listCommand = program.command("list").description("List commands, modes, models, or sessions")
|
||||
|
||||
const applyListOptions = (command: Command) =>
|
||||
command
|
||||
|
|
@ -69,6 +81,17 @@ const runListAction = async (action: () => Promise<void>) => {
|
|||
}
|
||||
}
|
||||
|
||||
const runUpgradeAction = async (action: () => Promise<void>) => {
|
||||
try {
|
||||
await action()
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[CLI] Error: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
applyListOptions(listCommand.command("commands").description("List available slash commands")).action(
|
||||
async (options: Parameters<typeof listCommands>[0]) => {
|
||||
await runListAction(() => listCommands(options))
|
||||
|
|
@ -87,6 +110,19 @@ applyListOptions(listCommand.command("models").description("List available Roo m
|
|||
},
|
||||
)
|
||||
|
||||
applyListOptions(listCommand.command("sessions").description("List task sessions")).action(
|
||||
async (options: Parameters<typeof listSessions>[0]) => {
|
||||
await runListAction(() => listSessions(options))
|
||||
},
|
||||
)
|
||||
|
||||
program
|
||||
.command("upgrade")
|
||||
.description("Upgrade Roo Code CLI to the latest version")
|
||||
.action(async () => {
|
||||
await runUpgradeAction(() => upgrade())
|
||||
})
|
||||
|
||||
const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
|
||||
|
||||
authCommand
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified"
|
|||
|
||||
export type FlagOptions = {
|
||||
promptFile?: string
|
||||
sessionId?: string
|
||||
continue: boolean
|
||||
workspace?: string
|
||||
print: boolean
|
||||
stdinPromptStream: boolean
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ const PICKER_HEIGHT = 10
|
|||
|
||||
export interface TUIAppProps extends ExtensionHostOptions {
|
||||
initialPrompt?: string
|
||||
initialSessionId?: string
|
||||
continueSession?: boolean
|
||||
version: string
|
||||
// Create extension host factory for dependency injection.
|
||||
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
|
||||
|
|
@ -71,6 +73,8 @@ export interface TUIAppProps extends ExtensionHostOptions {
|
|||
function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) {
|
||||
const {
|
||||
initialPrompt,
|
||||
initialSessionId,
|
||||
continueSession,
|
||||
workspacePath,
|
||||
extensionPath,
|
||||
user,
|
||||
|
|
@ -170,6 +174,8 @@ function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps)
|
|||
|
||||
const { sendToExtension, runTask, cleanup } = useExtensionHost({
|
||||
initialPrompt,
|
||||
initialSessionId,
|
||||
continueSession,
|
||||
mode,
|
||||
reasoningEffort,
|
||||
user,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,46 @@
|
|||
import { useEffect, useRef, useCallback, useMemo } from "react"
|
||||
import { useApp } from "ink"
|
||||
import { randomUUID } from "crypto"
|
||||
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import type { ExtensionMessage, HistoryItem, WebviewMessage } from "@roo-code/types"
|
||||
|
||||
import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js"
|
||||
import { arePathsEqual } from "@/lib/utils/path.js"
|
||||
|
||||
import { useCLIStore } from "../store.js"
|
||||
|
||||
const TASK_HISTORY_WAIT_TIMEOUT_MS = 2_000
|
||||
|
||||
function extractTaskHistory(message: ExtensionMessage): HistoryItem[] | undefined {
|
||||
if (message.type === "state" && Array.isArray(message.state?.taskHistory)) {
|
||||
return message.state.taskHistory as HistoryItem[]
|
||||
}
|
||||
|
||||
if (message.type === "taskHistoryUpdated" && Array.isArray(message.taskHistory)) {
|
||||
return message.taskHistory as HistoryItem[]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getMostRecentTaskId(taskHistory: HistoryItem[], workspacePath: string): string | undefined {
|
||||
const workspaceTasks = taskHistory.filter(
|
||||
(item) => typeof item.workspace === "string" && arePathsEqual(item.workspace, workspacePath),
|
||||
)
|
||||
|
||||
if (workspaceTasks.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sorted = [...workspaceTasks].sort((a, b) => b.ts - a.ts)
|
||||
return sorted[0]?.id
|
||||
}
|
||||
|
||||
// TODO: Unify with TUIAppProps?
|
||||
export interface UseExtensionHostOptions extends ExtensionHostOptions {
|
||||
initialPrompt?: string
|
||||
initialSessionId?: string
|
||||
continueSession?: boolean
|
||||
onExtensionMessage: (msg: ExtensionMessage) => void
|
||||
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
|
||||
}
|
||||
|
|
@ -32,6 +63,8 @@ export interface UseExtensionHostReturn {
|
|||
*/
|
||||
export function useExtensionHost({
|
||||
initialPrompt,
|
||||
initialSessionId,
|
||||
continueSession,
|
||||
mode,
|
||||
reasoningEffort,
|
||||
user,
|
||||
|
|
@ -48,7 +81,8 @@ export function useExtensionHost({
|
|||
createExtensionHost,
|
||||
}: UseExtensionHostOptions): UseExtensionHostReturn {
|
||||
const { exit } = useApp()
|
||||
const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore()
|
||||
const { addMessage, setComplete, setLoading, setHasStartedTask, setError, setCurrentTaskId, setIsResumingTask } =
|
||||
useCLIStore()
|
||||
|
||||
const hostRef = useRef<ExtensionHostInterface | null>(null)
|
||||
const isReadyRef = useRef(false)
|
||||
|
|
@ -64,6 +98,10 @@ export function useExtensionHost({
|
|||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const requestedSessionId = initialSessionId?.trim()
|
||||
let taskHistorySnapshot: HistoryItem[] = []
|
||||
let hasReceivedTaskHistory = false
|
||||
|
||||
const host = createExtensionHost({
|
||||
mode,
|
||||
user,
|
||||
|
|
@ -83,7 +121,17 @@ export function useExtensionHost({
|
|||
hostRef.current = host
|
||||
isReadyRef.current = true
|
||||
|
||||
host.on("extensionWebviewMessage", (msg) => onExtensionMessage(msg as ExtensionMessage))
|
||||
host.on("extensionWebviewMessage", (msg) => {
|
||||
const extensionMessage = msg as ExtensionMessage
|
||||
const taskHistory = extractTaskHistory(extensionMessage)
|
||||
|
||||
if (taskHistory) {
|
||||
taskHistorySnapshot = taskHistory
|
||||
hasReceivedTaskHistory = true
|
||||
}
|
||||
|
||||
onExtensionMessage(extensionMessage)
|
||||
})
|
||||
|
||||
host.client.on("taskCompleted", async () => {
|
||||
setComplete(true)
|
||||
|
|
@ -108,6 +156,37 @@ export function useExtensionHost({
|
|||
host.sendToExtension({ type: "requestCommands" })
|
||||
host.sendToExtension({ type: "requestModes" })
|
||||
|
||||
if (requestedSessionId || continueSession) {
|
||||
await pWaitFor(() => hasReceivedTaskHistory, {
|
||||
interval: 25,
|
||||
timeout: TASK_HISTORY_WAIT_TIMEOUT_MS,
|
||||
}).catch(() => undefined)
|
||||
|
||||
if (requestedSessionId && hasReceivedTaskHistory) {
|
||||
const hasRequestedTask = taskHistorySnapshot.some((item) => item.id === requestedSessionId)
|
||||
|
||||
if (!hasRequestedTask) {
|
||||
throw new Error(`Session not found in task history: ${requestedSessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedSessionId =
|
||||
requestedSessionId || getMostRecentTaskId(taskHistorySnapshot, workspacePath)
|
||||
|
||||
if (continueSession && !resolvedSessionId) {
|
||||
throw new Error("No previous tasks found to continue in this workspace.")
|
||||
}
|
||||
|
||||
if (resolvedSessionId) {
|
||||
setCurrentTaskId(resolvedSessionId)
|
||||
setIsResumingTask(true)
|
||||
setHasStartedTask(true)
|
||||
setLoading(true)
|
||||
host.sendToExtension({ type: "showTaskWithId", text: resolvedSessionId })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (initialPrompt) {
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@
|
|||
|
||||
export * from "./debug-log/index.js"
|
||||
export * from "./message-utils/index.js"
|
||||
export * from "./task-history/index.js"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * from "./custom-tools/index.js"
|
||||
export * from "./debug-log/index.js"
|
||||
export * from "./message-utils/index.js"
|
||||
export * from "./task-history/index.js"
|
||||
export * from "./worktree/index.js"
|
||||
|
|
|
|||
114
packages/core/src/task-history/__tests__/task-history.spec.ts
Normal file
114
packages/core/src/task-history/__tests__/task-history.spec.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import * as fs from "fs/promises"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
|
||||
import { readTaskSessionsFromStoragePath } from "../index.js"
|
||||
|
||||
describe("readTaskSessionsFromStoragePath", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-core-"))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("reads sessions from _index.json and sorts by timestamp descending", async () => {
|
||||
const tasksDir = path.join(tempDir, "tasks")
|
||||
await fs.mkdir(path.join(tasksDir, "a"), { recursive: true })
|
||||
await fs.mkdir(path.join(tasksDir, "b"), { recursive: true })
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tasksDir, "_index.json"),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{ id: "a", task: "Task A", ts: 100, status: "completed" },
|
||||
{ id: "b", task: "Task B", ts: 300, mode: "code" },
|
||||
{ id: "invalid", ts: 200 },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const sessions = await readTaskSessionsFromStoragePath(tempDir)
|
||||
|
||||
expect(sessions).toEqual([
|
||||
{ id: "b", task: "Task B", ts: 300, mode: "code", workspace: undefined, status: undefined },
|
||||
{ id: "a", task: "Task A", ts: 100, mode: undefined, workspace: undefined, status: "completed" },
|
||||
])
|
||||
})
|
||||
|
||||
it("merges missing sessions from tasks/<id>/history_item.json", async () => {
|
||||
const tasksDir = path.join(tempDir, "tasks")
|
||||
await fs.mkdir(path.join(tasksDir, "a"), { recursive: true })
|
||||
await fs.mkdir(path.join(tasksDir, "c"), { recursive: true })
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tasksDir, "_index.json"),
|
||||
JSON.stringify({
|
||||
entries: [{ id: "a", task: "Task A", ts: 100 }],
|
||||
}),
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(tasksDir, "c", "history_item.json"),
|
||||
JSON.stringify({ id: "c", task: "Task C", ts: 500, workspace: "/tmp/project" }),
|
||||
)
|
||||
|
||||
const sessions = await readTaskSessionsFromStoragePath(tempDir)
|
||||
|
||||
expect(sessions).toEqual([
|
||||
{ id: "c", task: "Task C", ts: 500, workspace: "/tmp/project", mode: undefined, status: undefined },
|
||||
{ id: "a", task: "Task A", ts: 100, workspace: undefined, mode: undefined, status: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it("removes stale index entries that have no on-disk task directory", async () => {
|
||||
const tasksDir = path.join(tempDir, "tasks")
|
||||
await fs.mkdir(path.join(tasksDir, "live"), { recursive: true })
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tasksDir, "_index.json"),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{ id: "stale", task: "Stale Task", ts: 999 },
|
||||
{ id: "live", task: "Live Task", ts: 100 },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const sessions = await readTaskSessionsFromStoragePath(tempDir)
|
||||
|
||||
expect(sessions).toEqual([
|
||||
{ id: "live", task: "Live Task", ts: 100, workspace: undefined, mode: undefined, status: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it("ignores malformed JSON and invalid history entries", async () => {
|
||||
const tasksDir = path.join(tempDir, "tasks")
|
||||
await fs.mkdir(path.join(tasksDir, "good"), { recursive: true })
|
||||
await fs.mkdir(path.join(tasksDir, "bad-json"), { recursive: true })
|
||||
await fs.mkdir(path.join(tasksDir, "bad-shape"), { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(tasksDir, "_index.json"), "{not-valid-json")
|
||||
await fs.writeFile(
|
||||
path.join(tasksDir, "good", "history_item.json"),
|
||||
JSON.stringify({ id: "good", task: "Good Task", ts: 10, status: "active" }),
|
||||
)
|
||||
await fs.writeFile(path.join(tasksDir, "bad-json", "history_item.json"), "{oops")
|
||||
await fs.writeFile(
|
||||
path.join(tasksDir, "bad-shape", "history_item.json"),
|
||||
JSON.stringify({ id: "bad-shape", task: 123, ts: "not-a-number" }),
|
||||
)
|
||||
|
||||
const sessions = await readTaskSessionsFromStoragePath(tempDir)
|
||||
|
||||
expect(sessions).toEqual([
|
||||
{ id: "good", task: "Good Task", ts: 10, workspace: undefined, mode: undefined, status: "active" },
|
||||
])
|
||||
})
|
||||
|
||||
it("returns an empty list when tasks directory does not exist", async () => {
|
||||
await expect(readTaskSessionsFromStoragePath(tempDir)).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
105
packages/core/src/task-history/index.ts
Normal file
105
packages/core/src/task-history/index.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
import type { HistoryItem } from "@roo-code/types"
|
||||
|
||||
const HISTORY_ITEM_FILENAME = "history_item.json"
|
||||
const HISTORY_INDEX_FILENAME = "_index.json"
|
||||
|
||||
export interface TaskSessionEntry {
|
||||
id: string
|
||||
task: string
|
||||
ts: number
|
||||
workspace?: string
|
||||
mode?: string
|
||||
status?: HistoryItem["status"]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function extractSessionEntry(value: unknown): TaskSessionEntry | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const id = value.id
|
||||
const task = value.task
|
||||
const ts = value.ts
|
||||
const workspace = value.workspace
|
||||
const mode = value.mode
|
||||
const status = value.status
|
||||
|
||||
if (typeof id !== "string" || typeof task !== "string" || typeof ts !== "number") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
task,
|
||||
ts,
|
||||
workspace: typeof workspace === "string" ? workspace : undefined,
|
||||
mode: typeof mode === "string" ? mode : undefined,
|
||||
status: status === "active" || status === "completed" || status === "delegated" ? status : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<unknown | undefined> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8")
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTaskSessionsFromStoragePath(storageBasePath: string): Promise<TaskSessionEntry[]> {
|
||||
const tasksDir = path.join(storageBasePath, "tasks")
|
||||
const sessionsById = new Map<string, TaskSessionEntry>()
|
||||
|
||||
const historyIndex = await readJsonFile(path.join(tasksDir, HISTORY_INDEX_FILENAME))
|
||||
const indexEntries = isRecord(historyIndex) && Array.isArray(historyIndex.entries) ? historyIndex.entries : []
|
||||
|
||||
for (const entry of indexEntries) {
|
||||
const session = extractSessionEntry(entry)
|
||||
if (session) {
|
||||
sessionsById.set(session.id, session)
|
||||
}
|
||||
}
|
||||
|
||||
let taskDirs: string[] = []
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(tasksDir, { withFileTypes: true })
|
||||
taskDirs = entries
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("_") && !entry.name.startsWith("."))
|
||||
.map((entry) => entry.name)
|
||||
} catch {
|
||||
// No tasks directory; return index-derived entries only.
|
||||
}
|
||||
|
||||
for (const taskId of taskDirs) {
|
||||
if (sessionsById.has(taskId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const historyItem = await readJsonFile(path.join(tasksDir, taskId, HISTORY_ITEM_FILENAME))
|
||||
const session = extractSessionEntry(historyItem)
|
||||
|
||||
if (session) {
|
||||
sessionsById.set(session.id, session)
|
||||
}
|
||||
}
|
||||
|
||||
if (taskDirs.length > 0) {
|
||||
const onDiskIds = new Set(taskDirs)
|
||||
for (const sessionId of sessionsById.keys()) {
|
||||
if (!onDiskIds.has(sessionId)) {
|
||||
sessionsById.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(sessionsById.values()).sort((a, b) => b.ts - a.ts)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue