agent-ecosystem/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts

329 lines
10 KiB
TypeScript

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState';
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
import type {
RecentProjectsSourcePort,
RecentProjectsSourceResult,
} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort';
import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate';
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
import type { ServiceContext } from '@main/services';
const CODEX_SESSION_FILE_PARSE_LIMIT = 500;
const CODEX_PROJECT_CANDIDATE_LIMIT = 40;
const CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS = 8_000;
const CODEX_SESSION_FILE_READ_BATCH_SIZE = 24;
const CODEX_SESSION_METADATA_READ_LIMIT_BYTES = 128 * 1024;
interface CodexSessionFileEntry {
filePath: string;
mtimeMs: number;
}
interface CodexSessionEvent {
timestamp?: unknown;
payload?: {
cwd?: unknown;
source?: unknown;
timestamp?: unknown;
git?: {
branch?: unknown;
} | null;
};
}
interface CodexSessionProjectSnapshot {
cwd: string;
source: unknown;
lastActivityAt: number;
branchName?: string;
}
interface CodexSessionMetadata {
cwd: string;
source: unknown;
payloadTimestamp?: unknown;
eventTimestamp?: unknown;
branchName?: string;
}
function isInteractiveSource(source: unknown): boolean {
return source === 'vscode' || source === 'cli';
}
function normalizeTimestamp(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1_000_000_000_000 ? value * 1000 : value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
}
function getCodexHome(codexHome?: string): string {
return codexHome?.trim() || process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
}
function extractJsonStringField(input: string, fieldName: string): string {
const pattern = new RegExp(`"${fieldName}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`);
const match = pattern.exec(input);
if (!match) return '';
try {
return JSON.parse(`"${match[1]}"`) as string;
} catch {
return '';
}
}
function parseSessionMetadataPrefix(firstLine: string): CodexSessionMetadata | null {
const cwd = extractJsonStringField(firstLine, 'cwd').trim();
const source = extractJsonStringField(firstLine, 'source').trim();
if (!cwd || !source) return null;
return {
cwd,
source,
payloadTimestamp: extractJsonStringField(firstLine, 'timestamp'),
eventTimestamp: extractJsonStringField(firstLine, 'timestamp'),
branchName: extractJsonStringField(firstLine, 'branch').trim() || undefined,
};
}
async function readFirstLine(filePath: string): Promise<string | null> {
let handle: fs.FileHandle | null = null;
try {
handle = await fs.open(filePath, 'r');
const buffer = Buffer.allocUnsafe(CODEX_SESSION_METADATA_READ_LIMIT_BYTES);
const result = await handle.read(buffer, 0, buffer.length, 0);
if (result.bytesRead <= 0) return null;
const newlineIndex = buffer.subarray(0, result.bytesRead).indexOf(0x0a);
const endIndex = newlineIndex >= 0 ? newlineIndex : result.bytesRead;
return buffer.toString('utf8', 0, endIndex);
} catch {
return null;
} finally {
await handle?.close().catch(() => undefined);
}
}
async function listJsonlFiles(root: string, maxDepth: number): Promise<CodexSessionFileEntry[]> {
async function walk(directory: string, depth: number): Promise<CodexSessionFileEntry[]> {
let entries;
try {
entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry): Promise<CodexSessionFileEntry[]> => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return depth < maxDepth ? walk(entryPath, depth + 1) : [];
}
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
return [];
}
try {
const stats = await fs.stat(entryPath);
return [
{
filePath: entryPath,
mtimeMs: stats.mtimeMs,
},
];
} catch {
return [];
}
})
);
return files.flat();
}
return walk(root, 0);
}
function parseSessionSnapshot(
firstLine: string,
mtimeMs: number
): CodexSessionProjectSnapshot | null {
let metadata: CodexSessionMetadata | null = null;
try {
const event = JSON.parse(firstLine) as CodexSessionEvent;
metadata = {
cwd: typeof event.payload?.cwd === 'string' ? event.payload.cwd.trim() : '',
source: event.payload?.source,
payloadTimestamp: event.payload?.timestamp,
eventTimestamp: event.timestamp,
branchName:
typeof event.payload?.git?.branch === 'string' ? event.payload.git.branch.trim() : '',
};
} catch {
metadata = parseSessionMetadataPrefix(firstLine);
}
const cwd = metadata?.cwd ?? '';
if (!metadata || !cwd || !isInteractiveSource(metadata.source) || isEphemeralProjectPath(cwd)) {
return null;
}
const timestamp =
mtimeMs ||
normalizeTimestamp(metadata.payloadTimestamp) ||
normalizeTimestamp(metadata.eventTimestamp);
return {
cwd,
source: metadata.source,
lastActivityAt: timestamp,
branchName: metadata.branchName || undefined,
};
}
export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
readonly sourceId = 'codex-session-files';
readonly timeoutMs = CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS;
readonly #codexHome: string;
constructor(
private readonly deps: {
getActiveContext: () => ServiceContext;
getLocalContext: () => ServiceContext | undefined;
identityResolver: RecentProjectIdentityResolver;
logger: LoggerPort;
codexHome?: string;
}
) {
this.#codexHome = getCodexHome(deps.codexHome);
}
async list(): Promise<RecentProjectsSourceResult> {
const activeContext = this.deps.getActiveContext();
const localContext = this.deps.getLocalContext();
if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) {
return {
candidates: [],
degraded: false,
};
}
try {
const snapshots = await this.#listRecentSessionSnapshots();
const candidates = await Promise.all(
snapshots.map((snapshot) => this.#toCandidate(snapshot, activeContext.fsProvider))
);
const validCandidates = candidates.filter(
(candidate): candidate is RecentProjectCandidate => candidate !== null
);
this.deps.logger.info('codex session-file recent-projects source loaded', {
count: validCandidates.length,
codexHome: this.#codexHome,
});
return {
candidates: validCandidates,
degraded: false,
};
} catch (error) {
this.deps.logger.warn('codex session-file recent-projects source failed', {
error: error instanceof Error ? error.message : String(error),
});
return {
candidates: [],
degraded: true,
};
}
}
async #listRecentSessionSnapshots(): Promise<CodexSessionProjectSnapshot[]> {
const files = [
...(await listJsonlFiles(path.join(this.#codexHome, 'sessions'), 4)),
...(await listJsonlFiles(path.join(this.#codexHome, 'archived_sessions'), 1)),
].sort((left, right) => right.mtimeMs - left.mtimeMs);
const snapshotsByCwd = new Map<string, CodexSessionProjectSnapshot>();
const candidateFiles = files.slice(0, CODEX_SESSION_FILE_PARSE_LIMIT);
for (
let offset = 0;
offset < candidateFiles.length && snapshotsByCwd.size < CODEX_PROJECT_CANDIDATE_LIMIT;
offset += CODEX_SESSION_FILE_READ_BATCH_SIZE
) {
const batch = candidateFiles.slice(offset, offset + CODEX_SESSION_FILE_READ_BATCH_SIZE);
const firstLines = await Promise.all(
batch.map(async (file) => ({
file,
firstLine: await readFirstLine(file.filePath),
}))
);
for (const { file, firstLine } of firstLines) {
if (!firstLine) {
continue;
}
const snapshot = parseSessionSnapshot(firstLine, file.mtimeMs);
if (!snapshot) {
continue;
}
const previous = snapshotsByCwd.get(snapshot.cwd);
if (!previous || snapshot.lastActivityAt > previous.lastActivityAt) {
snapshotsByCwd.set(snapshot.cwd, snapshot);
}
if (snapshotsByCwd.size >= CODEX_PROJECT_CANDIDATE_LIMIT) {
break;
}
}
}
return Array.from(snapshotsByCwd.values())
.sort((left, right) => right.lastActivityAt - left.lastActivityAt)
.slice(0, CODEX_PROJECT_CANDIDATE_LIMIT);
}
async #toCandidate(
snapshot: CodexSessionProjectSnapshot,
fsProvider?: ServiceContext['fsProvider']
): Promise<RecentProjectCandidate | null> {
const identity = await this.deps.identityResolver.resolve(snapshot.cwd);
const displayName = identity?.name ?? path.basename(snapshot.cwd) ?? snapshot.cwd;
return {
identity: identity?.id ?? `path:${normalizeIdentityPath(snapshot.cwd)}`,
displayName,
primaryPath: snapshot.cwd,
associatedPaths: [snapshot.cwd],
lastActivityAt: snapshot.lastActivityAt,
providerIds: ['codex'],
sourceKind: 'codex',
openTarget: {
type: 'synthetic-path',
path: snapshot.cwd,
},
branchName: snapshot.branchName,
filesystemState: await resolveProjectFilesystemState(snapshot.cwd, fsProvider),
};
}
}