fix(coding-agent): harden archived stats cleanup
This commit is contained in:
@@ -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)}`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user