mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
fix(codex): defer session persistence until user activity
This commit is contained in:
@@ -41,6 +41,16 @@ describe('parseCodexCliOverrides', () => {
|
||||
expect(parseCodexCliOverrides(['-a', 'untrusted', '-a', 'on-failure'])).toEqual({
|
||||
approvalPolicy: 'on-failure'
|
||||
});
|
||||
|
||||
expect(parseCodexCliOverrides(['-C', 'first', '--cd=second'])).toEqual({
|
||||
cwd: 'second'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses cwd overrides before the argument terminator', () => {
|
||||
expect(parseCodexCliOverrides(['--cd', '../project'])).toEqual({ cwd: '../project' });
|
||||
expect(parseCodexCliOverrides(['-C=/tmp/project'])).toEqual({ cwd: '/tmp/project' });
|
||||
expect(parseCodexCliOverrides(['--', '--cd', '/tmp/ignored'])).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores invalid values and stops at terminator', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type CodexCliOverrides = {
|
||||
sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
|
||||
approvalPolicy?: 'untrusted' | 'on-failure' | 'on-request' | 'never';
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
const SANDBOX_VALUES = new Set<CodexCliOverrides['sandbox']>([
|
||||
@@ -46,6 +47,23 @@ export function parseCodexCliOverrides(args?: string[]): CodexCliOverrides {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '-C' || arg === '--cd') {
|
||||
const value = args[i + 1];
|
||||
if (value && value !== '--') {
|
||||
overrides.cwd = value;
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--cd=') || arg.startsWith('-C=')) {
|
||||
const value = arg.slice(arg.indexOf('=') + 1);
|
||||
if (value) {
|
||||
overrides.cwd = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '-s' || arg === '--sandbox') {
|
||||
const value = args[i + 1];
|
||||
if (SANDBOX_VALUES.has(value as CodexCliOverrides['sandbox'])) {
|
||||
|
||||
@@ -43,10 +43,24 @@ describe('convertCodexEvent', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
userMessage: 'hello from response_item user'
|
||||
userMessage: 'hello from response_item user',
|
||||
userActivity: true
|
||||
});
|
||||
});
|
||||
|
||||
it('marks image-only response_item messages as user activity', () => {
|
||||
const result = convertCodexEvent({
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({ userActivity: true });
|
||||
});
|
||||
|
||||
it('converts response_item assistant messages', () => {
|
||||
const result = convertCodexEvent({
|
||||
type: 'response_item',
|
||||
|
||||
@@ -42,6 +42,7 @@ export type CodexConversionResult = {
|
||||
sessionId?: string;
|
||||
message?: CodexMessage;
|
||||
userMessage?: string;
|
||||
userActivity?: true;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
@@ -146,11 +147,9 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
|
||||
const message = asString(payloadRecord.message)
|
||||
?? asString(payloadRecord.text)
|
||||
?? asString(payloadRecord.content);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userMessage: message
|
||||
userActivity: true,
|
||||
...(message ? { userMessage: message } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,13 +220,16 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
|
||||
if (itemType === 'message') {
|
||||
const role = asString(payloadRecord.role);
|
||||
const text = extractCodexText(payloadRecord.content);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
if (role === 'user') {
|
||||
return { userMessage: text };
|
||||
return {
|
||||
userActivity: true,
|
||||
...(text ? { userMessage: text } : {})
|
||||
};
|
||||
}
|
||||
if (role === 'assistant') {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
message: {
|
||||
type: 'message',
|
||||
|
||||
@@ -163,4 +163,30 @@ describe('codexSessionScanner', () => {
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.payload).toEqual({ type: 'agent_message', message: 'after-truncate' });
|
||||
});
|
||||
|
||||
it('retries an unterminated final record after it is completed', async () => {
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: 'session-partial' } }) + '\n'
|
||||
);
|
||||
scanner = await createCodexSessionScanner({
|
||||
transcriptPath,
|
||||
onEvent: (event) => events.push(event)
|
||||
});
|
||||
const event = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', message: 'completed later' }
|
||||
});
|
||||
const splitAt = Math.floor(event.length / 2);
|
||||
|
||||
await appendFile(transcriptPath, event.slice(0, splitAt));
|
||||
await wait(300);
|
||||
expect(events).toEqual([]);
|
||||
|
||||
await appendFile(transcriptPath, event.slice(splitAt));
|
||||
await wait(700);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.payload).toEqual({ type: 'agent_message', message: 'completed later' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
const lines = content.split('\n');
|
||||
const hasTrailingEmpty = lines.length > 0 && lines[lines.length - 1] === '';
|
||||
const totalLines = hasTrailingEmpty ? lines.length - 1 : lines.length;
|
||||
let nextCursor = totalLines;
|
||||
const currentSize = Buffer.byteLength(content);
|
||||
const previousSize = this.fileSizeByPath.get(filePath);
|
||||
let effectiveStartLine = startLine;
|
||||
@@ -145,6 +146,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
logger.debug(`[codex-session-scanner] Failed to parse transcript line ${filePath}:${lineIndex + 1}: ${error}`);
|
||||
if (!hasTrailingEmpty && lineIndex === totalLines - 1) {
|
||||
nextCursor = lineIndex;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -169,7 +173,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
|
||||
return {
|
||||
events,
|
||||
nextCursor: totalLines
|
||||
nextCursor
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './codexTranscriptLocator';
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe('codexTranscriptLocator', () => {
|
||||
let codexHome: string;
|
||||
let sessionDirectory: string;
|
||||
let locator: CodexTranscriptLocator | null = null;
|
||||
const originalCodexHome = process.env.CODEX_HOME;
|
||||
|
||||
beforeEach(async () => {
|
||||
codexHome = join(tmpdir(), `codex-transcript-locator-${Date.now()}-${Math.random()}`);
|
||||
const now = new Date();
|
||||
sessionDirectory = join(
|
||||
codexHome,
|
||||
'sessions',
|
||||
String(now.getUTCFullYear()),
|
||||
String(now.getUTCMonth() + 1).padStart(2, '0'),
|
||||
String(now.getUTCDate()).padStart(2, '0')
|
||||
);
|
||||
await mkdir(sessionDirectory, { recursive: true });
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await locator?.cleanup();
|
||||
locator = null;
|
||||
if (originalCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = originalCodexHome;
|
||||
}
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does not attach for session metadata alone', async () => {
|
||||
const located: string[] = [];
|
||||
locator = createCodexTranscriptLocator({
|
||||
cwd: '/tmp/project',
|
||||
startupTimestampMs: Date.now(),
|
||||
intervalMs: 25,
|
||||
onLocated: (result) => located.push(result.transcriptPath)
|
||||
});
|
||||
await locator.ready;
|
||||
const transcriptPath = await createTranscript('thread-meta-only', '/tmp/project');
|
||||
|
||||
await wait(150);
|
||||
expect(located).toEqual([]);
|
||||
expect(transcriptPath).toContain('thread-meta-only');
|
||||
});
|
||||
|
||||
it('attaches after fresh real user activity in the matching cwd', async () => {
|
||||
const located: string[] = [];
|
||||
locator = createCodexTranscriptLocator({
|
||||
cwd: '/tmp/project',
|
||||
startupTimestampMs: Date.now(),
|
||||
intervalMs: 25,
|
||||
settlementMs: 25,
|
||||
onLocated: (result) => located.push(result.transcriptPath)
|
||||
});
|
||||
await locator.ready;
|
||||
const transcriptPath = await createTranscript('thread-user', '/tmp/project');
|
||||
|
||||
await appendFile(transcriptPath, `${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'hello' }
|
||||
})}\n`);
|
||||
await wait(150);
|
||||
|
||||
expect(located).toEqual([transcriptPath]);
|
||||
});
|
||||
|
||||
it('attaches after image-only user activity', async () => {
|
||||
const located: string[] = [];
|
||||
locator = createCodexTranscriptLocator({
|
||||
cwd: '/tmp/project',
|
||||
startupTimestampMs: Date.now(),
|
||||
intervalMs: 25,
|
||||
settlementMs: 0,
|
||||
onLocated: (result) => located.push(result.transcriptPath)
|
||||
});
|
||||
await locator.ready;
|
||||
const transcriptPath = await createTranscript('thread-image', '/tmp/project');
|
||||
|
||||
await appendFile(transcriptPath, `${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
|
||||
}
|
||||
})}\n`);
|
||||
await wait(100);
|
||||
|
||||
expect(located).toEqual([transcriptPath]);
|
||||
});
|
||||
|
||||
it('refuses fallback when fresh activity is ambiguous', async () => {
|
||||
const located: string[] = [];
|
||||
const ambiguous: string[][] = [];
|
||||
locator = createCodexTranscriptLocator({
|
||||
cwd: '/tmp/project',
|
||||
startupTimestampMs: Date.now(),
|
||||
intervalMs: 25,
|
||||
settlementMs: 50,
|
||||
onLocated: (result) => located.push(result.transcriptPath),
|
||||
onAmbiguous: (paths) => ambiguous.push(paths)
|
||||
});
|
||||
await locator.ready;
|
||||
const first = await createTranscript('thread-a', '/tmp/project');
|
||||
const second = await createTranscript('thread-b', '/tmp/project');
|
||||
|
||||
const userEvent = `${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'hello' }
|
||||
})}\n`;
|
||||
await Promise.all([appendFile(first, userEvent), appendFile(second, userEvent)]);
|
||||
await wait(150);
|
||||
|
||||
expect(located).toEqual([]);
|
||||
expect(ambiguous).toHaveLength(1);
|
||||
expect(new Set(ambiguous[0])).toEqual(new Set([first, second]));
|
||||
});
|
||||
|
||||
it('rejects candidates whose activity arrives in adjacent polling cycles', async () => {
|
||||
const located: string[] = [];
|
||||
const ambiguous: string[][] = [];
|
||||
locator = createCodexTranscriptLocator({
|
||||
cwd: '/tmp/project',
|
||||
startupTimestampMs: Date.now(),
|
||||
intervalMs: 25,
|
||||
settlementMs: 150,
|
||||
onLocated: (result) => located.push(result.transcriptPath),
|
||||
onAmbiguous: (paths) => ambiguous.push(paths)
|
||||
});
|
||||
await locator.ready;
|
||||
const first = await createTranscript('thread-staggered-a', '/tmp/project');
|
||||
const second = await createTranscript('thread-staggered-b', '/tmp/project');
|
||||
const userEvent = (message: string) => JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message }
|
||||
});
|
||||
|
||||
await appendFile(first, `${userEvent('first')}\n`);
|
||||
await wait(60);
|
||||
await appendFile(second, `${userEvent('second')}\n`);
|
||||
await wait(150);
|
||||
|
||||
expect(located).toEqual([]);
|
||||
expect(ambiguous).toHaveLength(1);
|
||||
expect(new Set(ambiguous[0])).toEqual(new Set([first, second]));
|
||||
});
|
||||
|
||||
it('retries an unterminated final JSON record after it is completed', async () => {
|
||||
const located: string[] = [];
|
||||
locator = createCodexTranscriptLocator({
|
||||
cwd: '/tmp/project',
|
||||
startupTimestampMs: Date.now(),
|
||||
intervalMs: 25,
|
||||
settlementMs: 0,
|
||||
onLocated: (result) => located.push(result.transcriptPath)
|
||||
});
|
||||
await locator.ready;
|
||||
const transcriptPath = await createTranscript('thread-partial', '/tmp/project');
|
||||
const userEvent = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'completed later' }
|
||||
});
|
||||
const splitAt = Math.floor(userEvent.length / 2);
|
||||
|
||||
await appendFile(transcriptPath, userEvent.slice(0, splitAt));
|
||||
await wait(75);
|
||||
expect(located).toEqual([]);
|
||||
|
||||
await appendFile(transcriptPath, userEvent.slice(splitAt));
|
||||
await wait(100);
|
||||
expect(located).toEqual([transcriptPath]);
|
||||
});
|
||||
|
||||
it('ignores pre-existing fresh transcripts even when they receive new activity', async () => {
|
||||
const transcriptPath = await createTranscript('thread-existing', '/tmp/project');
|
||||
const located: string[] = [];
|
||||
locator = createCodexTranscriptLocator({
|
||||
cwd: '/tmp/project',
|
||||
startupTimestampMs: Date.now(),
|
||||
intervalMs: 25,
|
||||
settlementMs: 0,
|
||||
onLocated: (result) => located.push(result.transcriptPath)
|
||||
});
|
||||
await locator.ready;
|
||||
|
||||
await appendFile(transcriptPath, `${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'other terminal' }
|
||||
})}\n`);
|
||||
await wait(100);
|
||||
|
||||
expect(located).toEqual([]);
|
||||
});
|
||||
|
||||
it('polls only the exact resume transcript once it is found', async () => {
|
||||
const unrelated = await createTranscript('thread-unrelated', '/tmp/project');
|
||||
const target = await createTranscript('thread-resume', '/tmp/original-project');
|
||||
await appendFile(unrelated, `${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'unrelated activity' }
|
||||
})}\n`);
|
||||
const located: string[] = [];
|
||||
const ambiguous: string[][] = [];
|
||||
locator = createCodexTranscriptLocator({
|
||||
cwd: '/tmp/current-project',
|
||||
startupTimestampMs: Date.now(),
|
||||
resumeSessionId: 'thread-resume',
|
||||
intervalMs: 25,
|
||||
onLocated: (result) => located.push(result.transcriptPath),
|
||||
onAmbiguous: (paths) => ambiguous.push(paths)
|
||||
});
|
||||
await locator.ready;
|
||||
|
||||
await appendFile(target, `${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'resume activity' }
|
||||
})}\n`);
|
||||
await wait(100);
|
||||
|
||||
expect(located).toEqual([target]);
|
||||
expect(ambiguous).toEqual([]);
|
||||
});
|
||||
|
||||
async function createTranscript(sessionId: string, cwd: string): Promise<string> {
|
||||
const transcriptPath = join(sessionDirectory, `rollout-${sessionId}.jsonl`);
|
||||
await writeFile(transcriptPath, `${JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: sessionId, cwd }
|
||||
})}\n`);
|
||||
return transcriptPath;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { open, readdir, stat } from 'node:fs/promises';
|
||||
import { logger } from '@/ui/logger';
|
||||
import { convertCodexEvent, type CodexSessionEvent } from './codexEventConverter';
|
||||
|
||||
export type LocatedCodexTranscript = {
|
||||
sessionId: string;
|
||||
transcriptPath: string;
|
||||
};
|
||||
|
||||
export type CodexTranscriptLocator = {
|
||||
ready: Promise<void>;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
type TranscriptState = {
|
||||
offset: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ino: number;
|
||||
sessionId: string | null;
|
||||
cwd: string | null;
|
||||
};
|
||||
|
||||
type CodexTranscriptLocatorOptions = {
|
||||
cwd: string;
|
||||
startupTimestampMs: number;
|
||||
resumeSessionId?: string | null;
|
||||
intervalMs?: number;
|
||||
settlementMs?: number;
|
||||
onLocated: (located: LocatedCodexTranscript) => void;
|
||||
onAmbiguous?: (paths: string[]) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_LOCATOR_INTERVAL_MS = 500;
|
||||
|
||||
export function createCodexTranscriptLocator(options: CodexTranscriptLocatorOptions): CodexTranscriptLocator {
|
||||
const locator = new CodexTranscriptLocatorImpl(options);
|
||||
const ready = locator.start().catch((error) => {
|
||||
logger.debug('[codex-transcript-locator] Failed to initialize transcript fallback', error);
|
||||
});
|
||||
return {
|
||||
ready,
|
||||
cleanup: async () => {
|
||||
await locator.cleanup();
|
||||
await ready;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class CodexTranscriptLocatorImpl {
|
||||
private readonly sessionsRoot: string;
|
||||
private readonly targetCwd: string;
|
||||
private readonly startupTimestampMs: number;
|
||||
private readonly resumeSessionId: string | null;
|
||||
private readonly intervalMs: number;
|
||||
private readonly settlementMs: number;
|
||||
private readonly onLocated: CodexTranscriptLocatorOptions['onLocated'];
|
||||
private readonly onAmbiguous?: CodexTranscriptLocatorOptions['onAmbiguous'];
|
||||
private readonly states = new Map<string, TranscriptState>();
|
||||
private readonly initialFreshPaths = new Set<string>();
|
||||
private readonly pendingCandidates = new Map<string, LocatedCodexTranscript>();
|
||||
private resumeTranscriptPaths: string[] | null = null;
|
||||
private firstCandidateTimestampMs: number | null = null;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private scanPromise: Promise<void> | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(options: CodexTranscriptLocatorOptions) {
|
||||
const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex');
|
||||
this.sessionsRoot = join(codexHome, 'sessions');
|
||||
this.targetCwd = normalizePath(options.cwd);
|
||||
this.startupTimestampMs = options.startupTimestampMs;
|
||||
this.resumeSessionId = options.resumeSessionId ?? null;
|
||||
this.intervalMs = options.intervalMs ?? DEFAULT_LOCATOR_INTERVAL_MS;
|
||||
this.settlementMs = options.settlementMs ?? this.intervalMs;
|
||||
this.onLocated = options.onLocated;
|
||||
this.onAmbiguous = options.onAmbiguous;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (!this.resumeSessionId) {
|
||||
const existingPaths = await this.listNearbyTranscriptFiles();
|
||||
for (const transcriptPath of existingPaths) {
|
||||
this.initialFreshPaths.add(transcriptPath);
|
||||
}
|
||||
}
|
||||
if (this.stopped) return;
|
||||
|
||||
void this.scan();
|
||||
this.interval = setInterval(() => void this.scan(), this.intervalMs);
|
||||
this.interval.unref?.();
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
await this.scanPromise?.catch(() => {});
|
||||
}
|
||||
|
||||
private async scan(): Promise<void> {
|
||||
if (this.stopped || this.scanPromise) {
|
||||
return this.scanPromise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
this.scanPromise = this.runScan();
|
||||
try {
|
||||
await this.scanPromise;
|
||||
} finally {
|
||||
this.scanPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async runScan(): Promise<void> {
|
||||
const files = await this.listCandidateFiles();
|
||||
for (const transcriptPath of files) {
|
||||
if (this.stopped) return;
|
||||
const candidate = await this.scanFile(transcriptPath);
|
||||
if (candidate) {
|
||||
this.pendingCandidates.set(candidate.transcriptPath, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.stopped || this.pendingCandidates.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pendingCandidates.size > 1) {
|
||||
const paths = [...this.pendingCandidates.keys()];
|
||||
logger.warn('[codex-transcript-locator] Ambiguous Codex transcript activity; refusing fallback attachment', paths);
|
||||
this.stopPolling();
|
||||
this.onAmbiguous?.(paths);
|
||||
return;
|
||||
}
|
||||
|
||||
const [located] = this.pendingCandidates.values();
|
||||
if (!located) return;
|
||||
|
||||
if (!this.resumeSessionId) {
|
||||
if (this.firstCandidateTimestampMs === null) {
|
||||
this.firstCandidateTimestampMs = Date.now();
|
||||
}
|
||||
if (Date.now() - this.firstCandidateTimestampMs < this.settlementMs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`[codex-transcript-locator] Located ${located.sessionId} at ${located.transcriptPath}`);
|
||||
this.stopPolling();
|
||||
this.onLocated(located);
|
||||
}
|
||||
|
||||
private async scanFile(transcriptPath: string): Promise<LocatedCodexTranscript | null> {
|
||||
let fileStats: Awaited<ReturnType<typeof stat>>;
|
||||
try {
|
||||
fileStats = await stat(transcriptPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!fileStats.isFile()) return null;
|
||||
|
||||
const previous = this.states.get(transcriptPath);
|
||||
let state: TranscriptState = previous ?? {
|
||||
offset: 0,
|
||||
size: 0,
|
||||
mtimeMs: 0,
|
||||
ino: fileStats.ino,
|
||||
sessionId: null,
|
||||
cwd: null
|
||||
};
|
||||
|
||||
const replaced = previous && previous.ino !== fileStats.ino;
|
||||
const truncated = previous && fileStats.size < previous.offset;
|
||||
const rewrittenAtSameSize = previous
|
||||
&& fileStats.size === previous.size
|
||||
&& fileStats.mtimeMs !== previous.mtimeMs
|
||||
&& previous.offset === previous.size;
|
||||
if (replaced || truncated || rewrittenAtSameSize) {
|
||||
state = {
|
||||
offset: 0,
|
||||
size: 0,
|
||||
mtimeMs: 0,
|
||||
ino: fileStats.ino,
|
||||
sessionId: null,
|
||||
cwd: null
|
||||
};
|
||||
} else if (previous
|
||||
&& fileStats.size === previous.size
|
||||
&& fileStats.mtimeMs === previous.mtimeMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fileStats.size <= state.offset) {
|
||||
state.size = fileStats.size;
|
||||
state.mtimeMs = fileStats.mtimeMs;
|
||||
state.ino = fileStats.ino;
|
||||
this.states.set(transcriptPath, state);
|
||||
return null;
|
||||
}
|
||||
|
||||
let content: Buffer;
|
||||
try {
|
||||
content = await readBytes(transcriptPath, state.offset, fileStats.size - state.offset);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startOffset = state.offset;
|
||||
const text = content.toString('utf8');
|
||||
const lines = text.split('\n');
|
||||
let consumedBytes = 0;
|
||||
let sawFreshUserActivity = false;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? '';
|
||||
const terminated = index < lines.length - 1;
|
||||
const recordBytes = Buffer.byteLength(line) + (terminated ? 1 : 0);
|
||||
|
||||
if (!line.trim()) {
|
||||
if (terminated) consumedBytes += recordBytes;
|
||||
continue;
|
||||
}
|
||||
|
||||
let event: CodexSessionEvent;
|
||||
try {
|
||||
event = JSON.parse(line) as CodexSessionEvent;
|
||||
} catch {
|
||||
if (terminated) consumedBytes += recordBytes;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'session_meta') {
|
||||
const metadata = asRecord(event.payload);
|
||||
state.sessionId = asString(metadata?.id) ?? state.sessionId;
|
||||
const eventCwd = asString(metadata?.cwd);
|
||||
state.cwd = eventCwd ? normalizePath(eventCwd) : state.cwd;
|
||||
}
|
||||
|
||||
if (convertCodexEvent(event)?.userActivity) {
|
||||
const eventTimestamp = parseTimestamp(event.timestamp);
|
||||
if (eventTimestamp !== null && eventTimestamp >= this.startupTimestampMs) {
|
||||
sawFreshUserActivity = true;
|
||||
}
|
||||
}
|
||||
|
||||
consumedBytes += recordBytes;
|
||||
}
|
||||
|
||||
state.offset = startOffset + consumedBytes;
|
||||
state.size = startOffset + content.length;
|
||||
state.mtimeMs = fileStats.mtimeMs;
|
||||
state.ino = fileStats.ino;
|
||||
this.states.set(transcriptPath, state);
|
||||
|
||||
if (!sawFreshUserActivity || !state.sessionId) {
|
||||
return null;
|
||||
}
|
||||
if (this.resumeSessionId) {
|
||||
if (state.sessionId !== this.resumeSessionId) {
|
||||
return null;
|
||||
}
|
||||
} else if (state.cwd !== this.targetCwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { sessionId: state.sessionId, transcriptPath };
|
||||
}
|
||||
|
||||
private async listCandidateFiles(): Promise<string[]> {
|
||||
if (this.resumeSessionId) {
|
||||
if (this.resumeTranscriptPaths) {
|
||||
return this.resumeTranscriptPaths;
|
||||
}
|
||||
const suffix = `-${this.resumeSessionId}.jsonl`;
|
||||
const matches = await listJsonlFiles(this.sessionsRoot, (name) => name.endsWith(suffix));
|
||||
if (matches.length > 0) {
|
||||
this.resumeTranscriptPaths = matches;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
const files = await this.listNearbyTranscriptFiles();
|
||||
return files.filter((transcriptPath) => !this.initialFreshPaths.has(transcriptPath));
|
||||
}
|
||||
|
||||
private async listNearbyTranscriptFiles(): Promise<string[]> {
|
||||
const roots = getNearbyDateRoots(this.sessionsRoot, this.startupTimestampMs);
|
||||
const groups = await Promise.all(roots.map((root) => listJsonlFiles(root)));
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
this.stopped = true;
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBytes(filePath: string, offset: number, length: number): Promise<Buffer> {
|
||||
const handle = await open(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
let totalBytesRead = 0;
|
||||
while (totalBytesRead < length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
totalBytesRead,
|
||||
length - totalBytesRead,
|
||||
offset + totalBytesRead
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
totalBytesRead += bytesRead;
|
||||
}
|
||||
return buffer.subarray(0, totalBytesRead);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function listJsonlFiles(
|
||||
directory: string,
|
||||
matchesName: (name: string) => boolean = () => true
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const groups = await Promise.all(entries.map(async (entry) => {
|
||||
const fullPath = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return await listJsonlFiles(fullPath, matchesName);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') && matchesName(entry.name)
|
||||
? [fullPath]
|
||||
: [];
|
||||
}));
|
||||
return groups.flat();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getNearbyDateRoots(sessionsRoot: string, timestampMs: number): string[] {
|
||||
const roots: string[] = [];
|
||||
for (const offsetDays of [-1, 0, 1]) {
|
||||
const date = new Date(timestampMs + offsetDays * 24 * 60 * 60 * 1000);
|
||||
roots.push(join(
|
||||
sessionsRoot,
|
||||
String(date.getUTCFullYear()),
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0'),
|
||||
String(date.getUTCDate()).padStart(2, '0')
|
||||
));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function parseTimestamp(value: unknown): number | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
const normalized = resolve(value);
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ vi.mock('cross-spawn', () => ({
|
||||
|
||||
import {
|
||||
assertCodexLocalSupported,
|
||||
CODEX_VERSION_TIMEOUT_MS,
|
||||
isCodexVersionAtLeast,
|
||||
MIN_CODEX_HOOKS_VERSION,
|
||||
parseCodexVersion
|
||||
@@ -74,7 +75,8 @@ describe('codexVersion', () => {
|
||||
'node',
|
||||
[codexScriptPath, '--version'],
|
||||
expect.objectContaining({
|
||||
encoding: 'utf8'
|
||||
encoding: 'utf8',
|
||||
timeout: CODEX_VERSION_TIMEOUT_MS
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { withBunRuntimeEnv } from '@/utils/bunRuntime'
|
||||
import { resolveCodexCommand } from './codexExecutable'
|
||||
|
||||
export const MIN_CODEX_HOOKS_VERSION = '0.124.0'
|
||||
export const CODEX_VERSION_TIMEOUT_MS = 3_000
|
||||
|
||||
const SEMVER_PATTERN = /\b(\d+)\.(\d+)\.(\d+)\b/
|
||||
|
||||
@@ -59,6 +60,7 @@ export function assertCodexLocalSupported(): void {
|
||||
const result = spawn.sync(codexCommand.command, [...codexCommand.args, '--version'], {
|
||||
encoding: 'utf8',
|
||||
env: withBunRuntimeEnv(),
|
||||
timeout: CODEX_VERSION_TIMEOUT_MS,
|
||||
windowsHide: process.platform === 'win32'
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user