A team launch creates many directories/files in quick succession (worktrees, inboxes, session logs), and each addDir/unlinkDir event triggered a full TeamTaskWatchRegistry reconcile that tore down and recreated the entire chokidar watcher (re-opening a kqueue fd per watched file on macOS). Profiling a 6-member mixed-team launch showed kqueue churn (kevent) as a top native cost and watcher rebuild as the top remaining main-thread JS cost after the transcript fix. Debounce the event-driven reconcile (250ms) so a burst collapses into one rebuild. collectTargets re-reads the current directory state and emitExistingFilesForNewTargets backfills files created before the rebuild, so no change is missed; requestReconcile, startup, and the periodic 30s reconcile stay immediate. Adds a test asserting a burst of addDir events yields a single rebuild.
197 lines
6.9 KiB
TypeScript
197 lines
6.9 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
type MockChokidarWatcher = {
|
|
targets: string[];
|
|
options: unknown;
|
|
handlers: Map<string, Array<(...args: unknown[]) => void>>;
|
|
on: (event: string, handler: (...args: unknown[]) => void) => MockChokidarWatcher;
|
|
emit: (event: string, ...args: unknown[]) => void;
|
|
close: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
const chokidarMock = vi.hoisted(() => {
|
|
const instances: MockChokidarWatcher[] = [];
|
|
const make = () => (targets: string | string[], options: unknown) => {
|
|
const watcher = {
|
|
targets: (Array.isArray(targets) ? targets : [targets]).map((t) => String(t)),
|
|
options,
|
|
handlers: new Map<string, Array<(...args: unknown[]) => void>>(),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
emit(event: string, ...args: unknown[]) {
|
|
for (const h of watcher.handlers.get(event) ?? []) h(...args);
|
|
},
|
|
} as MockChokidarWatcher;
|
|
watcher.on = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
const hs = watcher.handlers.get(event) ?? [];
|
|
hs.push(handler);
|
|
watcher.handlers.set(event, hs);
|
|
return watcher;
|
|
});
|
|
instances.push(watcher);
|
|
return watcher;
|
|
};
|
|
const watch = vi.fn(make());
|
|
return {
|
|
instances,
|
|
watch,
|
|
reset() {
|
|
instances.length = 0;
|
|
watch.mockReset();
|
|
watch.mockImplementation(make());
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('chokidar', () => ({ watch: chokidarMock.watch }));
|
|
|
|
import { TeamTaskWatchRegistry } from '../../../../src/main/services/infrastructure/TeamTaskWatchRegistry';
|
|
|
|
function latestTargets(): string[] {
|
|
const last = chokidarMock.instances.at(-1);
|
|
return (last?.targets ?? []).map((t) => path.normalize(t));
|
|
}
|
|
|
|
describe('TeamTaskWatchRegistry scoping', () => {
|
|
let root: string;
|
|
|
|
beforeEach(() => {
|
|
chokidarMock.reset();
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'ttwr-scope-'));
|
|
for (const team of ['alpha', 'beta', 'gamma']) {
|
|
fs.mkdirSync(path.join(root, team, 'inboxes'), { recursive: true });
|
|
fs.writeFileSync(path.join(root, team, 'config.json'), '{}');
|
|
fs.writeFileSync(path.join(root, team, 'inboxes', 'team-lead.json'), '[]');
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
it('watches only scoped team dirs but every team inbox (teams kind)', async () => {
|
|
const registry = new TeamTaskWatchRegistry({
|
|
kind: 'teams',
|
|
rootPath: root,
|
|
onChange: () => {},
|
|
onError: () => {},
|
|
getScopedTeamNames: () => new Set(['alpha']),
|
|
});
|
|
await registry.start();
|
|
const targets = latestTargets();
|
|
await registry.close();
|
|
|
|
expect(targets).toContain(path.normalize(root));
|
|
// scoped team root watched, unscoped team roots not watched
|
|
expect(targets).toContain(path.normalize(path.join(root, 'alpha')));
|
|
expect(targets).not.toContain(path.normalize(path.join(root, 'beta')));
|
|
expect(targets).not.toContain(path.normalize(path.join(root, 'gamma')));
|
|
// ALL inboxes watched regardless of scope (cross-team delivery)
|
|
expect(targets).toContain(path.normalize(path.join(root, 'alpha', 'inboxes')));
|
|
expect(targets).toContain(path.normalize(path.join(root, 'beta', 'inboxes')));
|
|
expect(targets).toContain(path.normalize(path.join(root, 'gamma', 'inboxes')));
|
|
});
|
|
|
|
it('falls back to watching every team when no scope provider is given', async () => {
|
|
const registry = new TeamTaskWatchRegistry({
|
|
kind: 'teams',
|
|
rootPath: root,
|
|
onChange: () => {},
|
|
onError: () => {},
|
|
});
|
|
await registry.start();
|
|
const targets = latestTargets();
|
|
await registry.close();
|
|
|
|
for (const team of ['alpha', 'beta', 'gamma']) {
|
|
expect(targets).toContain(path.normalize(path.join(root, team)));
|
|
expect(targets).toContain(path.normalize(path.join(root, team, 'inboxes')));
|
|
}
|
|
});
|
|
|
|
it('falls back to watching every team when the scope provider returns null', async () => {
|
|
const registry = new TeamTaskWatchRegistry({
|
|
kind: 'teams',
|
|
rootPath: root,
|
|
onChange: () => {},
|
|
onError: () => {},
|
|
getScopedTeamNames: () => null,
|
|
});
|
|
await registry.start();
|
|
const targets = latestTargets();
|
|
await registry.close();
|
|
|
|
for (const team of ['alpha', 'beta', 'gamma']) {
|
|
expect(targets).toContain(path.normalize(path.join(root, team)));
|
|
}
|
|
});
|
|
|
|
it('scopes task dirs and never adds inboxes (tasks kind)', async () => {
|
|
const registry = new TeamTaskWatchRegistry({
|
|
kind: 'tasks',
|
|
rootPath: root,
|
|
onChange: () => {},
|
|
onError: () => {},
|
|
getScopedTeamNames: () => new Set(['beta']),
|
|
});
|
|
await registry.start();
|
|
const targets = latestTargets();
|
|
await registry.close();
|
|
|
|
expect(targets).toContain(path.normalize(root));
|
|
expect(targets).toContain(path.normalize(path.join(root, 'beta')));
|
|
expect(targets).not.toContain(path.normalize(path.join(root, 'alpha')));
|
|
expect(targets).not.toContain(path.normalize(path.join(root, 'gamma')));
|
|
// tasks kind never watches inboxes
|
|
expect(targets).not.toContain(path.normalize(path.join(root, 'beta', 'inboxes')));
|
|
});
|
|
|
|
it('re-resolves scope on requestReconcile (newly scoped team gets watched)', async () => {
|
|
const scoped = new Set<string>(['alpha']);
|
|
const registry = new TeamTaskWatchRegistry({
|
|
kind: 'teams',
|
|
rootPath: root,
|
|
onChange: () => {},
|
|
onError: () => {},
|
|
getScopedTeamNames: () => scoped,
|
|
});
|
|
await registry.start();
|
|
expect(latestTargets()).not.toContain(path.normalize(path.join(root, 'beta')));
|
|
|
|
scoped.add('beta');
|
|
await registry.requestReconcile();
|
|
const targets = latestTargets();
|
|
await registry.close();
|
|
|
|
expect(targets).toContain(path.normalize(path.join(root, 'beta')));
|
|
});
|
|
|
|
it('coalesces a burst of addDir events into a single watcher rebuild', async () => {
|
|
const registry = new TeamTaskWatchRegistry({
|
|
kind: 'teams',
|
|
rootPath: root,
|
|
onChange: () => {},
|
|
onError: () => {},
|
|
});
|
|
await registry.start();
|
|
const instancesAfterStart = chokidarMock.instances.length;
|
|
const watcher = chokidarMock.instances.at(-1) as MockChokidarWatcher;
|
|
|
|
// A new team dir appears, then a burst of addDir events fire for it.
|
|
fs.mkdirSync(path.join(root, 'delta', 'inboxes'), { recursive: true });
|
|
for (let i = 0; i < 4; i += 1) {
|
|
watcher.emit('addDir', path.join(root, 'delta'));
|
|
}
|
|
|
|
// Wait past the debounce window for the single coalesced reconcile to run.
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
const finalTargets = latestTargets();
|
|
await registry.close();
|
|
|
|
// Exactly one rebuild despite 4 addDir events, and it picked up the new dir.
|
|
expect(chokidarMock.instances.length).toBe(instancesAfterStart + 1);
|
|
expect(finalTargets).toContain(path.normalize(path.join(root, 'delta')));
|
|
});
|
|
});
|