Files

231 lines
7.5 KiB
JavaScript
Raw Permalink Normal View History

import crypto from 'crypto';
import fs from 'fs';
import fsp from 'fs/promises';
import os from 'os';
import path from 'path';
import { PROMPT_VERSION, WALKTHROUGH_VERSION } from './schema.js';
// Two artifacts with two different jobs.
//
// Cache entries are content-addressed and immutable: the key is derived from
// the *current* diff, so a hit means "this walkthrough was written about
// exactly this code". There is no freshness question to ask of an entry —
// staleness is a miss.
//
// The pointer is mutable and keyed by repository + source only. It answers the
// questions the cache cannot: which walkthrough was the last one here, what was
// it written about, and has the code moved since. It is also what feeds the
// previous walkthrough into a regeneration.
const DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const WALKTHROUGH_DIR = path.join(DATA_DIR, 'walkthroughs');
const ENTRIES_DIR = path.join(WALKTHROUGH_DIR, 'entries');
const POINTERS_DIR = path.join(WALKTHROUGH_DIR, 'pointers');
const MAX_ENTRIES = 200;
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
const MAX_FILE_BYTES = 4 * 1024 * 1024;
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const ensureDir = (dir) => {
try {
fs.mkdirSync(dir, { recursive: true });
return true;
} catch (error) {
console.error('[walkthrough] failed to create store directory:', error?.message || error);
return false;
}
};
// Atomic so a crash mid-write leaves the previous entry intact rather than a
// half-written file that later fails to parse.
const writeJsonAtomic = (filePath, value) => {
if (!ensureDir(path.dirname(filePath))) return false;
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
fs.writeFileSync(tmp, JSON.stringify(value), 'utf8');
fs.renameSync(tmp, filePath);
return true;
} catch (error) {
console.error('[walkthrough] failed to write store file:', error?.message || error);
try {
fs.unlinkSync(tmp);
} catch {
// Nothing else to do; the temp file is already orphaned.
}
return false;
}
};
const readJson = (filePath) => {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
// Missing, unreadable, or corrupt all mean the same thing to callers: no
// usable cached walkthrough. Never throw — a bad cache file must not break
// the feature.
return null;
}
};
/**
* Content-addressed key. Every input that can change the output is in here:
* change any of them and you get a miss rather than a stale hit.
*/
export function buildCacheKey({ repoRoot, sourceKey, providerID, modelID, language, files }) {
const canonical = JSON.stringify({
walkthroughVersion: WALKTHROUGH_VERSION,
promptVersion: PROMPT_VERSION,
repoRoot,
sourceKey,
providerID,
modelID,
// Without this, switching language hits the entry written in the previous
// one and the panel answers a request to translate with the untranslated
// text it already had.
language,
files: [...files]
.map((file) => ({ path: file.path, status: file.status, hunkIds: file.hunks.map((hunk) => hunk.id) }))
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
});
return sha256(canonical);
}
const entryPath = (cacheKey) => path.join(ENTRIES_DIR, `${cacheKey}.json`);
const pointerPath = (repoRoot, sourceKey) => path.join(POINTERS_DIR, `${sha256(`${repoRoot}\0${sourceKey}`)}.json`);
const isWalkthroughEntry = (value) => Boolean(
value
&& typeof value === 'object'
&& value.walkthroughVersion === WALKTHROUGH_VERSION
&& value.walkthrough
&& Array.isArray(value.walkthrough.chapters),
);
export function readCachedWalkthrough(cacheKey) {
const value = readJson(entryPath(cacheKey));
return isWalkthroughEntry(value) ? value : null;
}
export function writeCachedWalkthrough(cacheKey, entry) {
const written = writeJsonAtomic(entryPath(cacheKey), {
walkthroughVersion: WALKTHROUGH_VERSION,
...entry,
});
if (written) evictEntries();
return written;
}
export function readPointer(repoRoot, sourceKey) {
const value = readJson(pointerPath(repoRoot, sourceKey));
if (!value || typeof value !== 'object' || typeof value.cacheKey !== 'string') return null;
return value;
}
export function writePointer(repoRoot, sourceKey, pointer) {
return writeJsonAtomic(pointerPath(repoRoot, sourceKey), pointer);
}
/**
* Bound the cache by count and total size, dropping least-recently-used
* entries. Pointers are tiny and are left alone; a pointer to an evicted entry
* simply reads as "no walkthrough", which is the truthful answer.
*/
function evictEntries() {
let files;
try {
files = fs.readdirSync(ENTRIES_DIR)
.filter((name) => name.endsWith('.json'))
.map((name) => {
const full = path.join(ENTRIES_DIR, name);
try {
const stat = fs.statSync(full);
return { full, size: stat.size, atime: stat.atimeMs };
} catch {
return null;
}
})
.filter(Boolean);
} catch {
return;
}
let totalBytes = files.reduce((sum, file) => sum + file.size, 0);
if (files.length <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) return;
files.sort((a, b) => a.atime - b.atime);
let count = files.length;
for (const file of files) {
if (count <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) break;
try {
fs.unlinkSync(file.full);
count -= 1;
totalBytes -= file.size;
} catch {
// Skip files we cannot remove; the next write retries.
}
}
}
// Housekeeping runs off the request path and never synchronously.
//
// The Electron desktop app hosts this server inside the main process, so a
// blocking loop here stalls IPC and the window, not just one request. Worse,
// the paths being checked are user repositories: a worktree on an unplugged
// drive or an unreachable network share can make a single existence check hang
// for seconds. Async calls wait without holding the loop, and the cap keeps a
// pathological directory from turning into a long tail of work.
const PRUNE_LIMIT = 500;
/**
* Drop pointers for repositories that no longer exist. Only ever removes
* entries whose subject is provably gone.
*/
export async function pruneMissingRepositories() {
let names;
try {
names = (await fsp.readdir(POINTERS_DIR)).filter((name) => name.endsWith('.json'));
} catch {
return 0;
}
let removed = 0;
for (const name of names.slice(0, PRUNE_LIMIT)) {
const full = path.join(POINTERS_DIR, name);
let repoRoot = null;
try {
const value = JSON.parse(await fsp.readFile(full, 'utf8'));
repoRoot = value && typeof value.repoRoot === 'string' ? value.repoRoot : null;
} catch {
continue;
}
if (!repoRoot) continue;
try {
await fsp.stat(repoRoot);
continue;
} catch (error) {
// Unreachable is not the same as gone. Only a definite "no such file"
// justifies deleting: a disconnected share or a permissions error must
// not cost the user their walkthroughs.
if (error?.code !== 'ENOENT') continue;
}
try {
await fsp.unlink(full);
removed += 1;
} catch {
// Leave it; the next prune retries.
}
}
return removed;
}
export const __testing = { WALKTHROUGH_DIR, ENTRIES_DIR, POINTERS_DIR, MAX_ENTRIES, MAX_TOTAL_BYTES };