mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
perf(anthropic): memoize the local Claude session scan (#114833)
* perf(anthropic): memoize the local Claude session scan * fix(anthropic): make the scan memo freshness gate race-safe
This commit is contained in:
committed by
GitHub
parent
1d67a55681
commit
92fff941ef
@@ -1579,7 +1579,7 @@ describe("Claude session catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses cached metadata for unchanged discovered transcripts", async () => {
|
||||
it("reuses the assembled scan within the TTL without per-file filesystem work", async () => {
|
||||
const home = await createHome();
|
||||
const sessionIds = ["cached-session-a", "cached-session-b"];
|
||||
await writeProject({
|
||||
@@ -1589,18 +1589,32 @@ describe("Claude session catalog", () => {
|
||||
sessionIds.map((sessionId) => [sessionId, [sdkCliMessage(sessionId, sessionId)]]),
|
||||
),
|
||||
});
|
||||
const realpathSpy = vi.spyOn(fs, "realpath");
|
||||
const statSpy = vi.spyOn(fs, "stat");
|
||||
const openSpy = vi.spyOn(fs, "open");
|
||||
const readFileSpy = vi.spyOn(fs, "readFile");
|
||||
|
||||
const first = await listLocalClaudeSessionPage({}, home);
|
||||
expect(openSpy).toHaveBeenCalledTimes(2);
|
||||
realpathSpy.mockClear();
|
||||
statSpy.mockClear();
|
||||
openSpy.mockClear();
|
||||
readFileSpy.mockClear();
|
||||
|
||||
const second = await listLocalClaudeSessionPage({}, home);
|
||||
expect(second).toEqual(first);
|
||||
const isCatalogFile = (value: unknown) =>
|
||||
typeof value === "string" &&
|
||||
(value.endsWith(".jsonl") ||
|
||||
value.endsWith("sessions-index.json") ||
|
||||
path.basename(value).startsWith("local_"));
|
||||
expect(realpathSpy.mock.calls.filter(([filePath]) => isCatalogFile(filePath))).toEqual([]);
|
||||
expect(statSpy.mock.calls.filter(([filePath]) => isCatalogFile(filePath))).toEqual([]);
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
expect(readFileSpy.mock.calls.filter(([filePath]) => isCatalogFile(filePath))).toEqual([]);
|
||||
});
|
||||
|
||||
it("rescans only a changed transcript and refreshes a negative result", async () => {
|
||||
it("invalidates the assembled scan on a project directory mtime change", async () => {
|
||||
const home = await createHome();
|
||||
const projectDir = path.join(home, ".claude", "projects", "-workspace");
|
||||
const changedPath = path.join(projectDir, "changed-session.jsonl");
|
||||
@@ -1621,6 +1635,7 @@ describe("Claude session catalog", () => {
|
||||
);
|
||||
const changedTime = new Date(Date.now() + 2_000);
|
||||
await fs.utimes(changedPath, changedTime, changedTime);
|
||||
await fs.utimes(projectDir, changedTime, changedTime);
|
||||
const resolvedChangedPath = await fs.realpath(changedPath);
|
||||
const resolvedUnchangedPath = await fs.realpath(unchangedPath);
|
||||
openSpy.mockClear();
|
||||
@@ -1640,14 +1655,17 @@ describe("Claude session catalog", () => {
|
||||
const home = await createHome();
|
||||
const projectDir = path.join(home, ".claude", "projects", "-workspace");
|
||||
const newPath = path.join(projectDir, "new-session.jsonl");
|
||||
const fixedDirectoryTime = new Date("2026-07-20T12:00:00.000Z");
|
||||
await writeProject({
|
||||
home,
|
||||
entries: [],
|
||||
transcripts: { "existing-session": [sdkCliMessage("existing-session", "Existing")] },
|
||||
});
|
||||
await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime);
|
||||
const openSpy = vi.spyOn(fs, "open");
|
||||
await listLocalClaudeSessionPage({}, home);
|
||||
await fs.writeFile(newPath, `${JSON.stringify(sdkCliMessage("new-session", "New"))}\n`);
|
||||
await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime);
|
||||
const resolvedNewPath = await fs.realpath(newPath);
|
||||
openSpy.mockClear();
|
||||
|
||||
@@ -1659,6 +1677,160 @@ describe("Claude session catalog", () => {
|
||||
expect(openSpy.mock.calls.map(([filePath]) => filePath)).toEqual([resolvedNewPath]);
|
||||
});
|
||||
|
||||
it("refreshes a warm catalog when a specific new transcript is requested", async () => {
|
||||
const home = await createHome();
|
||||
const projectDir = path.join(home, ".claude", "projects", "-workspace");
|
||||
const sessionId = "just-created-session";
|
||||
const fixedDirectoryTime = new Date("2026-07-20T12:00:00.000Z");
|
||||
await writeProject({
|
||||
home,
|
||||
entries: [],
|
||||
transcripts: { "existing-session": [sdkCliMessage("existing-session", "Existing")] },
|
||||
});
|
||||
await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime);
|
||||
await listLocalClaudeSessionPage({}, home);
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, `${sessionId}.jsonl`),
|
||||
`${JSON.stringify(sdkCliMessage(sessionId, "New transcript"))}\n`,
|
||||
);
|
||||
// Keep the directory fingerprint unchanged so this exercises the per-id miss refresh rather
|
||||
// than the normal catalog invalidation path.
|
||||
await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime);
|
||||
|
||||
await expect(
|
||||
readLocalClaudeTranscriptPage({ threadId: sessionId, limit: 1 }, home),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
items: [expect.objectContaining({ text: "New transcript" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keys index and Desktop metadata parse caches by path, mtime, and size", async () => {
|
||||
const home = await createHome();
|
||||
const projectDir = path.join(home, ".claude", "projects", "-workspace");
|
||||
const indexPath = path.join(projectDir, "sessions-index.json");
|
||||
const desktopPath = path.join(
|
||||
home,
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"claude-code-sessions",
|
||||
"account",
|
||||
"workspace",
|
||||
"local_metadata-cache.json",
|
||||
);
|
||||
const indexedPath = path.join(projectDir, "indexed-session.jsonl");
|
||||
const desktopTranscriptPath = path.join(projectDir, "desktop-session.jsonl");
|
||||
const entries = [
|
||||
{
|
||||
sessionId: "indexed-session",
|
||||
fullPath: indexedPath,
|
||||
summary: "Indexed before",
|
||||
isSidechain: false,
|
||||
},
|
||||
{
|
||||
sessionId: "desktop-session",
|
||||
fullPath: desktopTranscriptPath,
|
||||
summary: "Desktop index",
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
await writeProject({
|
||||
home,
|
||||
entries,
|
||||
transcripts: {
|
||||
"indexed-session": [message("indexed-session", "user", "Indexed", 1)],
|
||||
"desktop-session": [message("desktop-session", "user", "Desktop", 1)],
|
||||
},
|
||||
});
|
||||
await writeDesktopMetadata(home, "metadata-cache", {
|
||||
cliSessionId: "desktop-session",
|
||||
title: "Desktop before",
|
||||
});
|
||||
const readFileSpy = vi.spyOn(fs, "readFile");
|
||||
const metadataReads = () =>
|
||||
readFileSpy.mock.calls
|
||||
.map(([filePath]) => filePath)
|
||||
.filter((filePath) => filePath === indexPath || filePath === desktopPath);
|
||||
|
||||
await listLocalClaudeSessionPage({}, home);
|
||||
expect(metadataReads()).toEqual(expect.arrayContaining([indexPath, desktopPath]));
|
||||
const firstRefreshTime = new Date(Date.now() + 2_000);
|
||||
await fs.utimes(projectDir, firstRefreshTime, firstRefreshTime);
|
||||
readFileSpy.mockClear();
|
||||
|
||||
await listLocalClaudeSessionPage({}, home);
|
||||
expect(metadataReads()).toEqual([]);
|
||||
|
||||
await fs.writeFile(
|
||||
indexPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: [{ ...entries[0], summary: "Indexed after a longer title" }, entries[1]],
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
desktopPath,
|
||||
JSON.stringify({
|
||||
cliSessionId: "desktop-session",
|
||||
title: "Desktop after a longer title",
|
||||
}),
|
||||
);
|
||||
const secondRefreshTime = new Date(Date.now() + 4_000);
|
||||
await Promise.all([
|
||||
fs.utimes(indexPath, secondRefreshTime, secondRefreshTime),
|
||||
fs.utimes(desktopPath, secondRefreshTime, secondRefreshTime),
|
||||
fs.utimes(projectDir, secondRefreshTime, secondRefreshTime),
|
||||
]);
|
||||
readFileSpy.mockClear();
|
||||
|
||||
const refreshed = await listLocalClaudeSessionPage({}, home);
|
||||
expect(metadataReads()).toEqual(expect.arrayContaining([indexPath, desktopPath]));
|
||||
expect(
|
||||
Object.fromEntries(refreshed.sessions.map((record) => [record.threadId, record.name])),
|
||||
).toEqual({
|
||||
"desktop-session": "Desktop after a longer title",
|
||||
"indexed-session": "Indexed after a longer title",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries transient index reads without waiting for the file metadata to change", async () => {
|
||||
const home = await createHome();
|
||||
const projectDir = path.join(home, ".claude", "projects", "-workspace");
|
||||
const indexPath = path.join(projectDir, "sessions-index.json");
|
||||
const sessionId = "retry-index-session";
|
||||
await writeProject({
|
||||
home,
|
||||
entries: [
|
||||
{
|
||||
sessionId,
|
||||
fullPath: path.join(projectDir, `${sessionId}.jsonl`),
|
||||
summary: "Recovered index",
|
||||
isSidechain: false,
|
||||
},
|
||||
],
|
||||
transcripts: { [sessionId]: [message(sessionId, "user", "Indexed only", 1)] },
|
||||
});
|
||||
const readFile = fs.readFile.bind(fs);
|
||||
let failIndexRead = true;
|
||||
vi.spyOn(fs, "readFile").mockImplementation(async (...args) => {
|
||||
if (failIndexRead && args[0] === indexPath) {
|
||||
failIndexRead = false;
|
||||
throw new Error("transient index read failure");
|
||||
}
|
||||
return await readFile(...args);
|
||||
});
|
||||
|
||||
expect((await listLocalClaudeSessionPage({}, home)).sessions).toEqual([]);
|
||||
const refreshTime = new Date(Date.now() + 2_000);
|
||||
await fs.utimes(projectDir, refreshTime, refreshTime);
|
||||
|
||||
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
|
||||
sessions: [{ threadId: sessionId, name: "Recovered index" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts a deleted transcript after a complete scan", async () => {
|
||||
const home = await createHome();
|
||||
const projectDir = path.join(home, ".claude", "projects", "-workspace");
|
||||
@@ -1671,13 +1843,16 @@ describe("Claude session catalog", () => {
|
||||
transcripts: { [sessionId]: [sdkCliMessage(sessionId, "Alpha")] },
|
||||
});
|
||||
await fs.utimes(transcriptPath, fixedTime, fixedTime);
|
||||
await fs.utimes(projectDir, fixedTime, fixedTime);
|
||||
const originalStat = await fs.stat(transcriptPath);
|
||||
await listLocalClaudeSessionPage({}, home);
|
||||
|
||||
await fs.rm(transcriptPath);
|
||||
await fs.utimes(projectDir, fixedTime, fixedTime);
|
||||
expect((await listLocalClaudeSessionPage({}, home)).sessions).toEqual([]);
|
||||
await fs.writeFile(transcriptPath, `${JSON.stringify(sdkCliMessage(sessionId, "Bravo"))}\n`);
|
||||
await fs.utimes(transcriptPath, fixedTime, fixedTime);
|
||||
await fs.utimes(projectDir, fixedTime, fixedTime);
|
||||
const recreatedStat = await fs.stat(transcriptPath);
|
||||
expect({ mtimeMs: recreatedStat.mtimeMs, size: recreatedStat.size }).toEqual({
|
||||
mtimeMs: originalStat.mtimeMs,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Dirent, Stats } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -62,6 +63,9 @@ const MAX_SEARCH_LENGTH = 500;
|
||||
const MAX_SESSION_PULL_REQUESTS = 20;
|
||||
const MAX_CATALOG_DISCOVERY_FILES = 10_000;
|
||||
const MAX_CATALOG_DISCOVERY_CACHE_ENTRIES = 20_000;
|
||||
const MAX_CATALOG_JSON_CACHE_ENTRIES = 4_000;
|
||||
const MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES = 8;
|
||||
const CLAUDE_SESSION_SCAN_TTL_MS = 5_000;
|
||||
const CLAUDE_METADATA_PREFIX_BYTES = 1024 * 1024;
|
||||
const CLAUDE_METADATA_READ_CHUNK_BYTES = 16 * 1024;
|
||||
const MAX_CATALOG_METADATA_SCAN_BYTES = 64 * 1024 * 1024;
|
||||
@@ -137,20 +141,38 @@ type CatalogDiscoveryCacheEntry = {
|
||||
sidechain: boolean;
|
||||
};
|
||||
|
||||
const catalogDiscoveryCache = new Map<string, CatalogDiscoveryCacheEntry>();
|
||||
type CatalogJsonCacheEntry = {
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
function cacheCatalogDiscovery(filePath: string, entry: CatalogDiscoveryCacheEntry): void {
|
||||
catalogDiscoveryCache.delete(filePath);
|
||||
catalogDiscoveryCache.set(filePath, entry);
|
||||
while (catalogDiscoveryCache.size > MAX_CATALOG_DISCOVERY_CACHE_ENTRIES) {
|
||||
const oldestPath = catalogDiscoveryCache.keys().next().value;
|
||||
if (oldestPath === undefined) {
|
||||
type ClaudeSessionScanCacheEntry = {
|
||||
treeStamp: string;
|
||||
expiresAt: number;
|
||||
records: Promise<CatalogRecord[]>;
|
||||
};
|
||||
|
||||
const catalogDiscoveryCache = new Map<string, CatalogDiscoveryCacheEntry>();
|
||||
const catalogJsonCache = new Map<string, CatalogJsonCacheEntry>();
|
||||
const claudeSessionScanCache = new Map<string, ClaudeSessionScanCacheEntry>();
|
||||
|
||||
function setBoundedCache<K, V>(cache: Map<K, V>, key: K, value: V, maxEntries: number): void {
|
||||
cache.delete(key);
|
||||
cache.set(key, value);
|
||||
while (cache.size > maxEntries) {
|
||||
const oldest = cache.keys().next();
|
||||
if (oldest.done) {
|
||||
break;
|
||||
}
|
||||
catalogDiscoveryCache.delete(oldestPath);
|
||||
cache.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
|
||||
function cacheCatalogDiscovery(filePath: string, entry: CatalogDiscoveryCacheEntry): void {
|
||||
setBoundedCache(catalogDiscoveryCache, filePath, entry, MAX_CATALOG_DISCOVERY_CACHE_ENTRIES);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
@@ -273,7 +295,7 @@ async function safeSessionFile(
|
||||
resolvedRoot: string,
|
||||
candidate: string,
|
||||
sessionId: string,
|
||||
): Promise<string | undefined> {
|
||||
): Promise<{ filePath: string; stat: Stats } | undefined> {
|
||||
if (!isWithin(root, candidate) || path.basename(candidate) !== `${sessionId}.jsonl`) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -283,15 +305,32 @@ async function safeSessionFile(
|
||||
return undefined;
|
||||
}
|
||||
const stat = await fs.stat(resolvedCandidate);
|
||||
return stat.isFile() ? resolvedCandidate : undefined;
|
||||
return stat.isFile() ? { filePath: resolvedCandidate, stat } : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<unknown> {
|
||||
const stat = await fs.stat(filePath).catch(() => undefined);
|
||||
if (!stat?.isFile()) {
|
||||
catalogJsonCache.delete(filePath);
|
||||
return undefined;
|
||||
}
|
||||
const cached = catalogJsonCache.get(filePath);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||
setBoundedCache(catalogJsonCache, filePath, cached, MAX_CATALOG_JSON_CACHE_ENTRIES);
|
||||
return cached.value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
|
||||
const value = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
|
||||
setBoundedCache(
|
||||
catalogJsonCache,
|
||||
filePath,
|
||||
{ mtimeMs: stat.mtimeMs, size: stat.size, value },
|
||||
MAX_CATALOG_JSON_CACHE_ENTRIES,
|
||||
);
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -311,6 +350,34 @@ function projectsDir(homeDir: string): string {
|
||||
return path.join(homeDir, ".claude", "projects");
|
||||
}
|
||||
|
||||
async function readProjectsTreeStamp(root: string): Promise<string> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
const directoryNames = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.toSorted();
|
||||
const directorySnapshots = await Promise.all(
|
||||
directoryNames.map(async (name) => {
|
||||
const directory = path.join(root, name);
|
||||
const [stat, childNames] = await Promise.all([
|
||||
fs.stat(directory).catch(() => undefined),
|
||||
fs.readdir(directory).catch(() => undefined),
|
||||
]);
|
||||
return [
|
||||
name,
|
||||
stat?.isDirectory() === true ? stat.mtimeMs : null,
|
||||
childNames?.toSorted() ?? null,
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
return JSON.stringify(directorySnapshots);
|
||||
}
|
||||
|
||||
function desktopSessionsDir(homeDir: string): string {
|
||||
return path.join(homeDir, "Library", "Application Support", "Claude", "claude-code-sessions");
|
||||
}
|
||||
@@ -394,13 +461,13 @@ async function readIndexRecords(homeDir: string): Promise<{
|
||||
continue;
|
||||
}
|
||||
const indexedPath = optionalString(entry.fullPath, MAX_STRING_LENGTH);
|
||||
const filePath = await safeSessionFile(
|
||||
const safeFile = await safeSessionFile(
|
||||
root,
|
||||
resolvedRoot,
|
||||
indexedPath ?? path.join(projectDir, `${sessionId}.jsonl`),
|
||||
sessionId,
|
||||
);
|
||||
if (!filePath) {
|
||||
if (!safeFile) {
|
||||
continue;
|
||||
}
|
||||
const createdAt = timestampMs(entry.created);
|
||||
@@ -420,7 +487,7 @@ async function readIndexRecords(homeDir: string): Promise<{
|
||||
? { gitBranch: optionalString(entry.gitBranch, 500) }
|
||||
: {}),
|
||||
archived: false,
|
||||
filePath,
|
||||
filePath: safeFile.filePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -435,9 +502,9 @@ async function locateSessionFile(homeDir: string, sessionId: string): Promise<st
|
||||
}
|
||||
for (const projectDir of await childDirectories(root)) {
|
||||
const candidate = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
const filePath = await safeSessionFile(root, resolvedRoot, candidate, sessionId);
|
||||
if (filePath) {
|
||||
return filePath;
|
||||
const safeFile = await safeSessionFile(root, resolvedRoot, candidate, sessionId);
|
||||
if (safeFile) {
|
||||
return safeFile.filePath;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -484,20 +551,17 @@ async function discoverCliRecords(
|
||||
if (!sessionId || records.has(sessionId) || sidechainIds.has(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
const filePath = await safeSessionFile(
|
||||
const safeFile = await safeSessionFile(
|
||||
root,
|
||||
resolvedRoot,
|
||||
path.join(projectDir, name),
|
||||
sessionId,
|
||||
);
|
||||
if (!filePath) {
|
||||
if (!safeFile) {
|
||||
continue;
|
||||
}
|
||||
const { filePath, stat: fileStat } = safeFile;
|
||||
seenFilePaths.add(filePath);
|
||||
const fileStat = await fs.stat(filePath).catch(() => undefined);
|
||||
if (!fileStat?.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const cached = catalogDiscoveryCache.get(filePath);
|
||||
// Claude transcripts only append while active, then stay static, so mtime+size+ino identify
|
||||
// the parsed content (ino also rejects an atomic replacement that reused the same mtime/size),
|
||||
@@ -671,7 +735,7 @@ async function discoverCliRecords(
|
||||
}
|
||||
}
|
||||
|
||||
async function listClaudeSessions(homeDir = currentHomeDir()): Promise<CatalogRecord[]> {
|
||||
async function scanClaudeSessions(homeDir: string): Promise<CatalogRecord[]> {
|
||||
const [indexed, desktop] = await Promise.all([
|
||||
readIndexRecords(homeDir),
|
||||
readDesktopMetadata(homeDir),
|
||||
@@ -718,6 +782,42 @@ async function listClaudeSessions(homeDir = currentHomeDir()): Promise<CatalogRe
|
||||
});
|
||||
}
|
||||
|
||||
async function listClaudeSessions(
|
||||
homeDir = currentHomeDir(),
|
||||
options: { forceRefresh?: boolean } = {},
|
||||
): Promise<CatalogRecord[]> {
|
||||
const root = projectsDir(homeDir);
|
||||
const treeStamp = await readProjectsTreeStamp(root);
|
||||
const cached = claudeSessionScanCache.get(root);
|
||||
// This tree stamp is the catalog cache's cheap freshness owner: exact child membership makes
|
||||
// same-tick adds, deletes, and renames visible even when a filesystem reuses the directory mtime.
|
||||
// File appends and Desktop-only changes may stay stale for at most the five-second TTL.
|
||||
if (
|
||||
options.forceRefresh !== true &&
|
||||
cached &&
|
||||
cached.treeStamp === treeStamp &&
|
||||
cached.expiresAt > Date.now()
|
||||
) {
|
||||
setBoundedCache(claudeSessionScanCache, root, cached, MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES);
|
||||
return await cached.records;
|
||||
}
|
||||
const records = scanClaudeSessions(homeDir);
|
||||
const entry = {
|
||||
treeStamp,
|
||||
expiresAt: Date.now() + CLAUDE_SESSION_SCAN_TTL_MS,
|
||||
records,
|
||||
};
|
||||
setBoundedCache(claudeSessionScanCache, root, entry, MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES);
|
||||
try {
|
||||
return await records;
|
||||
} catch (error) {
|
||||
if (claudeSessionScanCache.get(root) === entry) {
|
||||
claudeSessionScanCache.delete(root);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeOffset(offset: number): string {
|
||||
return Buffer.from(JSON.stringify({ offset }), "utf8").toString("base64url");
|
||||
}
|
||||
@@ -852,9 +952,16 @@ export async function readLocalClaudeTranscriptPage(
|
||||
homeDir = currentHomeDir(),
|
||||
): Promise<Omit<ClaudeSessionTranscriptPage, "hostId" | "label">> {
|
||||
const params = readTranscriptParams(value);
|
||||
const filePath = (await listClaudeSessions(homeDir)).find(
|
||||
let filePath = (await listClaudeSessions(homeDir)).find(
|
||||
(record) => record.threadId === params.threadId,
|
||||
)?.filePath;
|
||||
if (!filePath) {
|
||||
// A just-created session can predate the short catalog TTL. Specific reads must retry against
|
||||
// disk so opening a new thread never fails only because the sidebar snapshot is still warm.
|
||||
filePath = (await listClaudeSessions(homeDir, { forceRefresh: true })).find(
|
||||
(record) => record.threadId === params.threadId,
|
||||
)?.filePath;
|
||||
}
|
||||
if (!filePath) {
|
||||
throw new ClaudeCatalogParamsError("Claude session is unavailable");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user