- rendererLogs: inline IPC channel constants to fix boundary violation - EditorFileWatcher/TaskBoundaryParser: rename UPPER_CASE class props to camelCase - TeamConfigReader: remove unused readFileHead/extractQuotedString functions - TeamFsWorkerClient: use crypto.randomUUID(), remove duplicated branches - team-fs-worker: full type safety rewrite — 10 interfaces replacing ~150 any casts, extract readTasksDirForTeam diagnostics to avoid param reassign - useDiffNavigation/editorSlice/buildSelectionAction: add missing return types - useFileSuggestions: suppress intentional setState-in-effect - store/index: suppress intentional deprecated navigator.platform fallback - platformPath: replace vulnerable regexes with manual loops
227 lines
7 KiB
TypeScript
227 lines
7 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { Worker } from 'node:worker_threads';
|
|
|
|
import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
|
|
import type { TeamSummary, TeamTask } from '@shared/types';
|
|
|
|
const logger = createLogger('Service:TeamFsWorkerClient');
|
|
|
|
const DEFAULT_CONCURRENCY = process.platform === 'win32' ? 4 : 12;
|
|
const DEFAULT_READ_TIMEOUT_MS = 5_000;
|
|
const WORKER_CALL_TIMEOUT_MS = 20_000;
|
|
|
|
type WorkerDiag = Record<string, unknown>;
|
|
|
|
interface ListTeamsPayload {
|
|
teamsDir: string;
|
|
largeConfigBytes: number;
|
|
configHeadBytes: number;
|
|
maxConfigBytes: number;
|
|
maxConfigReadMs: number;
|
|
maxMembersMetaBytes: number;
|
|
maxSessionHistoryInSummary: number;
|
|
maxProjectPathHistoryInSummary: number;
|
|
concurrency: number;
|
|
}
|
|
|
|
interface GetAllTasksPayload {
|
|
tasksBase: string;
|
|
maxTaskBytes: number;
|
|
maxTaskReadMs: number;
|
|
concurrency: number;
|
|
}
|
|
|
|
type WorkerRequest =
|
|
| { id: string; op: 'listTeams'; payload: ListTeamsPayload }
|
|
| { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload };
|
|
|
|
type WorkerResponse =
|
|
| { id: string; ok: true; result: unknown; diag?: WorkerDiag }
|
|
| { id: string; ok: false; error: string };
|
|
|
|
function makeId(): string {
|
|
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
|
}
|
|
|
|
function resolveWorkerPath(): string | null {
|
|
// We try multiple locations because dev/prod/test environments differ.
|
|
// Priority: co-located with bundled main output, then workspace dist folder.
|
|
const baseDir =
|
|
typeof __dirname === 'string' && __dirname.length > 0
|
|
? __dirname
|
|
: path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
const candidates = [
|
|
path.join(baseDir, 'team-fs-worker.cjs'),
|
|
path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.cjs'),
|
|
path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.js'),
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
if (fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export class TeamFsWorkerClient {
|
|
private worker: Worker | null = null;
|
|
private readonly workerPath: string | null = resolveWorkerPath();
|
|
private warnedUnavailable = false;
|
|
private pending = new Map<
|
|
string,
|
|
{ resolve: (v: { result: unknown; diag?: WorkerDiag }) => void; reject: (e: Error) => void }
|
|
>();
|
|
|
|
isAvailable(): boolean {
|
|
if (!this.workerPath && !this.warnedUnavailable) {
|
|
this.warnedUnavailable = true;
|
|
const baseDir =
|
|
typeof __dirname === 'string' && __dirname.length > 0
|
|
? __dirname
|
|
: path.dirname(fileURLToPath(import.meta.url));
|
|
const expected = [
|
|
path.join(baseDir, 'team-fs-worker.cjs'),
|
|
path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.cjs'),
|
|
];
|
|
logger.warn(
|
|
`team-fs-worker not found; falling back to main-thread scanning. expectedOneOf=${expected.join(',')}`
|
|
);
|
|
}
|
|
return this.workerPath !== null;
|
|
}
|
|
|
|
private ensureWorker(): Worker {
|
|
if (!this.workerPath) {
|
|
throw new Error('Worker is not available in this environment');
|
|
}
|
|
if (this.worker) {
|
|
return this.worker;
|
|
}
|
|
|
|
this.worker = new Worker(this.workerPath);
|
|
this.worker.on('message', (msg: WorkerResponse) => {
|
|
const entry = this.pending.get(msg.id);
|
|
if (!entry) return;
|
|
this.pending.delete(msg.id);
|
|
if (msg.ok) {
|
|
entry.resolve({ result: msg.result, diag: msg.diag });
|
|
} else {
|
|
entry.reject(new Error(msg.error));
|
|
}
|
|
});
|
|
this.worker.on('error', (err) => {
|
|
logger.error('Worker error', err);
|
|
for (const [, entry] of this.pending) {
|
|
entry.reject(err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
this.pending.clear();
|
|
this.worker = null;
|
|
});
|
|
this.worker.on('exit', (code) => {
|
|
if (code !== 0) {
|
|
logger.warn(`Worker exited with code ${code}`);
|
|
}
|
|
for (const [, entry] of this.pending) {
|
|
entry.reject(new Error(`Worker exited with code ${code}`));
|
|
}
|
|
this.pending.clear();
|
|
this.worker = null;
|
|
});
|
|
|
|
return this.worker;
|
|
}
|
|
|
|
private call(
|
|
op: WorkerRequest['op'],
|
|
payload: WorkerRequest['payload']
|
|
): Promise<{ result: unknown; diag?: WorkerDiag }> {
|
|
const worker = this.ensureWorker();
|
|
const id = makeId();
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
this.pending.delete(id);
|
|
try {
|
|
// Terminate and recreate on next call — worker may be stuck in native IO.
|
|
this.worker?.terminate().catch(() => undefined);
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
this.worker = null;
|
|
}
|
|
reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})`));
|
|
}, WORKER_CALL_TIMEOUT_MS);
|
|
|
|
this.pending.set(id, {
|
|
resolve: (value) => {
|
|
clearTimeout(timeout);
|
|
resolve(value);
|
|
},
|
|
reject: (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
},
|
|
});
|
|
worker.postMessage({ id, op, payload } as WorkerRequest);
|
|
});
|
|
}
|
|
|
|
async listTeams(options: {
|
|
largeConfigBytes: number;
|
|
configHeadBytes: number;
|
|
maxConfigBytes: number;
|
|
maxMembersMetaBytes: number;
|
|
maxSessionHistoryInSummary: number;
|
|
maxProjectPathHistoryInSummary: number;
|
|
concurrency?: number;
|
|
maxConfigReadMs?: number;
|
|
}): Promise<{ teams: TeamSummary[]; diag?: WorkerDiag }> {
|
|
const payload: ListTeamsPayload = {
|
|
teamsDir: getTeamsBasePath(),
|
|
largeConfigBytes: options.largeConfigBytes,
|
|
configHeadBytes: options.configHeadBytes,
|
|
maxConfigBytes: options.maxConfigBytes,
|
|
maxConfigReadMs: options.maxConfigReadMs ?? DEFAULT_READ_TIMEOUT_MS,
|
|
maxMembersMetaBytes: options.maxMembersMetaBytes,
|
|
maxSessionHistoryInSummary: options.maxSessionHistoryInSummary,
|
|
maxProjectPathHistoryInSummary: options.maxProjectPathHistoryInSummary,
|
|
concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
|
|
};
|
|
const { result, diag } = await this.call('listTeams', payload);
|
|
return { teams: result as TeamSummary[], diag };
|
|
}
|
|
|
|
async getAllTasks(options: {
|
|
maxTaskBytes: number;
|
|
concurrency?: number;
|
|
maxTaskReadMs?: number;
|
|
}): Promise<{ tasks: (TeamTask & { teamName: string })[]; diag?: WorkerDiag }> {
|
|
const payload: GetAllTasksPayload = {
|
|
tasksBase: getTasksBasePath(),
|
|
maxTaskBytes: options.maxTaskBytes,
|
|
maxTaskReadMs: options.maxTaskReadMs ?? DEFAULT_READ_TIMEOUT_MS,
|
|
concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
|
|
};
|
|
const { result, diag } = await this.call('getAllTasks', payload);
|
|
return { tasks: result as (TeamTask & { teamName: string })[], diag };
|
|
}
|
|
}
|
|
|
|
let singleton: TeamFsWorkerClient | null = null;
|
|
|
|
export function getTeamFsWorkerClient(): TeamFsWorkerClient {
|
|
if (!singleton) {
|
|
singleton = new TeamFsWorkerClient();
|
|
}
|
|
return singleton;
|
|
}
|