agent-ecosystem/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts

172 lines
5.9 KiB
TypeScript

import { withFileLock } from '@main/services/team/fileLock';
import { appendFile, mkdir, rename, rm, stat } from 'fs/promises';
import { dirname } from 'path';
import type {
MemberWorkSyncAuditEvent,
MemberWorkSyncAuditJournalPort,
MemberWorkSyncLoggerPort,
} from '../../core/application';
import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths';
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
const DEFAULT_ROTATED_FILE_COUNT = 5;
const MAX_PREVIEW_CHARS = 240;
const MAX_DIAGNOSTICS = 20;
const MAX_TRIGGER_REASONS = 20;
const MAX_TASK_REFS = 20;
const MAX_SHORT_FIELD_CHARS = 240;
interface PersistedAuditEvent extends MemberWorkSyncAuditEvent {
schemaVersion: 1;
}
export interface FileMemberWorkSyncAuditJournalOptions {
maxBytes?: number;
rotatedFileCount?: number;
}
function truncateText(value: string, maxChars: number): string {
return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`;
}
function sanitizeMetadata(
metadata: MemberWorkSyncAuditEvent['metadata']
): MemberWorkSyncAuditEvent['metadata'] {
if (!metadata) {
return undefined;
}
const sanitized = Object.create(null) as NonNullable<MemberWorkSyncAuditEvent['metadata']>;
for (const [key, value] of Object.entries(metadata)) {
sanitized[truncateText(key, MAX_SHORT_FIELD_CHARS)] =
typeof value === 'string' ? truncateText(value, MAX_SHORT_FIELD_CHARS) : value;
}
return sanitized;
}
function sanitizeTaskRefs(
taskRefs: MemberWorkSyncAuditEvent['taskRefs']
): MemberWorkSyncAuditEvent['taskRefs'] {
return taskRefs?.slice(0, MAX_TASK_REFS).map((taskRef) => ({
taskId: truncateText(taskRef.taskId, MAX_SHORT_FIELD_CHARS),
...(taskRef.displayId
? { displayId: truncateText(taskRef.displayId, MAX_SHORT_FIELD_CHARS) }
: {}),
...(taskRef.teamName
? { teamName: truncateText(taskRef.teamName, MAX_SHORT_FIELD_CHARS) }
: {}),
}));
}
function sanitizeEvent(event: MemberWorkSyncAuditEvent): PersistedAuditEvent {
return {
...event,
schemaVersion: 1,
source: truncateText(event.source, MAX_SHORT_FIELD_CHARS),
...(event.reason ? { reason: truncateText(event.reason, MAX_SHORT_FIELD_CHARS) } : {}),
...(event.providerId
? { providerId: truncateText(event.providerId, MAX_SHORT_FIELD_CHARS) }
: {}),
...(event.state ? { state: truncateText(event.state, MAX_SHORT_FIELD_CHARS) } : {}),
...(event.agendaFingerprint
? { agendaFingerprint: truncateText(event.agendaFingerprint, MAX_SHORT_FIELD_CHARS) }
: {}),
...(typeof event.messagePreview === 'string'
? { messagePreview: truncateText(event.messagePreview, MAX_PREVIEW_CHARS) }
: {}),
...(event.diagnostics
? {
diagnostics: event.diagnostics
.slice(0, MAX_DIAGNOSTICS)
.map((diagnostic) => truncateText(diagnostic, MAX_SHORT_FIELD_CHARS)),
}
: {}),
...(event.triggerReasons
? {
triggerReasons: event.triggerReasons
.slice(0, MAX_TRIGGER_REASONS)
.map((reason) => truncateText(reason, MAX_SHORT_FIELD_CHARS)),
}
: {}),
...(event.taskRefs ? { taskRefs: sanitizeTaskRefs(event.taskRefs) } : {}),
...(event.metadata ? { metadata: sanitizeMetadata(event.metadata) } : {}),
};
}
function rotatedPath(filePath: string, index: number): string {
return `${filePath}.${index}`;
}
async function rotateIfNeeded(
filePath: string,
maxBytes: number,
rotatedFileCount: number
): Promise<void> {
const current = await stat(filePath).catch(() => null);
if (!current?.isFile() || current.size < maxBytes) {
return;
}
await rm(rotatedPath(filePath, rotatedFileCount), { force: true }).catch(() => undefined);
for (let index = rotatedFileCount - 1; index >= 1; index -= 1) {
await rename(rotatedPath(filePath, index), rotatedPath(filePath, index + 1)).catch(
() => undefined
);
}
await rename(filePath, rotatedPath(filePath, 1)).catch(() => undefined);
}
export class NoopMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJournalPort {
async append(): Promise<void> {
// Intentionally empty.
}
}
export class FileMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJournalPort {
private readonly maxBytes: number;
private readonly rotatedFileCount: number;
private readonly appendChains = new Map<string, Promise<void>>();
constructor(
private readonly paths: MemberWorkSyncStorePaths,
private readonly logger?: MemberWorkSyncLoggerPort,
options: FileMemberWorkSyncAuditJournalOptions = {}
) {
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
this.rotatedFileCount = options.rotatedFileCount ?? DEFAULT_ROTATED_FILE_COUNT;
}
async append(event: MemberWorkSyncAuditEvent): Promise<void> {
const filePath = this.paths.getMemberJournalPath(event.teamName, event.memberName);
const previous = this.appendChains.get(filePath) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(() => this.appendToFile(filePath, event));
this.appendChains.set(filePath, next);
try {
await next;
} finally {
if (this.appendChains.get(filePath) === next) {
this.appendChains.delete(filePath);
}
}
}
private async appendToFile(filePath: string, event: MemberWorkSyncAuditEvent): Promise<void> {
try {
await this.paths.ensureMemberWorkSyncDir(event.teamName, event.memberName);
await mkdir(dirname(filePath), { recursive: true });
await withFileLock(filePath, async () => {
await rotateIfNeeded(filePath, this.maxBytes, this.rotatedFileCount);
await appendFile(filePath, `${JSON.stringify(sanitizeEvent(event))}\n`, 'utf8');
});
} catch (error) {
this.logger?.warn('member work sync audit journal append failed', {
teamName: event.teamName,
memberName: event.memberName,
event: event.event,
error: String(error),
});
}
}
}