agent-ecosystem/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts
2026-04-30 00:05:58 +03:00

231 lines
8 KiB
TypeScript

import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises';
import path from 'path';
import { isRuntimeTurnSettledProvider } from '../../core/domain';
import type {
RuntimeTurnSettledClaimedPayload,
RuntimeTurnSettledEventStorePort,
RuntimeTurnSettledInvalidResult,
RuntimeTurnSettledProcessedResult,
} from '../../core/application';
import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
const DEFAULT_PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000;
const DEFAULT_PROCESSED_RETENTION_COUNT = 1000;
const DEFAULT_PROCESSING_STALE_MS = 5 * 60 * 1000;
export interface FileRuntimeTurnSettledEventStoreDeps {
paths: RuntimeTurnSettledSpoolPaths;
maxPayloadBytes?: number;
processedRetentionMs?: number;
processedRetentionCount?: number;
processingStaleMs?: number;
now?: () => Date;
}
function parseProviderFromFileName(fileName: string): 'claude' | 'codex' | null {
const parts = fileName.split('.');
const provider = parts.length >= 3 ? parts[parts.length - 2] : null;
return isRuntimeTurnSettledProvider(provider) ? provider : null;
}
function buildMetaFilePath(filePath: string): string {
return `${filePath}.meta.json`;
}
async function moveFileBestEffort(sourcePath: string, targetPath: string): Promise<void> {
await mkdir(path.dirname(targetPath), { recursive: true });
try {
await rename(sourcePath, targetPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEventStorePort {
private readonly maxPayloadBytes: number;
private readonly processedRetentionMs: number;
private readonly processedRetentionCount: number;
private readonly processingStaleMs: number;
private readonly now: () => Date;
constructor(private readonly deps: FileRuntimeTurnSettledEventStoreDeps) {
this.maxPayloadBytes = deps.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
this.processedRetentionMs = deps.processedRetentionMs ?? DEFAULT_PROCESSED_RETENTION_MS;
this.processedRetentionCount =
deps.processedRetentionCount ?? DEFAULT_PROCESSED_RETENTION_COUNT;
this.processingStaleMs = deps.processingStaleMs ?? DEFAULT_PROCESSING_STALE_MS;
this.now = deps.now ?? (() => new Date());
}
async claimPending(limit: number): Promise<RuntimeTurnSettledClaimedPayload[]> {
await this.ensureDirectories();
await this.recoverStaleProcessingPayloads();
const entries = await readdir(this.deps.paths.getIncomingDir(), { withFileTypes: true }).catch(
() => []
);
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((fileName) => !fileName.startsWith('.'))
.sort()
.slice(0, Math.max(0, limit));
const claimed: RuntimeTurnSettledClaimedPayload[] = [];
for (const fileName of files) {
const provider = parseProviderFromFileName(fileName);
const incomingPath = path.join(this.deps.paths.getIncomingDir(), fileName);
if (!provider) {
await this.quarantineIncoming(incomingPath, fileName, 'unsupported_provider');
continue;
}
const processingPath = path.join(this.deps.paths.getProcessingDir(), fileName);
try {
await rename(incomingPath, processingPath);
} catch {
continue;
}
const fileStat = await stat(processingPath).catch(() => null);
const payloadTooLarge = Boolean(fileStat?.isFile() && fileStat.size > this.maxPayloadBytes);
if (!fileStat?.isFile() || payloadTooLarge) {
await this.markInvalid(
{
id: fileName,
filePath: processingPath,
fileName,
provider,
raw: '',
claimedAt: this.now().toISOString(),
},
{
reason: payloadTooLarge ? 'payload_too_large' : 'payload_missing',
processedAt: this.now().toISOString(),
}
);
continue;
}
const raw = await readFile(processingPath, 'utf8').catch(() => '');
claimed.push({
id: fileName,
filePath: processingPath,
fileName,
provider,
raw,
claimedAt: this.now().toISOString(),
});
}
return claimed;
}
async markProcessed(
payload: RuntimeTurnSettledClaimedPayload,
result: RuntimeTurnSettledProcessedResult
): Promise<void> {
const processedPath = path.join(this.deps.paths.getProcessedDir(), payload.fileName);
await moveFileBestEffort(payload.filePath, processedPath);
await writeFile(
buildMetaFilePath(processedPath),
`${JSON.stringify(result, null, 2)}\n`,
'utf8'
);
await this.cleanupDirectory(this.deps.paths.getProcessedDir());
}
async markInvalid(
payload: RuntimeTurnSettledClaimedPayload,
result: RuntimeTurnSettledInvalidResult
): Promise<void> {
const invalidPath = path.join(this.deps.paths.getInvalidDir(), payload.fileName);
await moveFileBestEffort(payload.filePath, invalidPath);
await writeFile(buildMetaFilePath(invalidPath), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
await this.cleanupDirectory(this.deps.paths.getInvalidDir());
}
private async ensureDirectories(): Promise<void> {
await Promise.all([
mkdir(this.deps.paths.getIncomingDir(), { recursive: true }),
mkdir(this.deps.paths.getProcessingDir(), { recursive: true }),
mkdir(this.deps.paths.getProcessedDir(), { recursive: true }),
mkdir(this.deps.paths.getInvalidDir(), { recursive: true }),
]);
}
private async recoverStaleProcessingPayloads(): Promise<void> {
const cutoff = this.now().getTime() - this.processingStaleMs;
const entries = await readdir(this.deps.paths.getProcessingDir(), {
withFileTypes: true,
}).catch(() => []);
await Promise.allSettled(
entries
.filter(
(entry) =>
entry.isFile() && !entry.name.startsWith('.') && !entry.name.endsWith('.meta.json')
)
.map(async (entry) => {
const processingPath = path.join(this.deps.paths.getProcessingDir(), entry.name);
const fileStat = await stat(processingPath).catch(() => null);
if (!fileStat?.isFile() || fileStat.mtimeMs > cutoff) {
return;
}
await moveFileBestEffort(
processingPath,
path.join(this.deps.paths.getIncomingDir(), entry.name)
);
})
);
}
private async quarantineIncoming(
incomingPath: string,
fileName: string,
reason: string
): Promise<void> {
const invalidPath = path.join(this.deps.paths.getInvalidDir(), fileName);
await moveFileBestEffort(incomingPath, invalidPath);
await writeFile(
buildMetaFilePath(invalidPath),
`${JSON.stringify({ reason, processedAt: this.now().toISOString() }, null, 2)}\n`,
'utf8'
);
}
private async cleanupDirectory(directory: string): Promise<void> {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
const files = (
await Promise.all(
entries
.filter((entry) => entry.isFile() && !entry.name.endsWith('.meta.json'))
.map(async (entry) => {
const filePath = path.join(directory, entry.name);
const fileStat = await stat(filePath).catch(() => null);
return fileStat?.isFile() ? { filePath, mtimeMs: fileStat.mtimeMs } : null;
})
)
)
.filter((entry): entry is { filePath: string; mtimeMs: number } => Boolean(entry))
.sort((left, right) => right.mtimeMs - left.mtimeMs);
const cutoff = this.now().getTime() - this.processedRetentionMs;
const toRemove = files.filter(
(file, index) => index >= this.processedRetentionCount || file.mtimeMs < cutoff
);
await Promise.allSettled(
toRemove.flatMap((file) => [
rm(file.filePath, { force: true }),
rm(buildMetaFilePath(file.filePath), { force: true }),
])
);
}
}