agent-ecosystem/src/main/services/infrastructure/TeamTaskWatchRegistry.ts

290 lines
8.2 KiB
TypeScript

import { OPENCODE_TASK_LOG_ATTRIBUTION_FILE } from '@shared/constants/opencodeTaskLogAttribution';
import { watch } from 'chokidar';
import * as fsp from 'fs/promises';
import * as path from 'path';
import type { FSWatcher } from 'chokidar';
import type { Dirent } from 'fs';
export type TeamTaskWatchKind = 'teams' | 'tasks';
export type TeamTaskWatchEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir';
export interface TeamTaskWatchRegistryOptions {
kind: TeamTaskWatchKind;
rootPath: string;
onChange: (eventType: TeamTaskWatchEventType, relativePath: string) => void;
onError: (error: unknown) => void;
}
const RECONCILE_INTERVAL_MS = 30_000;
const TEAM_ROOT_FILES = new Set([
'config.json',
'processes.json',
'sentMessages.json',
'team.meta.json',
'members.meta.json',
OPENCODE_TASK_LOG_ATTRIBUTION_FILE,
]);
export class TeamTaskWatchRegistry {
private watcher: FSWatcher | null = null;
private reconcileTimer: NodeJS.Timeout | null = null;
private targets = new Set<string>();
private targetKey = '';
private initialTargetsCaptured = false;
private closed = false;
private generation = 0;
private reconcileInProgress = false;
private reconcileAgain = false;
constructor(private readonly options: TeamTaskWatchRegistryOptions) {}
async start(): Promise<void> {
if (this.closed) {
return;
}
await this.reconcileTargets();
if (this.closed || this.reconcileTimer) {
return;
}
this.reconcileTimer = setInterval(() => {
void this.reconcileTargets();
}, RECONCILE_INTERVAL_MS);
this.reconcileTimer.unref();
}
async close(): Promise<void> {
this.closed = true;
this.generation += 1;
if (this.reconcileTimer) {
clearInterval(this.reconcileTimer);
this.reconcileTimer = null;
}
const watcher = this.watcher;
this.watcher = null;
this.targets.clear();
this.targetKey = '';
if (watcher) {
await watcher.close().catch(() => undefined);
}
}
private async reconcileTargets(): Promise<void> {
if (this.closed) {
return;
}
if (this.reconcileInProgress) {
this.reconcileAgain = true;
return;
}
this.reconcileInProgress = true;
try {
const targets = await this.collectTargets();
const nextKey = targets.join('\n');
if (nextKey !== this.targetKey) {
const addedTargets = targets.filter((target) => !this.targets.has(target));
await this.rebuildWatcher(targets, nextKey, addedTargets);
}
} catch (error) {
if (!this.closed) {
this.options.onError(error);
}
} finally {
this.reconcileInProgress = false;
}
if (this.reconcileAgain && !this.closed) {
this.reconcileAgain = false;
await this.reconcileTargets();
}
}
private async rebuildWatcher(
targets: string[],
nextKey: string,
addedTargets: string[]
): Promise<void> {
const generation = this.generation + 1;
this.generation = generation;
const previousWatcher = this.watcher;
this.watcher = null;
if (previousWatcher) {
await previousWatcher.close().catch(() => undefined);
}
if (this.closed || generation !== this.generation) {
return;
}
const nextWatcher = watch(targets, {
ignoreInitial: true,
ignorePermissionErrors: true,
followSymlinks: false,
depth: 0,
});
this.watcher = nextWatcher;
this.targets = new Set(targets);
this.targetKey = nextKey;
const shouldEmitExistingFiles = this.initialTargetsCaptured;
this.initialTargetsCaptured = true;
const handleEvent = (eventType: TeamTaskWatchEventType, changedPath?: string): void => {
if (this.closed || generation !== this.generation || !changedPath) {
return;
}
const relativePath = this.toRelativePath(changedPath);
if (!relativePath) {
return;
}
if (this.shouldReconcile(eventType, relativePath)) {
void this.reconcileTargets();
}
if (!this.shouldEmit(eventType, relativePath)) {
return;
}
this.options.onChange(eventType, relativePath);
};
nextWatcher.on('add', (changedPath) => handleEvent('add', changedPath));
nextWatcher.on('change', (changedPath) => handleEvent('change', changedPath));
nextWatcher.on('unlink', (changedPath) => handleEvent('unlink', changedPath));
nextWatcher.on('addDir', (changedPath) => handleEvent('addDir', changedPath));
nextWatcher.on('unlinkDir', (changedPath) => handleEvent('unlinkDir', changedPath));
nextWatcher.on('error', (error) => {
if (!this.closed && generation === this.generation) {
this.options.onError(error);
}
});
if (shouldEmitExistingFiles) {
await this.emitExistingFilesForNewTargets(addedTargets, generation);
}
}
private async emitExistingFilesForNewTargets(
targets: string[],
generation: number
): Promise<void> {
const normalizedRoot = path.normalize(this.options.rootPath);
for (const targetPath of targets) {
if (this.closed || generation !== this.generation) {
return;
}
if (path.normalize(targetPath) === normalizedRoot) {
continue;
}
const entries = await this.readDirectory(targetPath);
for (const entry of entries) {
if (this.closed || generation !== this.generation) {
return;
}
if (!entry.isFile()) {
continue;
}
const relativePath = this.toRelativePath(path.join(targetPath, entry.name));
if (relativePath && this.shouldEmit('add', relativePath)) {
this.options.onChange('add', relativePath);
}
}
}
}
private async collectTargets(): Promise<string[]> {
const targets = new Set<string>([path.normalize(this.options.rootPath)]);
const rootEntries = await this.readDirectory(this.options.rootPath);
for (const entry of rootEntries) {
if (!entry.isDirectory()) {
continue;
}
const teamPath = path.join(this.options.rootPath, entry.name);
targets.add(path.normalize(teamPath));
if (this.options.kind === 'teams') {
const inboxPath = path.join(teamPath, 'inboxes');
if (await this.isDirectory(inboxPath)) {
targets.add(path.normalize(inboxPath));
}
}
}
return [...targets].sort((left, right) => left.localeCompare(right));
}
private async readDirectory(dirPath: string): Promise<Dirent[]> {
try {
return await fsp.readdir(dirPath, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
}
private async isDirectory(dirPath: string): Promise<boolean> {
try {
return (await fsp.stat(dirPath)).isDirectory();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
}
private toRelativePath(changedPath: string): string | null {
const absolutePath = path.isAbsolute(changedPath)
? changedPath
: path.join(this.options.rootPath, changedPath);
const relativePath = path.relative(this.options.rootPath, absolutePath);
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
return relativePath.replace(/\\/g, '/');
}
private shouldReconcile(eventType: TeamTaskWatchEventType, relativePath: string): boolean {
if (eventType !== 'addDir' && eventType !== 'unlinkDir') {
return false;
}
const parts = relativePath.split('/').filter(Boolean);
if (parts.length === 1) {
return true;
}
return this.options.kind === 'teams' && parts.length === 2 && parts[1] === 'inboxes';
}
private shouldEmit(eventType: TeamTaskWatchEventType, relativePath: string): boolean {
if (eventType === 'addDir' || eventType === 'unlinkDir') {
return false;
}
const parts = relativePath.split('/').filter(Boolean);
if (this.options.kind === 'tasks') {
return parts.length === 2 && !parts[1].startsWith('.') && parts[1].endsWith('.json');
}
if (parts.length === 2) {
return TEAM_ROOT_FILES.has(parts[1]);
}
return parts.length === 3 && parts[1] === 'inboxes' && parts[2].endsWith('.json');
}
}