agent-ecosystem/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts
2026-04-27 20:01:05 +03:00

287 lines
8.3 KiB
TypeScript

import { createReadStream } from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
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 = 3_500;
const CODEX_SESSION_FILE_READ_BATCH_SIZE = 24;
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;
}
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');
}
async function readFirstLine(filePath: string): Promise<string | null> {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const lines = readline.createInterface({
input: stream,
crlfDelay: Infinity,
});
try {
for await (const line of lines) {
return line;
}
return null;
} catch {
return null;
} finally {
lines.close();
stream.destroy();
}
}
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 event: CodexSessionEvent;
try {
event = JSON.parse(firstLine) as CodexSessionEvent;
} catch {
return null;
}
const cwd = typeof event.payload?.cwd === 'string' ? event.payload.cwd.trim() : '';
if (!cwd || !isInteractiveSource(event.payload?.source) || isEphemeralProjectPath(cwd)) {
return null;
}
const timestamp =
mtimeMs || normalizeTimestamp(event.payload?.timestamp) || normalizeTimestamp(event.timestamp);
const branchName =
typeof event.payload?.git?.branch === 'string' ? event.payload.git.branch.trim() : '';
return {
cwd,
source: event.payload?.source,
lastActivityAt: timestamp,
branchName: 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))
);
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
): 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,
};
}
}