fix(coding-agent): harden archived stats cleanup

This commit is contained in:
Sunil Srivatsa
2026-08-01 17:17:46 -04:00
parent f3a555ac78
commit 980ab4fd0a
9 changed files with 702 additions and 129 deletions
+282 -122
View File
@@ -433,6 +433,7 @@ function sessionArtifactsPath(sessionPath: string): string {
interface SessionLineageHeader {
id: string;
parentSession?: string;
previousSessionFiles: string[];
}
function sessionLineageHeaderFromText(text: string): SessionLineageHeader | undefined {
@@ -441,7 +442,12 @@ function sessionLineageHeaderFromText(text: string): SessionLineageHeader | unde
const line = rawLine.trim();
if (!line) continue;
try {
const record = JSON.parse(line) as { type?: unknown; id?: unknown; parentSession?: unknown };
const record = JSON.parse(line) as {
type?: unknown;
id?: unknown;
parentSession?: unknown;
previousSessionFiles?: unknown;
};
if (!sawTitleSlot && record.type === "title") {
sawTitleSlot = true;
continue;
@@ -450,6 +456,12 @@ function sessionLineageHeaderFromText(text: string): SessionLineageHeader | unde
return {
id: record.id,
parentSession: typeof record.parentSession === "string" ? record.parentSession : undefined,
previousSessionFiles: Array.isArray(record.previousSessionFiles)
? record.previousSessionFiles.filter(
(previousSessionFile): previousSessionFile is string =>
typeof previousSessionFile === "string" && previousSessionFile.length > 0,
)
: [],
};
} catch {
return undefined;
@@ -458,6 +470,19 @@ function sessionLineageHeaderFromText(text: string): SessionLineageHeader | unde
return undefined;
}
async function readSessionLineageHeader(file: string): Promise<SessionLineageHeader | undefined> {
const decoder = new TextDecoder();
const lines: string[] = [];
for await (const line of readLines(Bun.file(file).stream())) {
const decoded = decoder.decode(line).trim();
if (!decoded) continue;
lines.push(decoded);
const header = sessionLineageHeaderFromText(lines.join("\n"));
if (header || lines.length >= 2) return header;
}
return undefined;
}
async function gzipSessionFile(source: string, destination: string): Promise<void> {
await fs.mkdir(path.dirname(destination), { recursive: true });
const tempPath = `${destination}.${process.pid}.${Date.now()}.tmp`;
@@ -604,14 +629,17 @@ const STATS_IDENTITY_COLUMNS: Record<StatsEntryTable, readonly string[]> = {
tool_calls: ["entry_id", "timestamp", "tool_call_id"],
};
interface ArchivedStatsSession {
interface StatsSession {
path: string;
id: string;
parentSession?: string;
historicalPaths: string[];
identities: Record<StatsEntryTable, StatsEntryIdentity[]>;
}
interface StatsLineageNode extends ArchivedStatsSession {
interface StatsLineageNode extends StatsSession {
statsPaths: Set<string>;
identityKeys: Set<string>;
}
interface StatsEntryIdentity {
@@ -629,6 +657,33 @@ interface StatsCleanupPlan {
sessionPaths: string[];
retainedSessions: StatsLineageNode[];
transfers: StatsTransferTarget[];
archivedIdentityKeys: Set<string>;
preserveAll: boolean;
}
interface StatsCleanupContext {
plans: StatsCleanupPlan[];
incompleteRetainedSessions: StatsLineageNode[];
}
function createStatsIdentities(): Record<StatsEntryTable, StatsEntryIdentity[]> {
return {
messages: [],
user_messages: [],
tool_calls: [],
};
}
function statsIdentityKey(table: StatsEntryTable, identity: StatsEntryIdentity): string {
return `${table}\0${identity.entryId}\0${identity.timestamp}\0${identity.toolCallId}`;
}
function statsIdentityKeys(identities: Record<StatsEntryTable, StatsEntryIdentity[]>): Set<string> {
const keys = new Set<string>();
for (const table of STATS_ENTRY_TABLES) {
for (const identity of identities[table]) keys.add(statsIdentityKey(table, identity));
}
return keys;
}
function tableHasColumn(db: Database, table: string, column: string): boolean {
@@ -636,25 +691,20 @@ function tableHasColumn(db: Database, table: string, column: string): boolean {
return rows.some(row => row.name === column);
}
function collectStoredStatsSessionPaths(dbPath: string): string[] {
const db = new Database(dbPath);
try {
db.run("PRAGMA busy_timeout = 5000");
const sessionPaths = new Set<string>();
for (const table of STATS_SESSION_TABLES) {
if (!tableExists(db, table) || !tableHasColumn(db, table, "session_file")) continue;
const rows = db.prepare(`SELECT DISTINCT session_file FROM ${table}`).all() as Array<{
session_file?: string | null;
}>;
for (const row of rows) {
if (typeof row.session_file === "string") sessionPaths.add(row.session_file);
}
function collectStoredStatsSessionPaths(db: Database): string[] {
const sessionPaths = new Set<string>();
for (const table of STATS_SESSION_TABLES) {
if (!tableExists(db, table) || !tableHasColumn(db, table, "session_file")) continue;
const rows = db.prepare(`SELECT DISTINCT session_file FROM ${table}`).all() as Array<{
session_file?: string | null;
}>;
for (const row of rows) {
if (typeof row.session_file === "string") sessionPaths.add(row.session_file);
}
return [...sessionPaths];
} finally {
db.close();
}
return [...sessionPaths];
}
/**
* `/move` preserves the session filename and artifacts-directory basename.
* Recover the top-level path represented by any main or nested stats row so
@@ -674,6 +724,37 @@ function logicalSessionRootForStatsPath(statsPath: string, sessionPath: string):
}
}
function sessionPathEncodesId(sessionPath: string, sessionId: string): boolean {
const stem = path.basename(sessionPath, SESSION_SUFFIX);
return stem === sessionId || stem.endsWith(`_${sessionId}`);
}
function managedHistoricalSessionPaths(
header: SessionLineageHeader,
currentSessionPath: string,
sessionsRoot: string,
): string[] {
const root = path.resolve(sessionsRoot);
const filename = path.basename(currentSessionPath);
const paths = new Set<string>();
for (const previousSessionFile of header.previousSessionFiles) {
if (!path.isAbsolute(previousSessionFile) || path.basename(previousSessionFile) !== filename) continue;
const resolved = path.resolve(previousSessionFile);
const relative = path.relative(root, resolved);
if (
!relative ||
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative) ||
!resolved.endsWith(SESSION_SUFFIX)
) {
continue;
}
paths.add(resolved);
}
return [...paths];
}
function resolveLogicalSessionMatch(
statsPath: string,
nodes: StatsLineageNode[],
@@ -687,10 +768,14 @@ function resolveLogicalSessionMatch(
if (exact.length === 1) return exact[0];
if (exact.length > 1) return undefined;
const stem = matches[0] ? path.basename(matches[0].logicalRoot, SESSION_SUFFIX) : "";
const idMatches = matches.filter(match => stem === match.node.id || stem.endsWith(`_${match.node.id}`));
if (idMatches.length === 1) return idMatches[0];
return undefined;
const known = matches.filter(match =>
[...match.node.statsPaths].some(statsPath => path.resolve(statsPath) === path.resolve(match.logicalRoot)),
);
if (known.length === 1) return known[0];
if (known.length > 1) return undefined;
const idMatches = matches.filter(match => sessionPathEncodesId(match.logicalRoot, match.node.id));
return idMatches.length === 1 ? idMatches[0] : undefined;
}
/**
@@ -699,25 +784,31 @@ function resolveLogicalSessionMatch(
* unresolved so cleanup fails safe instead of transferring across sessions.
*/
function buildStatsCleanupPlans(
archivedSessions: ArchivedStatsSession[],
retainedSessions: SessionInfo[],
storedSessionPaths: string[],
): StatsCleanupPlan[] {
archivedSessions: StatsSession[],
retainedSessions: StatsSession[],
dbPath: string,
): StatsCleanupContext {
const archivedNodes: StatsLineageNode[] = archivedSessions.map(session => ({
...session,
statsPaths: new Set([session.path]),
statsPaths: new Set([session.path, ...session.historicalPaths]),
identityKeys: statsIdentityKeys(session.identities),
}));
const retainedNodes: StatsLineageNode[] = retainedSessions.map(session => ({
path: session.path,
id: session.id,
parentSession: session.parentSessionPath,
statsPaths: new Set([session.path]),
...session,
statsPaths: new Set([session.path, ...session.historicalPaths]),
identityKeys: statsIdentityKeys(session.identities),
}));
const nodes = [...archivedNodes, ...retainedNodes];
for (const storedPath of storedSessionPaths) {
const match = resolveLogicalSessionMatch(storedPath, nodes);
if (match) match.node.statsPaths.add(match.logicalRoot);
const db = new Database(dbPath);
try {
db.run("PRAGMA busy_timeout = 5000");
for (const storedPath of collectStoredStatsSessionPaths(db)) {
const match = resolveLogicalSessionMatch(storedPath, nodes);
if (match) match.node.statsPaths.add(match.logicalRoot);
}
} finally {
db.close();
}
for (const child of nodes) {
@@ -731,6 +822,17 @@ function buildStatsCleanupPlans(
if (match) match.node.statsPaths.add(match.logicalRoot);
}
const archivedPathClaimCounts = new Map<string, number>();
for (const archived of archivedNodes) {
for (const statsPath of archived.statsPaths) {
const key = path.resolve(statsPath);
archivedPathClaimCounts.set(key, (archivedPathClaimCounts.get(key) ?? 0) + 1);
}
}
const ambiguousArchivedPathKeys = new Set(
[...archivedPathClaimCounts].filter(([, count]) => count > 1).map(([key]) => key),
);
const aliases = new Map<string, StatsLineageNode | null>();
const identityKeysByNode = new Map<StatsLineageNode, Set<string>>();
for (const node of nodes) {
@@ -748,12 +850,17 @@ function buildStatsCleanupPlans(
}
}
const incompleteLineage = new Set<StatsLineageNode>();
const lineageKeysByNode = new Map<StatsLineageNode, Set<string>>();
for (const node of nodes) {
const lineageKeys = new Set(identityKeysByNode.get(node));
let current: StatsLineageNode | null = node;
const seen = new Set<StatsLineageNode>();
while (current?.parentSession && !seen.has(current)) {
while (current?.parentSession) {
if (seen.has(current)) {
incompleteLineage.add(node);
break;
}
seen.add(current);
const parentReference = current.parentSession;
const parentKey =
@@ -762,7 +869,10 @@ function buildStatsCleanupPlans(
: `id:${parentReference}`;
lineageKeys.add(parentKey);
const parent = aliases.get(parentKey);
if (!parent) break;
if (!parent) {
incompleteLineage.add(node);
break;
}
for (const key of identityKeysByNode.get(parent) ?? []) lineageKeys.add(key);
current = parent;
}
@@ -772,97 +882,130 @@ function buildStatsCleanupPlans(
const retainedPathKeys = new Set(
retainedNodes.flatMap(retained => [...retained.statsPaths].map(statsPath => path.resolve(statsPath))),
);
return archivedNodes.map(archived => {
const plans = archivedNodes.map(archived => {
const archivedLineage = lineageKeysByNode.get(archived) ?? new Set<string>();
return {
sessionPaths: [...archived.statsPaths].filter(statsPath => !retainedPathKeys.has(path.resolve(statsPath))),
sessionPaths: [...archived.statsPaths].filter(statsPath => {
const key = path.resolve(statsPath);
return !retainedPathKeys.has(key) && !ambiguousArchivedPathKeys.has(key);
}),
retainedSessions: retainedNodes.filter(retained => {
if (incompleteLineage.has(retained)) return false;
const retainedLineage = lineageKeysByNode.get(retained);
return retainedLineage ? [...retainedLineage].some(key => archivedLineage.has(key)) : false;
}),
transfers: [],
archivedIdentityKeys: archived.identityKeys,
preserveAll: false,
};
});
return {
plans,
incompleteRetainedSessions: retainedNodes.filter(retained => incompleteLineage.has(retained)),
};
}
function addSessionStatsIdentity(line: string, identities: Record<StatsEntryTable, StatsEntryIdentity[]>): void {
if (line.length === 0) return;
try {
const record: unknown = JSON.parse(line);
if (
!record ||
typeof record !== "object" ||
!("type" in record) ||
record.type !== "message" ||
!("id" in record) ||
typeof record.id !== "string" ||
record.id.length === 0 ||
!("message" in record) ||
!record.message ||
typeof record.message !== "object"
) {
return;
}
const message = record.message;
if (!("role" in message)) return;
const parsedEntryTimestamp =
"timestamp" in record && typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN;
if (message.role === "user") {
identities.user_messages.push({
entryId: record.id,
timestamp: Number.isFinite(parsedEntryTimestamp) ? parsedEntryTimestamp : 0,
toolCallId: "",
});
return;
}
if (message.role !== "assistant") return;
const timestamp =
"timestamp" in message && typeof message.timestamp === "number" && Number.isFinite(message.timestamp)
? message.timestamp
: Number.isFinite(parsedEntryTimestamp)
? parsedEntryTimestamp
: 0;
identities.messages.push({ entryId: record.id, timestamp, toolCallId: "" });
if (!("content" in message) || !Array.isArray(message.content)) return;
for (const block of message.content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
block.type === "toolCall" &&
"id" in block &&
typeof block.id === "string"
) {
identities.tool_calls.push({ entryId: record.id, timestamp, toolCallId: block.id });
}
}
} catch {
// Stats parsing is also lenient: a malformed line cannot own a retained row.
}
}
function collectSessionStatsIdentitiesFromText(text: string): Record<StatsEntryTable, StatsEntryIdentity[]> {
const identities = createStatsIdentities();
for (const line of text.split(/\r?\n/)) addSessionStatsIdentity(line, identities);
return identities;
}
async function collectSessionStatsIdentities(
sessionPath: string,
): Promise<Record<StatsEntryTable, StatsEntryIdentity[]>> {
const identities: Record<StatsEntryTable, StatsEntryIdentity[]> = {
messages: [],
user_messages: [],
tool_calls: [],
};
const identities = createStatsIdentities();
const decoder = new TextDecoder();
for await (const line of readLines(Bun.file(sessionPath).stream())) {
if (line.length === 0) continue;
try {
const record: unknown = JSON.parse(decoder.decode(line));
if (
!record ||
typeof record !== "object" ||
!("type" in record) ||
record.type !== "message" ||
!("id" in record) ||
typeof record.id !== "string" ||
record.id.length === 0 ||
!("message" in record) ||
!record.message ||
typeof record.message !== "object"
) {
continue;
}
const message = record.message;
if (!("role" in message)) continue;
const parsedEntryTimestamp =
"timestamp" in record && typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN;
if (message.role === "user") {
identities.user_messages.push({
entryId: record.id,
timestamp: Number.isFinite(parsedEntryTimestamp) ? parsedEntryTimestamp : 0,
toolCallId: "",
});
continue;
}
if (message.role !== "assistant") continue;
const timestamp =
"timestamp" in message && typeof message.timestamp === "number" && Number.isFinite(message.timestamp)
? message.timestamp
: Number.isFinite(parsedEntryTimestamp)
? parsedEntryTimestamp
: 0;
identities.messages.push({ entryId: record.id, timestamp, toolCallId: "" });
if (!("content" in message) || !Array.isArray(message.content)) continue;
for (const block of message.content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
block.type === "toolCall" &&
"id" in block &&
typeof block.id === "string" &&
block.id.length > 0
) {
identities.tool_calls.push({ entryId: record.id, timestamp, toolCallId: block.id });
}
}
} catch {
// Stats parsing is also lenient: a malformed line cannot own a retained row.
}
addSessionStatsIdentity(decoder.decode(line), identities);
}
return identities;
}
async function populateStatsTransferTargets(plans: StatsCleanupPlan[]): Promise<void> {
async function populateStatsTransferTargets(context: StatsCleanupContext): Promise<void> {
const identitiesBySession = new Map<string, Promise<Record<StatsEntryTable, StatsEntryIdentity[]>>>();
for (const plan of plans) {
const identitiesFor = (session: StatsLineageNode): Promise<Record<StatsEntryTable, StatsEntryIdentity[]>> => {
let identities = identitiesBySession.get(session.path);
if (!identities) {
identities = collectSessionStatsIdentities(session.path);
identitiesBySession.set(session.path, identities);
}
return identities;
};
const incompleteIdentityKeys = new Set<string>();
let hasUnidentifiableIncompleteSession = false;
for (const retained of context.incompleteRetainedSessions) {
const keys = statsIdentityKeys(await identitiesFor(retained));
if (keys.size === 0) hasUnidentifiableIncompleteSession = true;
for (const key of keys) incompleteIdentityKeys.add(key);
}
for (const plan of context.plans) {
if (context.incompleteRetainedSessions.length > 0) {
plan.preserveAll =
hasUnidentifiableIncompleteSession ||
plan.archivedIdentityKeys.size === 0 ||
[...plan.archivedIdentityKeys].some(key => incompleteIdentityKeys.has(key));
}
for (const retained of plan.retainedSessions) {
let identities = identitiesBySession.get(retained.path);
if (!identities) {
identities = collectSessionStatsIdentities(retained.path);
identitiesBySession.set(retained.path, identities);
}
plan.transfers.push({ path: retained.path, identities: await identities });
plan.transfers.push({ path: retained.path, identities: await identitiesFor(retained) });
}
}
}
@@ -933,6 +1076,7 @@ function reconcileStatsRowsForSessions(dbPath: string, plans: StatsCleanupPlan[]
let deleted = 0;
const tx = db.transaction((cleanupPlans: StatsCleanupPlan[]) => {
for (const plan of cleanupPlans) {
if (plan.preserveAll) continue;
clearRetainedEntries.run();
for (const target of plan.transfers) {
for (const table of entryTables) {
@@ -975,19 +1119,22 @@ async function collectArchivedStatsSessions(
archiveRoot: string,
sessionsRoot: string,
onError: (file: string, error: unknown) => void,
): Promise<ArchivedStatsSession[]> {
const sessions: ArchivedStatsSession[] = [];
): Promise<StatsSession[]> {
const sessions: StatsSession[] = [];
for (const file of await collectCompressedJsonlFiles(archiveRoot)) {
const relative = path.relative(archiveRoot, file);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) continue;
const sourcePath = path.join(sessionsRoot, relative.slice(0, -".gz".length));
try {
const header = sessionLineageHeaderFromText(await readTextIfPresent(file));
const text = await readTextIfPresent(file);
const header = sessionLineageHeaderFromText(text);
if (!header) throw new Error("archive is missing a valid session header");
sessions.push({
path: sourcePath,
id: header.id,
parentSession: header.parentSession,
historicalPaths: managedHistoricalSessionPaths(header, sourcePath, sessionsRoot),
identities: collectSessionStatsIdentitiesFromText(text),
});
} catch (error) {
onError(file, error);
@@ -1007,32 +1154,31 @@ async function cleanupStatsRowsForArchivedSessions(
? getStatsDbPath()
: path.join(options.agentDir, "stats.db");
if (!(await pathExists(dbPath))) return;
const sessionsRoot = getSessionsDir(options.agentDir);
const archivedByPath = new Map<string, ArchivedStatsSession>();
const archivedByPath = new Map<string, StatsSession>();
for (const session of newlyArchivedSessions) {
archivedByPath.set(path.resolve(session.path), {
path: session.path,
id: session.id,
parentSession: session.parentSessionPath,
historicalPaths: [],
identities: createStatsIdentities(),
});
}
let retainedSessions: SessionInfo[];
try {
for (const session of await collectArchivedStatsSessions(
archiveRoot,
getSessionsDir(options.agentDir),
(file, error) => {
result.errors.push(`stats cleanup scan ${file}: ${errorMessage(error)}`);
},
)) {
for (const session of await collectArchivedStatsSessions(archiveRoot, sessionsRoot, (file, error) => {
result.errors.push(`stats cleanup scan ${file}: ${errorMessage(error)}`);
})) {
archivedByPath.set(path.resolve(session.path), session);
}
} catch (error) {
result.errors.push(`stats cleanup scan: ${errorMessage(error)}`);
}
try {
retainedSessions = await listActiveSessions(getSessionsDir(options.agentDir));
retainedSessions = await listActiveSessions(sessionsRoot);
} catch (error) {
result.errors.push(`stats cleanup scan: ${errorMessage(error)}`);
return;
@@ -1040,10 +1186,24 @@ async function cleanupStatsRowsForArchivedSessions(
try {
await withStatsSyncLock(dbPath, async () => {
const storedSessionPaths = collectStoredStatsSessionPaths(dbPath);
const plans = buildStatsCleanupPlans([...archivedByPath.values()], retainedSessions, storedSessionPaths);
await populateStatsTransferTargets(plans);
result.statsRowsDeleted = reconcileStatsRowsForSessions(dbPath, plans);
const retainedStatsSessions = await Promise.all(
retainedSessions.map(async session => {
const header = await readSessionLineageHeader(session.path);
if (!header || header.id !== session.id) {
throw new Error(`session header changed during stats cleanup: ${session.path}`);
}
return {
path: session.path,
id: session.id,
parentSession: header.parentSession,
historicalPaths: managedHistoricalSessionPaths(header, session.path, sessionsRoot),
identities: createStatsIdentities(),
};
}),
);
const context = buildStatsCleanupPlans([...archivedByPath.values()], retainedStatsSessions, dbPath);
await populateStatsTransferTargets(context);
result.statsRowsDeleted = reconcileStatsRowsForSessions(dbPath, context.plans);
});
} catch (error) {
result.errors.push(`stats cleanup: ${errorMessage(error)}`);
+10 -3
View File
@@ -181,10 +181,17 @@ export interface SessionData {
subSessions?: Record<string, SubSession>;
}
function sessionHeaderForExport(header: SessionHeader | null): SessionHeader | null {
if (!header) return null;
const exported = { ...header };
delete exported.previousSessionFiles;
return exported;
}
/** Snapshot the session (plus optional agent state) into the JSON shape the viewer renders. */
export function buildSessionData(sm: SessionManager, state?: AgentState): SessionData {
return {
header: sm.getHeader(),
header: sessionHeaderForExport(sm.getHeader()),
entries: sm.getEntries(),
leafId: sm.getLeafId(),
systemPrompt: state?.systemPrompt.join("\n\n"),
@@ -231,7 +238,7 @@ async function collectSubSessionsFromDir(
out[key] = {
agentId,
parent: parentKey,
header,
header: sessionHeaderForExport(header),
entries,
leafId: entries.length > 0 ? entries[entries.length - 1].id : null,
};
@@ -295,7 +302,7 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
}
const sessionData: SessionData = {
header: sm.getHeader(),
header: sessionHeaderForExport(sm.getHeader()),
entries: sm.getEntries(),
leafId: sm.getLeafId(),
};
@@ -223,6 +223,7 @@ function collectShareRegexSecretValues(o: SecretObfuscator, data: SessionData):
if (!header) return;
add(header.title);
add(header.cwd);
for (const previousSessionFile of header.previousSessionFiles ?? []) add(previousSessionFile);
};
addHeader(data.header);
@@ -246,6 +247,9 @@ function redactShareHeader(
...header,
title: header.title === undefined ? undefined : o.obfuscate(header.title, sharedRegexSecretValues),
cwd: o.obfuscate(header.cwd, sharedRegexSecretValues),
previousSessionFiles: header.previousSessionFiles?.map(previousSessionFile =>
o.obfuscate(previousSessionFile, sharedRegexSecretValues),
),
};
}
@@ -39,6 +39,8 @@ export interface SessionHeader {
*/
additionalDirectories?: string[];
parentSession?: string;
/** Prior absolute JSONL locations recorded by successful session moves. */
previousSessionFiles?: string[];
/** Provider prompt-cache identity inherited by exact-route full forks. */
providerPromptCacheKey?: string;
}
@@ -1383,6 +1383,12 @@ export class SessionManager {
throw err;
}
if (sessionFileExisted && sessionPathChanged) {
this.#header.previousSessionFiles = [
...new Set([...(this.#header.previousSessionFiles ?? []), path.resolve(oldSessionFile)]),
];
}
this.#sessionFile = newSessionFile;
this.#artifactManager = null;
this.#artifactManagerSessionFile = null;
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
import { collectSubSessions } from "../src/export/html";
import { collectSubSessions, exportFromFile } from "../src/export/html";
/**
* Contract: a session at `<dir>/<name>.jsonl` embeds subagent transcripts from
@@ -11,9 +11,16 @@ import { collectSubSessions } from "../src/export/html";
* parent links and last-entry leaf ids. Corrupt/empty/backup files are skipped.
*/
function sessionJsonl(id: string, entryIds: string[]): string {
function sessionJsonl(id: string, entryIds: string[], previousSessionFiles?: string[]): string {
const lines = [
JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-06-12T00:00:00.000Z", cwd: "/tmp" }),
JSON.stringify({
type: "session",
version: 3,
id,
timestamp: "2026-06-12T00:00:00.000Z",
cwd: "/tmp",
previousSessionFiles,
}),
];
let parent: string | null = null;
for (const entryId of entryIds) {
@@ -60,6 +67,28 @@ describe("collectSubSessions", () => {
expect(subs.Beta).toMatchObject({ agentId: "Beta", parent: null, leafId: "b1" });
});
test("omits internal move history from standalone HTML", async () => {
const mainPreviousPath = "/Users/private/main.jsonl";
const subPreviousPath = "/Users/private/Alpha.jsonl";
await Bun.write(mainFile, sessionJsonl("main", ["m1"], [mainPreviousPath]));
await Bun.write(path.join(root, "main/Alpha.jsonl"), sessionJsonl("alpha", ["a1"], [subPreviousPath]));
const outputPath = path.join(root, "export.html");
await exportFromFile(mainFile, { outputPath });
const html = await Bun.file(outputPath).text();
const encoded = html.match(/<script id="session-data" type="application\/json">([^<]+)<\/script>/)?.[1];
expect(encoded).toBeDefined();
const data = JSON.parse(Buffer.from(encoded!, "base64").toString("utf8")) as {
header: { previousSessionFiles?: string[] };
subSessions: Record<string, { header: { previousSessionFiles?: string[] } }>;
};
expect(data.header.previousSessionFiles).toBeUndefined();
expect(data.subSessions.Alpha.header.previousSessionFiles).toBeUndefined();
expect(html).not.toContain(mainPreviousPath);
expect(html).not.toContain(subPreviousPath);
});
test("skips corrupt, empty, backup, and non-jsonl files", async () => {
await Bun.write(path.join(root, "main/Good.jsonl"), sessionJsonl("good", ["g1"]));
await Bun.write(path.join(root, "main/corrupt.jsonl"), "{not json\n");
+355
View File
@@ -1000,6 +1000,135 @@ describe("runGcCommand cold-session archive", () => {
expect(remaining).toEqual(Object.fromEntries(tables.map(table => [table, []])));
});
test("uses persisted move history to prune custom names without claiming identity collisions", async () => {
const timestamp = "2026-06-26T12:00:00.000Z";
const timestampMs = Date.parse(timestamp);
const original = await writeSession(root, "before-move", "custom-session-id", "complete", {
filename: "custom-name",
});
const sharedAssistant = {
type: "message",
id: "custom-entry",
parentId: null,
timestamp,
message: { role: "assistant", content: [] },
};
await Bun.write(
original,
[
JSON.stringify({
type: "session",
version: 3,
id: "custom-session-id",
timestamp,
cwd: "/tmp",
previousSessionFiles: [original],
}),
JSON.stringify(sharedAssistant),
"",
].join("\n"),
);
const moved = path.join(getSessionsDir(root), "after-move", path.basename(original));
await fs.mkdir(path.dirname(moved), { recursive: true });
await fs.rename(original, moved);
await agePath(moved, 90);
const historicalNested = path.join(original.slice(0, -".jsonl".length), "nested.jsonl");
const unrelated = path.join(getSessionsDir(root), "unrelated", path.basename(original));
const statsDbPath = path.join(root, "stats.db");
const db = new Database(statsDbPath);
db.run(
"CREATE TABLE messages (session_file TEXT NOT NULL, entry_id TEXT NOT NULL, timestamp INTEGER NOT NULL, UNIQUE(session_file, entry_id))",
);
db.run(
"CREATE TABLE file_offsets (session_file TEXT PRIMARY KEY, offset INTEGER NOT NULL, last_modified INTEGER NOT NULL)",
);
const insertMessage = db.prepare("INSERT INTO messages (session_file, entry_id, timestamp) VALUES (?, ?, ?)");
insertMessage.run(original, sharedAssistant.id, timestampMs);
insertMessage.run(historicalNested, "nested-entry", timestampMs);
insertMessage.run(unrelated, sharedAssistant.id, timestampMs);
const insertOffset = db.prepare(
"INSERT INTO file_offsets (session_file, offset, last_modified) VALUES (?, ?, ?)",
);
insertOffset.run(original, 1, 1);
insertOffset.run(moved, 2, 2);
db.close();
const result = await runGcCommand({
flags: {
agentDir: root,
archive: true,
coldArchiveAfterDays: 30,
retainNewestGlobal: 0,
retainNewestPerCwd: 0,
apply: true,
},
});
const check = new Database(statsDbPath);
const messages = check.prepare("SELECT session_file, entry_id FROM messages").all();
const offsets = check.prepare("SELECT session_file FROM file_offsets").all();
check.close();
expect(result.archive?.archived).toBe(1);
expect(result.archive?.statsRowsDeleted).toBe(4);
expect(result.archive?.errors).toEqual([]);
expect(messages).toEqual([{ session_file: unrelated, entry_id: sharedAssistant.id }]);
expect(offsets).toEqual([]);
});
test("preserves a historical path claimed by multiple archived sessions", async () => {
const timestamp = "2026-06-26T12:00:00.000Z";
const previousSessionFile = path.join(getSessionsDir(root), "before-move", "custom-name.jsonl");
for (const [project, id] of [
["archive-one", "session-one"],
["archive-two", "session-two"],
] as const) {
const current = path.join(getSessionsDir(root), project, "custom-name.jsonl");
await fs.mkdir(path.dirname(current), { recursive: true });
await Bun.write(
current,
[
JSON.stringify({
type: "session",
version: 3,
id,
timestamp,
cwd: "/tmp",
previousSessionFiles: [previousSessionFile],
}),
JSON.stringify({ type: "message", message: { role: "assistant", content: [] } }),
"",
].join("\n"),
);
await agePath(current, 90);
}
const statsDbPath = path.join(root, "stats.db");
const db = new Database(statsDbPath);
db.run("CREATE TABLE messages (session_file TEXT NOT NULL)");
db.prepare("INSERT INTO messages (session_file) VALUES (?)").run(previousSessionFile);
db.close();
const result = await runGcCommand({
flags: {
agentDir: root,
archive: true,
coldArchiveAfterDays: 30,
retainNewestGlobal: 0,
retainNewestPerCwd: 0,
apply: true,
},
});
const check = new Database(statsDbPath);
const rows = check.prepare("SELECT session_file FROM messages").all();
check.close();
expect(result.archive?.archived).toBe(2);
expect(result.archive?.statsRowsDeleted).toBe(0);
expect(result.archive?.errors).toEqual([]);
expect(rows).toEqual([{ session_file: previousSessionFile }]);
});
test("does not claim an unrelated historical path by basename alone", async () => {
const session = await writeSession(root, "project", "actual-session-id", "complete", {
ageDays: 90,
@@ -1067,6 +1196,232 @@ describe("runGcCommand cold-session archive", () => {
expect(rows).toEqual([]);
});
test("preserves shared stats through an unreadable intermediate archive", async () => {
const sessionsDir = path.join(getSessionsDir(root), "project");
const archiveDir = path.join(root, "archive", "sessions", "project");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.mkdir(archiveDir, { recursive: true });
const ancestorPath = path.join(sessionsDir, "20260625_ancestor.jsonl");
const intermediatePath = path.join(sessionsDir, "20260626_intermediate.jsonl");
const retainedPath = path.join(sessionsDir, "20260726_retained.jsonl");
const ancestorArchive = path.join(archiveDir, `${path.basename(ancestorPath)}.gz`);
const corruptArchive = path.join(archiveDir, `${path.basename(intermediatePath)}.gz`);
const timestamp = "2026-06-25T12:00:00.000Z";
const timestampMs = Date.parse(timestamp);
const sharedAssistant = {
type: "message",
id: "shared-assistant",
parentId: null,
timestamp,
message: { role: "assistant", content: [] },
};
await Bun.write(
ancestorArchive,
gzipSync(
[
JSON.stringify({ type: "session", version: 3, id: "ancestor", timestamp, cwd: "/tmp" }),
JSON.stringify(sharedAssistant),
"",
].join("\n"),
),
);
await Bun.write(corruptArchive, "not gzip");
await Bun.write(
retainedPath,
[
JSON.stringify({
type: "session",
version: 3,
id: "retained",
timestamp,
cwd: "/tmp",
parentSession: intermediatePath,
}),
JSON.stringify(sharedAssistant),
"",
].join("\n"),
);
const retainedStat = await fs.stat(retainedPath);
const statsDbPath = path.join(root, "stats.db");
const db = new Database(statsDbPath);
db.run(
"CREATE TABLE messages (session_file TEXT NOT NULL, entry_id TEXT NOT NULL, timestamp INTEGER NOT NULL, UNIQUE(session_file, entry_id))",
);
db.run(
"CREATE TABLE file_offsets (session_file TEXT PRIMARY KEY, offset INTEGER NOT NULL, last_modified INTEGER NOT NULL)",
);
db.prepare("INSERT INTO messages (session_file, entry_id, timestamp) VALUES (?, ?, ?)").run(
ancestorPath,
sharedAssistant.id,
timestampMs,
);
const insertOffset = db.prepare(
"INSERT INTO file_offsets (session_file, offset, last_modified) VALUES (?, ?, ?)",
);
insertOffset.run(ancestorPath, 1, 1);
insertOffset.run(retainedPath, retainedStat.size, retainedStat.mtimeMs);
db.close();
const first = await runGcCommand({
flags: {
agentDir: root,
archive: true,
coldArchiveAfterDays: 30,
retainNewestGlobal: 0,
retainNewestPerCwd: 0,
apply: true,
},
});
const firstCheck = new Database(statsDbPath);
const firstMessages = firstCheck.prepare("SELECT session_file, entry_id FROM messages").all();
const firstOffsets = firstCheck
.prepare("SELECT session_file, offset, last_modified FROM file_offsets ORDER BY session_file")
.all();
firstCheck.close();
expect(first.archive?.archived).toBe(0);
expect(first.archive?.statsRowsDeleted).toBe(0);
expect(first.archive?.errors.some(error => error.startsWith(`stats cleanup scan ${corruptArchive}: `))).toBe(
true,
);
expect(firstMessages).toEqual([{ session_file: ancestorPath, entry_id: sharedAssistant.id }]);
expect(firstOffsets).toEqual([
{ session_file: ancestorPath, offset: 1, last_modified: 1 },
{ session_file: retainedPath, offset: retainedStat.size, last_modified: retainedStat.mtimeMs },
]);
await Bun.write(
corruptArchive,
gzipSync(
[
JSON.stringify({
type: "session",
version: 3,
id: "intermediate",
timestamp,
cwd: "/tmp",
parentSession: ancestorPath,
}),
JSON.stringify(sharedAssistant),
"",
].join("\n"),
),
);
const second = await runGcCommand({
flags: {
agentDir: root,
archive: true,
coldArchiveAfterDays: 30,
retainNewestGlobal: 0,
retainNewestPerCwd: 0,
apply: true,
},
});
const secondCheck = new Database(statsDbPath);
const secondMessages = secondCheck.prepare("SELECT session_file, entry_id FROM messages").all();
const secondOffsets = secondCheck.prepare("SELECT session_file, offset, last_modified FROM file_offsets").all();
secondCheck.close();
expect(second.archive?.archived).toBe(0);
expect(second.archive?.statsRowsDeleted).toBe(1);
expect(second.archive?.errors).toEqual([]);
expect(secondMessages).toEqual([{ session_file: retainedPath, entry_id: sharedAssistant.id }]);
expect(secondOffsets).toEqual([
{ session_file: retainedPath, offset: retainedStat.size, last_modified: retainedStat.mtimeMs },
]);
});
test("transfers shared tool calls with empty ids before pruning archived ownership", async () => {
const sessionsDir = path.join(getSessionsDir(root), "project");
const archiveDir = path.join(root, "archive", "sessions", "project");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.mkdir(archiveDir, { recursive: true });
const parentPath = path.join(sessionsDir, "20260625_parent.jsonl");
const childPath = path.join(sessionsDir, "20260726_child.jsonl");
const parentArchive = path.join(archiveDir, `${path.basename(parentPath)}.gz`);
const timestamp = "2026-06-25T12:00:00.000Z";
const timestampMs = Date.parse(timestamp);
const assistant = {
type: "message",
id: "assistant",
parentId: null,
timestamp,
message: {
role: "assistant",
content: [{ type: "toolCall", id: "", name: "read" }],
},
};
await Bun.write(
parentArchive,
gzipSync(
[
JSON.stringify({ type: "session", version: 3, id: "parent", timestamp, cwd: "/tmp" }),
JSON.stringify(assistant),
"",
].join("\n"),
),
);
await Bun.write(
childPath,
[
JSON.stringify({
type: "session",
version: 3,
id: "child",
timestamp,
cwd: "/tmp",
parentSession: parentPath,
}),
JSON.stringify(assistant),
"",
].join("\n"),
);
const childStat = await fs.stat(childPath);
const statsDbPath = path.join(root, "stats.db");
const db = new Database(statsDbPath);
db.run(
"CREATE TABLE tool_calls (session_file TEXT NOT NULL, entry_id TEXT NOT NULL, timestamp INTEGER NOT NULL, tool_call_id TEXT NOT NULL, UNIQUE(session_file, tool_call_id))",
);
db.run(
"CREATE TABLE file_offsets (session_file TEXT PRIMARY KEY, offset INTEGER NOT NULL, last_modified INTEGER NOT NULL)",
);
db.prepare("INSERT INTO tool_calls (session_file, entry_id, timestamp, tool_call_id) VALUES (?, ?, ?, ?)").run(
parentPath,
assistant.id,
timestampMs,
"",
);
const insertOffset = db.prepare(
"INSERT INTO file_offsets (session_file, offset, last_modified) VALUES (?, ?, ?)",
);
insertOffset.run(parentPath, 1, 1);
insertOffset.run(childPath, childStat.size, childStat.mtimeMs);
db.close();
const result = await runGcCommand({
flags: {
agentDir: root,
archive: true,
coldArchiveAfterDays: 30,
retainNewestGlobal: 0,
retainNewestPerCwd: 0,
apply: true,
},
});
const check = new Database(statsDbPath);
const toolCalls = check.prepare("SELECT session_file, entry_id, tool_call_id FROM tool_calls").all();
const offsets = check.prepare("SELECT session_file, offset, last_modified FROM file_offsets").all();
check.close();
expect(result.archive?.archived).toBe(0);
expect(result.archive?.statsRowsDeleted).toBe(1);
expect(result.archive?.errors).toEqual([]);
expect(toolCalls).toEqual([{ session_file: childPath, entry_id: assistant.id, tool_call_id: "" }]);
expect(offsets).toEqual([{ session_file: childPath, offset: childStat.size, last_modified: childStat.mtimeMs }]);
});
test("skips decompressible historical archives without a valid session header", async () => {
const archive = path.join(root, "archive", "sessions", "older", "headerless-session.jsonl.gz");
await fs.mkdir(path.dirname(archive), { recursive: true });
@@ -106,6 +106,7 @@ describe("SessionManager.moveTo", () => {
const entries = await loadEntriesFromFile(newFile);
const header = getHeader(entries);
expect(header?.cwd).toBe(path.resolve(cwdB));
expect(header?.previousSessionFiles).toEqual([path.resolve(oldFile)]);
expect(hasAssistantEntry(entries)).toBe(true);
});
@@ -148,6 +149,7 @@ describe("SessionManager.moveTo", () => {
const entries = await loadEntriesFromFile(newFile);
const header = getHeader(entries);
expect(header?.cwd).toBe(path.resolve(cwdB));
expect(header?.previousSessionFiles).toBeUndefined();
});
it("recreates file from memory when old file is deleted (assistant exists)", async () => {
+9 -1
View File
@@ -152,7 +152,14 @@ describe("buildShareSnapshot", () => {
},
} as unknown as SessionEntry,
];
const header = { type: "session", version: 3, id: "t", timestamp: ts, cwd: `/home/${secret}/proj` };
const header = {
type: "session",
version: 3,
id: "t",
timestamp: ts,
cwd: `/home/${secret}/proj`,
previousSessionFiles: [`/home/${secret}/old/session.jsonl`],
};
const sm = {
getHeader: () => header,
getEntries: () => entries,
@@ -169,6 +176,7 @@ describe("buildShareSnapshot", () => {
expect(flat).toContain("/.env");
// Source entries keep the real values; redaction is share-only.
expect(JSON.stringify(entries)).toContain(secret);
expect(JSON.stringify(header)).toContain(secret);
});
test("redacts assistant tool calls / error messages and bash meta, and drops provider replay payloads", () => {