agent-ecosystem/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts
2026-04-21 17:21:29 +03:00

143 lines
4.3 KiB
TypeScript

import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { getTaskChangePresenceBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import {
normalizePersistedTaskChangePresenceIndex,
toPersistedTaskChangePresenceIndex,
} from './taskChangePresenceCacheSchema';
import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes';
import type {
PersistedTaskChangePresence,
PersistedTaskChangePresenceIndex,
} from './taskChangePresenceCacheTypes';
import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository';
const logger = createLogger('Service:JsonTaskChangePresenceRepository');
const READ_TIMEOUT_MS = 5_000;
function encodeFileSegment(value: string): string {
return encodeURIComponent(value);
}
export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepository {
private readonly writeChains = new Map<string, Promise<void>>();
private get basePath(): string {
return getTaskChangePresenceBasePath();
}
private filePath(teamName: string): string {
return path.join(this.basePath, `${encodeFileSegment(teamName)}.json`);
}
private async readIndex(teamName: string): Promise<PersistedTaskChangePresenceIndex | null> {
const filePath = this.filePath(teamName);
let content: string;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
try {
content = await fs.promises.readFile(filePath, {
encoding: 'utf8',
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.warn(`Failed to read task-change presence index ${filePath}: ${String(error)}`);
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(content) as unknown;
} catch (error) {
logger.warn(`Corrupted task-change presence index ${filePath}: ${String(error)}`);
await fs.promises.unlink(filePath).catch(() => undefined);
return null;
}
const normalized = normalizePersistedTaskChangePresenceIndex(parsed);
if (!normalized) {
await fs.promises.unlink(filePath).catch(() => undefined);
return null;
}
return normalized;
}
async load(teamName: string): Promise<PersistedTaskChangePresenceIndex | null> {
return this.readIndex(teamName);
}
async upsertEntry(
teamName: string,
metadata: {
projectFingerprint: string;
logSourceGeneration: string;
writtenAt: string;
},
entry: {
taskId: string;
taskSignature: string;
presence: PersistedTaskChangePresence;
writtenAt: string;
logSourceGeneration: string;
}
): Promise<void> {
const write = async (): Promise<void> => {
const current =
(await this.readIndex(teamName)) ??
({
version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
teamName,
projectFingerprint: metadata.projectFingerprint,
logSourceGeneration: metadata.logSourceGeneration,
writtenAt: metadata.writtenAt,
entries: {},
} satisfies PersistedTaskChangePresenceIndex);
const next = toPersistedTaskChangePresenceIndex({
...current,
projectFingerprint: metadata.projectFingerprint,
logSourceGeneration: metadata.logSourceGeneration,
writtenAt: metadata.writtenAt,
entries: {
...current.entries,
[entry.taskId]: {
taskId: entry.taskId,
taskSignature: entry.taskSignature,
presence: entry.presence,
writtenAt: entry.writtenAt,
logSourceGeneration: entry.logSourceGeneration,
},
},
});
await atomicWriteAsync(this.filePath(teamName), JSON.stringify(next, null, 2));
};
const previous = this.writeChains.get(teamName) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(write)
.finally(() => {
if (this.writeChains.get(teamName) === next) {
this.writeChains.delete(teamName);
}
});
this.writeChains.set(teamName, next);
await next;
}
}