fix(#680): session sort corruption with mtime fallback for corrupted JSON
When a session JSON file is corrupted, getFileMainSessions() previously
silently skipped it with 'catch { continue }', causing the session to
disappear from the sorted list entirely.
Now, when JSON.parse fails:
- Use the file's mtime as fallback for both created and updated timestamps
- Set load_error field with the parse error message
- Include the session in sort results so it remains visible
This prevents session sort corruption where corrupted sessions vanish
from the list, breaking chronological ordering and confusing users.
Closes #680
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
import { readdir, readFile } from "node:fs/promises"
|
import { readdir, readFile, stat } from "node:fs/promises"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
|
import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
|
||||||
import { getMessageDir } from "../../shared/opencode-message-dir"
|
import { getMessageDir } from "../../shared/opencode-message-dir"
|
||||||
@@ -20,14 +20,31 @@ export async function getFileMainSessions(directory?: string): Promise<SessionMe
|
|||||||
for (const file of sessionFiles) {
|
for (const file of sessionFiles) {
|
||||||
if (!file.endsWith(".json")) continue
|
if (!file.endsWith(".json")) continue
|
||||||
|
|
||||||
|
const filePath = join(projectPath, file)
|
||||||
try {
|
try {
|
||||||
const content = await readFile(join(projectPath, file), "utf-8")
|
const content = await readFile(filePath, "utf-8")
|
||||||
const meta = JSON.parse(content) as SessionMetadata
|
const meta = JSON.parse(content) as SessionMetadata
|
||||||
if (meta.parentID) continue
|
if (meta.parentID) continue
|
||||||
if (directory && meta.directory !== directory) continue
|
if (directory && meta.directory !== directory) continue
|
||||||
sessions.push(meta)
|
sessions.push(meta)
|
||||||
} catch {
|
} catch (err) {
|
||||||
continue
|
const sessionID = file.replace(/\.json$/, "")
|
||||||
|
try {
|
||||||
|
const stats = await stat(filePath)
|
||||||
|
const mtime = Math.floor(stats.mtimeMs)
|
||||||
|
sessions.push({
|
||||||
|
id: sessionID,
|
||||||
|
projectID: projectDir.name,
|
||||||
|
directory: directory || "",
|
||||||
|
time: {
|
||||||
|
created: mtime,
|
||||||
|
updated: mtime,
|
||||||
|
},
|
||||||
|
load_error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,6 +397,62 @@ describe("session-manager storage - getMainSessions", () => {
|
|||||||
expect(sessionsB[0].id).toBe("ses_projB")
|
expect(sessionsB[0].id).toBe("ses_projB")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("getMainSessions includes corrupted JSON files with mtime fallback and load_error", async () => {
|
||||||
|
//#given
|
||||||
|
const projectID = "proj_corrupt"
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Create a valid session
|
||||||
|
createSessionMetadata(projectID, "ses_valid", { directory: "/test/path", updated: now - 1000 })
|
||||||
|
|
||||||
|
// Create a corrupted session file
|
||||||
|
const projectDir = join(TEST_SESSION_STORAGE, projectID)
|
||||||
|
mkdirSync(projectDir, { recursive: true })
|
||||||
|
const corruptFile = join(projectDir, "ses_corrupt.json")
|
||||||
|
writeFileSync(corruptFile, "{ invalid json", "utf-8")
|
||||||
|
|
||||||
|
// Manually set the mtime to be newer than the valid session
|
||||||
|
// Note: we can't easily control mtime in tests, so we'll verify it's included with load_error
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const sessions = await storage.getMainSessions({ directory: "/test/path" })
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(sessions.length).toBe(2)
|
||||||
|
const corruptSession = sessions.find((s) => s.id === "ses_corrupt")
|
||||||
|
expect(corruptSession).toBeDefined()
|
||||||
|
expect(corruptSession?.load_error).toBeTruthy()
|
||||||
|
expect(corruptSession?.time.updated).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
test("getMainSessions sorts corrupted JSON files alongside valid sessions using mtime", async () => {
|
||||||
|
//#given
|
||||||
|
const projectID = "proj_sort"
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
createSessionMetadata(projectID, "ses_old", { directory: "/test/path", updated: now - 5000 })
|
||||||
|
createSessionMetadata(projectID, "ses_new", { directory: "/test/path", updated: now })
|
||||||
|
|
||||||
|
// Create a corrupted file with mtime in between
|
||||||
|
const projectDir = join(TEST_SESSION_STORAGE, projectID)
|
||||||
|
mkdirSync(projectDir, { recursive: true })
|
||||||
|
const corruptFile = join(projectDir, "ses_mid.json")
|
||||||
|
writeFileSync(corruptFile, "{ invalid json", "utf-8")
|
||||||
|
|
||||||
|
// Touch the file to set mtime between old and new
|
||||||
|
// We use utimesSync to set a specific mtime
|
||||||
|
const { utimesSync } = require("node:fs")
|
||||||
|
utimesSync(corruptFile, new Date(now - 2500), new Date(now - 2500))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const sessions = await storage.getMainSessions({ directory: "/test/path" })
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(sessions.length).toBe(3)
|
||||||
|
expect(sessions[0].id).toBe("ses_new")
|
||||||
|
expect(sessions[1].id).toBe("ses_mid")
|
||||||
|
expect(sessions[2].id).toBe("ses_old")
|
||||||
|
})
|
||||||
|
|
||||||
test("getMainSessions returns all main sessions when directory is not specified", async () => {
|
test("getMainSessions returns all main sessions when directory is not specified", async () => {
|
||||||
// given
|
// given
|
||||||
const projectA = "proj_aaa"
|
const projectA = "proj_aaa"
|
||||||
|
|||||||
@@ -49,7 +49,22 @@ export interface SearchResult {
|
|||||||
timestamp?: number
|
timestamp?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionMetadata {
|
id: string
|
||||||
|
version?: string
|
||||||
|
projectID: string
|
||||||
|
directory: string
|
||||||
|
title?: string
|
||||||
|
parentID?: string
|
||||||
|
time: {
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
}
|
||||||
|
summary?: {
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
files: number
|
||||||
|
}
|
||||||
|
load_error?: string
|
||||||
id: string
|
id: string
|
||||||
version?: string
|
version?: string
|
||||||
projectID: string
|
projectID: string
|
||||||
|
|||||||
Reference in New Issue
Block a user