feat: enhance team file handling and logging improvements
- Added support for a new worker in the team file system to list teams, improving performance and reliability in team management. - Introduced event loop lag monitoring in various IPC handlers to track and log slow operations, enhancing observability. - Implemented caching for CLI installation status to reduce redundant calls and improve responsiveness. - Updated project and team data services to include total session counts, optimizing data handling and performance. - Enhanced error handling and logging across multiple services to provide clearer insights into performance issues and failures. Made-with: Cursor
This commit is contained in:
parent
43b18d4920
commit
a30727d3b0
41 changed files with 2011 additions and 147 deletions
|
|
@ -51,7 +51,8 @@ export default defineConfig({
|
|||
outDir: 'dist-electron/main',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/main/index.ts')
|
||||
index: resolve(__dirname, 'src/main/index.ts'),
|
||||
'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts')
|
||||
},
|
||||
output: {
|
||||
// CJS format so bundled deps can use __dirname/require.
|
||||
|
|
|
|||
|
|
@ -2702,6 +2702,30 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 159
|
||||
},
|
||||
"openrouter/anthropic/claude-sonnet-4.6": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
|
||||
"input_cost_per_token": 0.000003,
|
||||
"input_cost_per_token_above_200k_tokens": 0.000006,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 0.000015,
|
||||
"output_cost_per_token_above_200k_tokens": 0.0000225,
|
||||
"source": "https://openrouter.ai/anthropic/claude-sonnet-4.6",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 159
|
||||
},
|
||||
"openrouter/anthropic/claude-opus-4.5": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
"cache_read_input_token_cost": 5e-7,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { join } from 'path';
|
|||
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||
import { showTeamNativeNotification } from './ipc/teams';
|
||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||
|
|
@ -69,6 +70,7 @@ import type { FileChangeEvent } from '@main/types';
|
|||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
||||
const logger = createLogger('App');
|
||||
startEventLoopLagMonitor();
|
||||
|
||||
// --- Team message notification tracking ---
|
||||
const teamInboxReader = new TeamInboxReader();
|
||||
|
|
@ -792,6 +794,7 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
|
|||
*/
|
||||
function createWindow(): void {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar;
|
||||
mainWindow = new BrowserWindow({
|
||||
|
|
@ -802,6 +805,10 @@ function createWindow(): void {
|
|||
preload: join(__dirname, '../preload/index.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
// In development, avoid persistent Chromium storage locks (IndexedDB/Quota)
|
||||
// when multiple dev instances are started or ports rotate (5173 -> 5174, etc).
|
||||
// This keeps startup resilient and prevents "LOCK" contention.
|
||||
...(isDev ? { partition: `temp:dev-${process.pid}` } : {}),
|
||||
},
|
||||
backgroundColor: '#1a1a1a',
|
||||
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
||||
|
|
@ -809,9 +816,53 @@ function createWindow(): void {
|
|||
title: 'Claude Agent Teams UI',
|
||||
});
|
||||
|
||||
// In dev, forward selected renderer console warnings/errors to the main terminal.
|
||||
// Use the new single-argument event payload to avoid Electron deprecation warnings.
|
||||
if (isDev) {
|
||||
mainWindow.webContents.on('console-message', (details: unknown) => {
|
||||
if (!details || typeof details !== 'object') return;
|
||||
const d = details as {
|
||||
level?: unknown;
|
||||
message?: unknown;
|
||||
lineNumber?: unknown;
|
||||
sourceId?: unknown;
|
||||
};
|
||||
const level = typeof d.level === 'string' ? d.level : 'info';
|
||||
if (level !== 'warning' && level !== 'error') return;
|
||||
const message = typeof d.message === 'string' ? d.message.trim() : '';
|
||||
if (!message) return;
|
||||
const isNamespaced =
|
||||
message.startsWith('[Store:') ||
|
||||
message.startsWith('[Component:') ||
|
||||
message.startsWith('[IPC:') ||
|
||||
message.startsWith('[Service:') ||
|
||||
message.startsWith('[Perf:') ||
|
||||
message.startsWith('[startup]');
|
||||
if (!isNamespaced) return;
|
||||
const sourceId = typeof d.sourceId === 'string' ? d.sourceId : 'unknown';
|
||||
const line = typeof d.lineNumber === 'number' ? d.lineNumber : -1;
|
||||
logger.warn(`RendererConsole: ${message} (${sourceId}:${line})`);
|
||||
});
|
||||
}
|
||||
|
||||
// Load the renderer
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`);
|
||||
if (isDev) {
|
||||
// electron-vite may move the dev server off 5173 if it's already taken.
|
||||
// Always prefer the URL it provides via env; fallback to the default port.
|
||||
const envUrl =
|
||||
process.env.ELECTRON_RENDERER_URL ||
|
||||
process.env.VITE_DEV_SERVER_URL ||
|
||||
process.env.ELECTRON_VITE_DEV_SERVER_URL;
|
||||
const devUrl = envUrl?.trim() || `http://localhost:${DEV_SERVER_PORT}`;
|
||||
if (!envUrl) {
|
||||
logger.warn(
|
||||
`[dev] renderer dev server URL env not set; falling back to ${devUrl}. ` +
|
||||
`If you see "Port 5173 is in use" in the terminal, the UI may appear stuck until this is fixed.`
|
||||
);
|
||||
} else {
|
||||
logger.warn(`[dev] loading renderer from ${devUrl}`);
|
||||
}
|
||||
void mainWindow.loadURL(devUrl);
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
void mainWindow.loadFile(getRendererIndexPath()).catch((error: unknown) => {
|
||||
|
|
@ -834,6 +885,7 @@ function createWindow(): void {
|
|||
// Set traffic light position + notify renderer on first load, and auto-check for updates
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
logger.warn('[startup] renderer did-finish-load');
|
||||
syncTrafficLightPosition(mainWindow);
|
||||
setTimeout(() => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
|
|
@ -865,6 +917,10 @@ function createWindow(): void {
|
|||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('dom-ready', () => {
|
||||
logger.warn('[startup] renderer dom-ready');
|
||||
});
|
||||
|
||||
// Log top-level renderer load failures (helps diagnose blank/black window issues in packaged apps)
|
||||
mainWindow.webContents.on(
|
||||
'did-fail-load',
|
||||
|
|
@ -986,10 +1042,13 @@ void app.whenReady().then(() => {
|
|||
// Apply configuration settings
|
||||
const config = configManager.getConfig();
|
||||
|
||||
// Apply launch at login setting
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: config.general.launchAtLogin,
|
||||
});
|
||||
// Apply launch-at-login setting only in packaged builds.
|
||||
// In dev, macOS may deny this (and Electron logs a noisy error to stderr).
|
||||
if (app.isPackaged) {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: config.general.launchAtLogin,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply dock visibility and icon (macOS)
|
||||
if (process.platform === 'darwin') {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
|||
const logger = createLogger('IPC:cliInstaller');
|
||||
|
||||
let service: CliInstallerService;
|
||||
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
||||
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
||||
const STATUS_CACHE_TTL_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Initializes CLI installer handlers with the service instance.
|
||||
|
|
@ -58,7 +61,28 @@ async function handleGetStatus(
|
|||
_event: IpcMainInvokeEvent
|
||||
): Promise<IpcResult<CliInstallationStatus>> {
|
||||
try {
|
||||
const status = await service.getStatus();
|
||||
if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) {
|
||||
return { success: true, data: cachedStatus.value };
|
||||
}
|
||||
|
||||
if (!statusInFlight) {
|
||||
const startedAt = Date.now();
|
||||
statusInFlight = service
|
||||
.getStatus()
|
||||
.then((status) => {
|
||||
cachedStatus = { value: status, at: Date.now() };
|
||||
return status;
|
||||
})
|
||||
.finally(() => {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 2000) {
|
||||
logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
|
||||
}
|
||||
statusInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
const status = await statusInFlight;
|
||||
return { success: true, data: status };
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import {
|
|||
import { registerUtilityHandlers, removeUtilityHandlers } from './utility';
|
||||
import { registerValidationHandlers, removeValidationHandlers } from './validation';
|
||||
import { registerWindowHandlers, removeWindowHandlers } from './window';
|
||||
import { registerRendererLogHandlers, removeRendererLogHandlers } from './rendererLogs';
|
||||
|
||||
import type {
|
||||
ChangeExtractorService,
|
||||
|
|
@ -171,6 +172,7 @@ export function initializeIpcHandlers(
|
|||
registerReviewHandlers(ipcMain);
|
||||
registerEditorHandlers(ipcMain);
|
||||
registerWindowHandlers(ipcMain);
|
||||
registerRendererLogHandlers(ipcMain);
|
||||
if (cliInstaller) {
|
||||
registerCliInstallerHandlers(ipcMain);
|
||||
}
|
||||
|
|
@ -204,6 +206,7 @@ export function removeIpcHandlers(): void {
|
|||
removeReviewHandlers(ipcMain);
|
||||
removeEditorHandlers(ipcMain);
|
||||
removeWindowHandlers(ipcMain);
|
||||
removeRendererLogHandlers(ipcMain);
|
||||
removeCliInstallerHandlers(ipcMain);
|
||||
removeTerminalHandlers(ipcMain);
|
||||
removeHttpServerHandlers(ipcMain);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
import { setCurrentMainOp } from '../services/infrastructure/EventLoopLagMonitor';
|
||||
import { type Project, type RepositoryGroup, type Session } from '../types';
|
||||
|
||||
import { validateProjectId } from './guards';
|
||||
|
|
@ -59,6 +60,12 @@ export function removeProjectHandlers(ipcMain: IpcMain): void {
|
|||
* Lists all projects from ~/.claude/projects/
|
||||
*/
|
||||
async function handleGetProjects(_event: IpcMainInvokeEvent): Promise<Project[]> {
|
||||
setCurrentMainOp('projects:getProjects');
|
||||
const startedAt = Date.now();
|
||||
const watchdogMs = 10_000;
|
||||
const watchdog = setTimeout(() => {
|
||||
logger.warn(`get-projects still running after ${watchdogMs}ms`);
|
||||
}, watchdogMs);
|
||||
try {
|
||||
const { projectScanner } = registry.getActive();
|
||||
const projects = await projectScanner.scan();
|
||||
|
|
@ -66,6 +73,13 @@ async function handleGetProjects(_event: IpcMainInvokeEvent): Promise<Project[]>
|
|||
} catch (error) {
|
||||
logger.error('Error in get-projects:', error);
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(watchdog);
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 1500) {
|
||||
logger.warn(`get-projects slow ms=${ms}`);
|
||||
}
|
||||
setCurrentMainOp(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +89,12 @@ async function handleGetProjects(_event: IpcMainInvokeEvent): Promise<Project[]>
|
|||
* Worktrees of the same repo are grouped together.
|
||||
*/
|
||||
async function handleGetRepositoryGroups(_event: IpcMainInvokeEvent): Promise<RepositoryGroup[]> {
|
||||
setCurrentMainOp('projects:getRepositoryGroups');
|
||||
const startedAt = Date.now();
|
||||
const watchdogMs = 10_000;
|
||||
const watchdog = setTimeout(() => {
|
||||
logger.warn(`get-repository-groups still running after ${watchdogMs}ms`);
|
||||
}, watchdogMs);
|
||||
try {
|
||||
const { projectScanner } = registry.getActive();
|
||||
const groups = await projectScanner.scanWithWorktreeGrouping();
|
||||
|
|
@ -82,6 +102,13 @@ async function handleGetRepositoryGroups(_event: IpcMainInvokeEvent): Promise<Re
|
|||
} catch (error) {
|
||||
logger.error('Error in get-repository-groups:', error);
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(watchdog);
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 2000) {
|
||||
logger.warn(`get-repository-groups slow ms=${ms}`);
|
||||
}
|
||||
setCurrentMainOp(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
94
src/main/ipc/rendererLogs.ts
Normal file
94
src/main/ipc/rendererLogs.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { RENDERER_BOOT, RENDERER_HEARTBEAT, RENDERER_LOG } from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { type IpcMain } from 'electron';
|
||||
|
||||
const logger = createLogger('IPC:rendererLogs');
|
||||
|
||||
type RendererLogLevel = 'warn' | 'error';
|
||||
|
||||
function truncate(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
return `${text.slice(0, maxChars)}…(truncated)`;
|
||||
}
|
||||
|
||||
function isRendererLogPayload(
|
||||
payload: unknown
|
||||
): payload is { level: RendererLogLevel; message: string } {
|
||||
if (!payload || typeof payload !== 'object') return false;
|
||||
const p = payload as { level?: unknown; message?: unknown };
|
||||
return (p.level === 'warn' || p.level === 'error') && typeof p.message === 'string';
|
||||
}
|
||||
|
||||
const lastHeartbeatByWebContentsId = new Map<number, number>();
|
||||
const lastHeartbeatWarnedAtByWebContentsId = new Map<number, number>();
|
||||
const hasReceivedHeartbeatByWebContentsId = new Set<number>();
|
||||
let heartbeatMonitorStarted = false;
|
||||
|
||||
function startHeartbeatMonitor(): void {
|
||||
if (heartbeatMonitorStarted) return;
|
||||
heartbeatMonitorStarted = true;
|
||||
|
||||
const CHECK_EVERY_MS = 1500;
|
||||
const STALE_AFTER_MS = 5000;
|
||||
const WARN_THROTTLE_MS = 10_000;
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, last] of lastHeartbeatByWebContentsId.entries()) {
|
||||
if (!hasReceivedHeartbeatByWebContentsId.has(id)) {
|
||||
// Don't warn "stale" if we never saw a heartbeat — that likely indicates the
|
||||
// heartbeat channel isn't wired (or the window reloaded) rather than a stall.
|
||||
continue;
|
||||
}
|
||||
const age = now - last;
|
||||
if (age < STALE_AFTER_MS) continue;
|
||||
const lastWarnedAt = lastHeartbeatWarnedAtByWebContentsId.get(id) ?? 0;
|
||||
if (now - lastWarnedAt < WARN_THROTTLE_MS) continue;
|
||||
lastHeartbeatWarnedAtByWebContentsId.set(id, now);
|
||||
logger.warn(`Renderer heartbeat stale webContentsId=${id} ageMs=${age}`);
|
||||
}
|
||||
}, CHECK_EVERY_MS);
|
||||
}
|
||||
|
||||
export function registerRendererLogHandlers(ipcMain: IpcMain): void {
|
||||
startHeartbeatMonitor();
|
||||
|
||||
ipcMain.on(RENDERER_LOG, (_event, payload: unknown) => {
|
||||
if (!isRendererLogPayload(payload)) return;
|
||||
const msg = truncate(payload.message, 4000);
|
||||
if (payload.level === 'error') {
|
||||
logger.error(`Renderer: ${msg}`);
|
||||
} else {
|
||||
logger.warn(`Renderer: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on(RENDERER_BOOT, (event) => {
|
||||
const id = event.sender.id;
|
||||
lastHeartbeatByWebContentsId.set(id, Date.now());
|
||||
lastHeartbeatWarnedAtByWebContentsId.delete(id);
|
||||
hasReceivedHeartbeatByWebContentsId.delete(id);
|
||||
logger.warn(`Renderer boot webContentsId=${id}`);
|
||||
event.sender.once('destroyed', () => {
|
||||
lastHeartbeatByWebContentsId.delete(id);
|
||||
lastHeartbeatWarnedAtByWebContentsId.delete(id);
|
||||
hasReceivedHeartbeatByWebContentsId.delete(id);
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on(RENDERER_HEARTBEAT, (event) => {
|
||||
const id = event.sender.id;
|
||||
const isFirst = !hasReceivedHeartbeatByWebContentsId.has(id);
|
||||
hasReceivedHeartbeatByWebContentsId.add(id);
|
||||
lastHeartbeatByWebContentsId.set(id, Date.now());
|
||||
if (isFirst) {
|
||||
logger.warn(`Renderer heartbeat started webContentsId=${id}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function removeRendererLogHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeAllListeners(RENDERER_LOG);
|
||||
ipcMain.removeAllListeners(RENDERER_BOOT);
|
||||
ipcMain.removeAllListeners(RENDERER_HEARTBEAT);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import {
|
||||
TEAM_ADD_MEMBER,
|
||||
|
|
@ -319,7 +320,17 @@ async function handleGetProjectBranch(
|
|||
}
|
||||
|
||||
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
|
||||
return wrapTeamHandler('list', () => getTeamDataService().listTeams());
|
||||
setCurrentMainOp('team:list');
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await wrapTeamHandler('list', () => getTeamDataService().listTeams());
|
||||
} finally {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 1500) {
|
||||
logger.warn(`[teams:list] slow ms=${ms}`);
|
||||
}
|
||||
setCurrentMainOp(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetData(
|
||||
|
|
@ -332,7 +343,6 @@ async function handleGetData(
|
|||
}
|
||||
const tn = validated.value!;
|
||||
const startedAt = Date.now();
|
||||
logger.info(`[teams:getData] start team=${tn}`);
|
||||
let data: TeamData;
|
||||
try {
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
|
|
@ -348,12 +358,8 @@ async function handleGetData(
|
|||
return { success: false, error: message };
|
||||
}
|
||||
const getDataMs = Date.now() - startedAt;
|
||||
if (getDataMs >= 1000) {
|
||||
logger.warn(
|
||||
`[teams:getData] slow team=${tn} ms=${getDataMs} tasks=${data.tasks.length} members=${data.members.length} messages=${data.messages.length}`
|
||||
);
|
||||
} else {
|
||||
logger.info(`[teams:getData] done team=${tn} ms=${getDataMs}`);
|
||||
if (getDataMs >= 1500) {
|
||||
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`);
|
||||
}
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isAlive = provisioning.isTeamAlive(tn);
|
||||
|
|
@ -1517,7 +1523,17 @@ async function handleStartTask(
|
|||
}
|
||||
|
||||
async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<GlobalTask[]>> {
|
||||
return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
||||
setCurrentMainOp('team:getAllTasks');
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
||||
} finally {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 1500) {
|
||||
logger.warn(`[teams:getAllTasks] slow ms=${ms}`);
|
||||
}
|
||||
setCurrentMainOp(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddMember(
|
||||
|
|
|
|||
|
|
@ -72,7 +72,11 @@ export class ProjectPathResolver {
|
|||
|
||||
// In SSH mode, avoid scanning every remote session file just to resolve display path.
|
||||
// One successful cwd extraction is sufficient.
|
||||
const maxPathsToInspect = this.fsProvider.type === 'ssh' ? 1 : sessionPaths.length;
|
||||
const MAX_LOCAL_PATHS_TO_INSPECT = 5;
|
||||
const maxPathsToInspect =
|
||||
this.fsProvider.type === 'ssh'
|
||||
? 1
|
||||
: Math.min(sessionPaths.length, MAX_LOCAL_PATHS_TO_INSPECT);
|
||||
for (const sessionPath of sessionPaths.slice(0, maxPathsToInspect)) {
|
||||
try {
|
||||
const cwd = await extractCwd(sessionPath, this.fsProvider);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemP
|
|||
|
||||
const logger = createLogger('Discovery:ProjectScanner');
|
||||
|
||||
// IPC payload safety: session ID arrays can be extremely large for long-lived projects.
|
||||
// Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive.
|
||||
// We no longer need session IDs in project/repository listings (session lists are fetched separately).
|
||||
// Keeping this at 0 avoids huge IPC payloads that can stall the renderer thread.
|
||||
const MAX_SESSION_IDS_EXPORTED = 0;
|
||||
|
||||
export class ProjectScanner {
|
||||
private readonly projectsDir: string;
|
||||
private readonly todosDir: string;
|
||||
|
|
@ -84,8 +90,8 @@ export class ProjectScanner {
|
|||
private static readonly SCAN_CACHE_TTL_MS = 2000;
|
||||
|
||||
// Platform-aware batch sizes to avoid UV thread pool saturation on Windows
|
||||
private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 32 : 128;
|
||||
private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 8 : 24;
|
||||
private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 16 : 64;
|
||||
private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 4 : 12;
|
||||
|
||||
// Delegated services
|
||||
private readonly fsProvider: FileSystemProvider;
|
||||
|
|
@ -127,7 +133,15 @@ export class ProjectScanner {
|
|||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
let stage = 'start';
|
||||
const slowWarnAfterMs = 10_000;
|
||||
const slowWarnTimer = setTimeout(() => {
|
||||
logger.warn(
|
||||
`[scan] still running after ${slowWarnAfterMs}ms stage=${stage} projectsDir=${this.projectsDir}`
|
||||
);
|
||||
}, slowWarnAfterMs);
|
||||
try {
|
||||
stage = 'exists';
|
||||
if (!(await this.fsProvider.exists(this.projectsDir))) {
|
||||
logger.warn(`Projects directory does not exist: ${this.projectsDir}`);
|
||||
return [];
|
||||
|
|
@ -136,7 +150,13 @@ export class ProjectScanner {
|
|||
// Clear the subproject registry on full re-scan
|
||||
subprojectRegistry.clear();
|
||||
|
||||
stage = 'readdirProjectsDir';
|
||||
const readdirStartedAt = Date.now();
|
||||
const entries = await this.fsProvider.readdir(this.projectsDir);
|
||||
const readdirMs = Date.now() - readdirStartedAt;
|
||||
if (readdirMs >= 2000) {
|
||||
logger.warn(`[scan] readdir slow ms=${readdirMs} entries=${entries.length}`);
|
||||
}
|
||||
|
||||
// Filter to only directories with valid encoding pattern
|
||||
const projectDirs = entries.filter(
|
||||
|
|
@ -144,6 +164,7 @@ export class ProjectScanner {
|
|||
);
|
||||
|
||||
// Process each project directory (may return multiple projects per dir)
|
||||
stage = 'scanProjects';
|
||||
const projectArrays = await this.collectFulfilledInBatches(
|
||||
projectDirs,
|
||||
this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH,
|
||||
|
|
@ -160,11 +181,19 @@ export class ProjectScanner {
|
|||
);
|
||||
}
|
||||
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 2000) {
|
||||
logger.warn(
|
||||
`[scan] completed slow ms=${ms} projectDirs=${projectDirs.length} projects=${validProjects.length}`
|
||||
);
|
||||
}
|
||||
this.scanCache = { projects: validProjects, timestamp: Date.now() };
|
||||
return validProjects;
|
||||
} catch (error) {
|
||||
logger.error('Error scanning projects directory:', error);
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(slowWarnTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,25 +228,29 @@ export class ProjectScanner {
|
|||
// 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
|
||||
// Git identity resolution is bypassed to avoid blocking I/O on startup.
|
||||
// Each project becomes a single-worktree group.
|
||||
const groups: RepositoryGroup[] = projects.map((project) => ({
|
||||
id: project.id,
|
||||
identity: null,
|
||||
worktrees: [
|
||||
{
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
name: project.name,
|
||||
isMainWorktree: true,
|
||||
source: 'unknown' as const,
|
||||
sessions: project.sessions,
|
||||
createdAt: project.createdAt,
|
||||
mostRecentSession: project.mostRecentSession,
|
||||
},
|
||||
],
|
||||
name: project.name,
|
||||
mostRecentSession: project.mostRecentSession,
|
||||
totalSessions: project.sessions.length,
|
||||
}));
|
||||
const groups: RepositoryGroup[] = projects.map((project) => {
|
||||
const totalSessions = project.totalSessions ?? project.sessions.length;
|
||||
return {
|
||||
id: project.id,
|
||||
identity: null,
|
||||
worktrees: [
|
||||
{
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
name: project.name,
|
||||
isMainWorktree: true,
|
||||
source: 'unknown' as const,
|
||||
sessions: project.sessions,
|
||||
totalSessions,
|
||||
createdAt: project.createdAt,
|
||||
mostRecentSession: project.mostRecentSession,
|
||||
},
|
||||
],
|
||||
name: project.name,
|
||||
mostRecentSession: project.mostRecentSession,
|
||||
totalSessions,
|
||||
};
|
||||
});
|
||||
|
||||
// 3. Merge custom project paths from config (persisted "Select Folder" picks)
|
||||
const { configManager } = await import('../infrastructure/ConfigManager');
|
||||
|
|
@ -244,6 +277,7 @@ export class ProjectScanner {
|
|||
isMainWorktree: true,
|
||||
source: 'unknown' as const,
|
||||
sessions: [],
|
||||
totalSessions: 0,
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
|
|
@ -305,7 +339,13 @@ export class ProjectScanner {
|
|||
cwd: string | null;
|
||||
}
|
||||
|
||||
const shouldSplitByCwd = this.fsProvider.type !== 'ssh';
|
||||
// Reading JSONL heads for cwd across hundreds/thousands of sessions can saturate I/O and
|
||||
// make the renderer appear frozen while waiting for repository groups.
|
||||
// Prefer correctness for small projects; for large ones, skip cwd splitting and fall back
|
||||
// to encoded-path decoding / limited path probing.
|
||||
const MAX_CWD_SPLIT_FILES = 80;
|
||||
const shouldSplitByCwd =
|
||||
this.fsProvider.type !== 'ssh' && sessionFiles.length <= MAX_CWD_SPLIT_FILES;
|
||||
const sessionInfos = await this.collectFulfilledInBatches(
|
||||
sessionFiles,
|
||||
this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH,
|
||||
|
|
@ -356,6 +396,7 @@ export class ProjectScanner {
|
|||
const realCwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__'));
|
||||
if (realCwdKeys.length <= 1) {
|
||||
const allSessionIds = sessionInfos.map((s) => s.sessionId);
|
||||
const exportedSessionIds = allSessionIds.slice(0, MAX_SESSION_IDS_EXPORTED);
|
||||
let mostRecentSession: number | undefined;
|
||||
let createdAt = Date.now();
|
||||
for (const info of sessionInfos) {
|
||||
|
|
@ -369,6 +410,7 @@ export class ProjectScanner {
|
|||
|
||||
const sessionPaths = sessionInfos.map((s) => s.filePath);
|
||||
const actualPath = await this.projectPathResolver.resolveProjectPath(encodedName, {
|
||||
cwdHint: firstCwd ?? undefined,
|
||||
sessionPaths,
|
||||
});
|
||||
|
||||
|
|
@ -381,7 +423,8 @@ export class ProjectScanner {
|
|||
id: encodedName,
|
||||
path: actualPath,
|
||||
name: resolvedName,
|
||||
sessions: allSessionIds,
|
||||
sessions: exportedSessionIds,
|
||||
totalSessions: allSessionIds.length,
|
||||
createdAt: Math.floor(createdAt),
|
||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||
},
|
||||
|
|
@ -411,6 +454,7 @@ export class ProjectScanner {
|
|||
actualCwd ?? decodedFallback,
|
||||
sessionIds
|
||||
);
|
||||
const exportedSessionIds = sessionIds.slice(0, MAX_SESSION_IDS_EXPORTED);
|
||||
|
||||
// Compute timestamps
|
||||
let mostRecentSession: number | undefined;
|
||||
|
|
@ -438,7 +482,8 @@ export class ProjectScanner {
|
|||
id: compositeId,
|
||||
path: actualCwd ?? decodedFallback,
|
||||
name: displayName,
|
||||
sessions: sessionIds,
|
||||
sessions: exportedSessionIds,
|
||||
totalSessions: sessionIds.length,
|
||||
createdAt: Math.floor(createdAt),
|
||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||
});
|
||||
|
|
@ -504,8 +549,10 @@ export class ProjectScanner {
|
|||
const sessionPaths = sessionFiles.map((file) => path.join(projectPath, file.name));
|
||||
const decodedPath = await this.resolveProjectPathForId(projectId, sessionPaths);
|
||||
|
||||
const sessions = await Promise.all(
|
||||
sessionFiles.map(async (file) => {
|
||||
const sessions = await this.collectFulfilledInBatches(
|
||||
sessionFiles,
|
||||
this.fsProvider.type === 'ssh' ? 8 : 16,
|
||||
async (file) => {
|
||||
const sessionId = extractSessionId(file.name);
|
||||
const filePath = path.join(projectPath, file.name);
|
||||
const fileDetails = await this.resolveFileDetails(file, filePath);
|
||||
|
|
@ -535,7 +582,7 @@ export class ProjectScanner {
|
|||
prefetchedSize,
|
||||
prefetchedBirthtimeMs
|
||||
);
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
// Filter out null results (noise-only sessions)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ const logger = createLogger('Service:SessionContentFilter');
|
|||
|
||||
const defaultProvider = new LocalFileSystemProvider();
|
||||
|
||||
const SESSION_SCAN_TIMEOUT_MS = 2500;
|
||||
const SESSION_SCAN_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const SESSION_SCAN_MAX_LINES = 2000;
|
||||
|
||||
function byteLen(chunk: string): number {
|
||||
return Buffer.byteLength(chunk, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard noise tags - user messages with ONLY these tags are filtered out.
|
||||
*/
|
||||
|
|
@ -66,14 +74,39 @@ export class SessionContentFilter {
|
|||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fsProvider.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
||||
let bytes = 0;
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
fileStream.destroy();
|
||||
}, SESSION_SCAN_TIMEOUT_MS);
|
||||
fileStream.on('data', (chunk: string) => {
|
||||
bytes += byteLen(chunk);
|
||||
if (bytes > SESSION_SCAN_MAX_BYTES) {
|
||||
fileStream.destroy();
|
||||
}
|
||||
});
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
try {
|
||||
let lines = 0;
|
||||
for await (const line of rl) {
|
||||
if (++lines > SESSION_SCAN_MAX_LINES) {
|
||||
break;
|
||||
}
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
|
|
@ -87,6 +120,7 @@ export class SessionContentFilter {
|
|||
// Check if this entry would create a displayable chunk
|
||||
// This aligns with ChunkBuilder.categorizeMessage() logic
|
||||
if (SessionContentFilter.isDisplayableEntry(entry)) {
|
||||
rl.close();
|
||||
fileStream.destroy();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -96,10 +130,21 @@ export class SessionContentFilter {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error checking displayable messages in ${filePath}:`, error);
|
||||
if (!timedOut) {
|
||||
logger.debug(`Error checking displayable messages in ${filePath}:`, error);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
rl.close();
|
||||
fileStream.destroy();
|
||||
}
|
||||
|
||||
// If we hit limits/timeouts, be conservative: treat as having content so we
|
||||
// don't accidentally hide sessions due to partial reads.
|
||||
if (timedOut || bytes > SESSION_SCAN_MAX_BYTES) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No displayable messages found
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
37
src/main/services/infrastructure/EventLoopLagMonitor.ts
Normal file
37
src/main/services/infrastructure/EventLoopLagMonitor.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
const logger = createLogger('Perf:EventLoop');
|
||||
|
||||
let started = false;
|
||||
let currentOp: string | null = null;
|
||||
|
||||
export function setCurrentMainOp(op: string | null): void {
|
||||
currentOp = op;
|
||||
}
|
||||
|
||||
export function startEventLoopLagMonitor(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
const h = monitorEventLoopDelay({ resolution: 20 });
|
||||
h.enable();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const maxMs = Number(h.max) / 1e6;
|
||||
const p95Ms = Number(h.percentile(95)) / 1e6;
|
||||
// Reset first so next window is clean even if logging throws
|
||||
h.reset();
|
||||
|
||||
// Only report meaningful stalls
|
||||
if (maxMs < 250) return;
|
||||
|
||||
logger.warn(
|
||||
`Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` +
|
||||
(currentOp ? ` op=${currentOp}` : '')
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
interval.unref();
|
||||
}
|
||||
|
|
@ -14,6 +14,32 @@ import type {
|
|||
ReadStreamOptions,
|
||||
} from './FileSystemProvider';
|
||||
|
||||
const STAT_CONCURRENCY = process.platform === 'win32' ? 32 : 128;
|
||||
const STAT_TIMEOUT_MS = 2000;
|
||||
// If a directory is huge, pre-statting every entry can take seconds+ and
|
||||
// saturate the thread pool. In those cases, prefer returning bare dirents and
|
||||
// let callers stat only the files they actually need.
|
||||
const STAT_PREFETCH_LIMIT = 1500;
|
||||
|
||||
async function mapLimit<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let index = 0;
|
||||
const workerCount = Math.max(1, Math.min(limit, items.length));
|
||||
const workers = new Array(workerCount).fill(0).map(async () => {
|
||||
while (true) {
|
||||
const i = index++;
|
||||
if (i >= items.length) return;
|
||||
results[i] = await fn(items[i]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
export class LocalFileSystemProvider implements FileSystemProvider {
|
||||
readonly type = 'local' as const;
|
||||
|
||||
|
|
@ -31,7 +57,12 @@ export class LocalFileSystemProvider implements FileSystemProvider {
|
|||
}
|
||||
|
||||
async stat(filePath: string): Promise<FsStatResult> {
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
const stats = await Promise.race([
|
||||
fs.promises.stat(filePath),
|
||||
new Promise<fs.Stats>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error('stat timeout')), STAT_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
return {
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
|
|
@ -43,32 +74,44 @@ export class LocalFileSystemProvider implements FileSystemProvider {
|
|||
|
||||
async readdir(dirPath: string): Promise<FsDirent[]> {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
// Stat all entries concurrently to populate mtimeMs/birthtimeMs/size.
|
||||
// Populating all three avoids a second stat() call in resolveFileDetails().
|
||||
// Failures are silently ignored (fields stay undefined).
|
||||
return Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
let mtimeMs: number | undefined;
|
||||
let birthtimeMs: number | undefined;
|
||||
let size: number | undefined;
|
||||
try {
|
||||
const stat = await fs.promises.stat(`${dirPath}/${entry.name}`);
|
||||
mtimeMs = stat.mtimeMs;
|
||||
birthtimeMs = stat.birthtimeMs;
|
||||
size = stat.size;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
name: entry.name,
|
||||
mtimeMs,
|
||||
birthtimeMs,
|
||||
size,
|
||||
isFile: () => entry.isFile(),
|
||||
isDirectory: () => entry.isDirectory(),
|
||||
};
|
||||
})
|
||||
);
|
||||
if (entries.length > STAT_PREFETCH_LIMIT) {
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isFile: () => entry.isFile(),
|
||||
isDirectory: () => entry.isDirectory(),
|
||||
}));
|
||||
}
|
||||
// Stat entries with bounded concurrency.
|
||||
// Unbounded Promise.all(stat(...)) can saturate the UV thread pool (even with
|
||||
// increased UV_THREADPOOL_SIZE) when directories contain thousands of files,
|
||||
// causing unrelated operations (teams/tasks/CLI checks) to time out.
|
||||
return mapLimit(entries, STAT_CONCURRENCY, async (entry) => {
|
||||
let mtimeMs: number | undefined;
|
||||
let birthtimeMs: number | undefined;
|
||||
let size: number | undefined;
|
||||
try {
|
||||
const fullPath = `${dirPath}/${entry.name}`;
|
||||
const stat = await Promise.race([
|
||||
fs.promises.stat(fullPath),
|
||||
new Promise<fs.Stats>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error('stat timeout')), STAT_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
mtimeMs = stat.mtimeMs;
|
||||
birthtimeMs = stat.birthtimeMs;
|
||||
size = stat.size;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
name: entry.name,
|
||||
mtimeMs,
|
||||
birthtimeMs,
|
||||
size,
|
||||
isFile: () => entry.isFile(),
|
||||
isDirectory: () => entry.isDirectory(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
createReadStream(filePath: string, opts?: ReadStreamOptions): fs.ReadStream {
|
||||
|
|
|
|||
|
|
@ -374,7 +374,9 @@ export class FileContentResolver {
|
|||
currentContent: string | null,
|
||||
snippets: SnippetDiff[]
|
||||
): string | null {
|
||||
if (!currentContent) return null;
|
||||
// `readFile()` can legitimately return an empty string for empty files.
|
||||
// Only treat `null` as "missing on disk".
|
||||
if (currentContent === null) return null;
|
||||
if (snippets.length === 0) return null;
|
||||
|
||||
// Filter out errored snippets
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
|
||||
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
||||
|
|
@ -79,6 +80,43 @@ export class TeamConfigReader {
|
|||
) {}
|
||||
|
||||
async listTeams(): Promise<TeamSummary[]> {
|
||||
const worker = getTeamFsWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const { teams, diag } = await worker.listTeams({
|
||||
largeConfigBytes: LARGE_CONFIG_BYTES,
|
||||
configHeadBytes: CONFIG_HEAD_BYTES,
|
||||
maxConfigBytes: MAX_CONFIG_READ_BYTES,
|
||||
maxMembersMetaBytes: 256 * 1024,
|
||||
maxSessionHistoryInSummary: MAX_SESSION_HISTORY_IN_SUMMARY,
|
||||
maxProjectPathHistoryInSummary: MAX_PROJECT_PATH_HISTORY_IN_SUMMARY,
|
||||
concurrency: TEAM_LIST_CONCURRENCY,
|
||||
maxConfigReadMs: PER_TEAM_READ_TIMEOUT_MS,
|
||||
});
|
||||
const ms = Date.now() - startedAt;
|
||||
const skipReasons =
|
||||
diag && typeof diag === 'object' ? (diag as Record<string, unknown>).skipReasons : null;
|
||||
if (skipReasons && typeof skipReasons === 'object') {
|
||||
const bad =
|
||||
Number((skipReasons as Record<string, unknown>).config_parse_failed ?? 0) +
|
||||
Number((skipReasons as Record<string, unknown>).config_read_timeout ?? 0);
|
||||
if (bad > 0) {
|
||||
logger.warn(`[listTeams] worker skipped broken team configs count=${bad}`);
|
||||
}
|
||||
}
|
||||
if (ms >= 1500) {
|
||||
logger.warn(`[listTeams] worker slow ms=${ms} diag=${JSON.stringify(diag)}`);
|
||||
}
|
||||
return teams;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[listTeams] worker failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
// Fall through to in-process implementation.
|
||||
}
|
||||
}
|
||||
|
||||
const teamsDir = getTeamsBasePath();
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import {
|
||||
encodePath,
|
||||
|
|
@ -59,6 +60,7 @@ const MIN_TEXT_LENGTH = 30;
|
|||
const MAX_LEAD_TEXTS = 50;
|
||||
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
||||
const MAX_PROCESSES_FILE_BYTES = 2 * 1024 * 1024;
|
||||
const TASK_MAP_YIELD_EVERY = 250;
|
||||
|
||||
export class TeamDataService {
|
||||
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
|
@ -82,10 +84,8 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async getAllTasks(): Promise<GlobalTask[]> {
|
||||
const [rawTasks, teams] = await Promise.all([
|
||||
this.taskReader.getAllTasks(),
|
||||
this.configReader.listTeams(),
|
||||
]);
|
||||
const rawTasks = await this.taskReader.getAllTasks();
|
||||
const teams = await this.configReader.listTeams();
|
||||
|
||||
const teamInfoMap = new Map<
|
||||
string,
|
||||
|
|
@ -116,24 +116,62 @@ export class TeamDataService {
|
|||
})
|
||||
);
|
||||
|
||||
return rawTasks
|
||||
.filter((task) => teamInfoMap.has(task.teamName))
|
||||
.map((task) => {
|
||||
const info = teamInfoMap.get(task.teamName)!;
|
||||
const kanban = kanbanByTeam.get(task.teamName);
|
||||
const kanbanEntry = kanban?.tasks[task.id];
|
||||
const kanbanColumn =
|
||||
kanbanEntry?.column === 'review' || kanbanEntry?.column === 'approved'
|
||||
? kanbanEntry.column
|
||||
: undefined;
|
||||
return {
|
||||
...task,
|
||||
teamDisplayName: info.displayName,
|
||||
projectPath: task.projectPath ?? info.projectPath,
|
||||
kanbanColumn,
|
||||
teamDeleted: deletedTeams.has(task.teamName) || undefined,
|
||||
};
|
||||
const out: GlobalTask[] = [];
|
||||
let processed = 0;
|
||||
for (const task of rawTasks) {
|
||||
if (!teamInfoMap.has(task.teamName)) {
|
||||
continue;
|
||||
}
|
||||
const info = teamInfoMap.get(task.teamName)!;
|
||||
const kanban = kanbanByTeam.get(task.teamName);
|
||||
const kanbanEntry = kanban?.tasks[task.id];
|
||||
const kanbanColumn =
|
||||
kanbanEntry?.column === 'review' || kanbanEntry?.column === 'approved'
|
||||
? kanbanEntry.column
|
||||
: undefined;
|
||||
|
||||
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
||||
// Return a "light" task object and defer heavy details to team/task detail views.
|
||||
const projectPath = task.projectPath ?? info.projectPath;
|
||||
const subject =
|
||||
typeof task.subject === 'string'
|
||||
? task.subject.slice(0, 300)
|
||||
: String(task.subject).slice(0, 300);
|
||||
out.push({
|
||||
id: task.id,
|
||||
subject,
|
||||
owner: task.owner,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
projectPath,
|
||||
needsClarification: task.needsClarification,
|
||||
deletedAt: task.deletedAt,
|
||||
// Intentionally omit description/comments/activeForm/workIntervals/links to keep payload small
|
||||
kanbanColumn,
|
||||
teamName: task.teamName,
|
||||
teamDisplayName: info.displayName,
|
||||
teamDeleted: deletedTeams.has(task.teamName) || undefined,
|
||||
});
|
||||
processed++;
|
||||
if (processed % TASK_MAP_YIELD_EVERY === 0) {
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap: keep renderer responsive even with huge task sets.
|
||||
const MAX_GLOBAL_TASKS_EXPORTED = 500;
|
||||
if (out.length > MAX_GLOBAL_TASKS_EXPORTED) {
|
||||
// Prefer newest first if timestamps exist.
|
||||
out.sort((a, b) => {
|
||||
const at = Date.parse(a.updatedAt ?? a.createdAt ?? '') || 0;
|
||||
const bt = Date.parse(b.updatedAt ?? b.createdAt ?? '') || 0;
|
||||
return bt - at;
|
||||
});
|
||||
return out.slice(0, MAX_GLOBAL_TASKS_EXPORTED);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async updateConfig(
|
||||
|
|
@ -320,7 +358,7 @@ export class TeamDataService {
|
|||
const totalMs = Date.now() - startedAt;
|
||||
if (totalMs >= 1500) {
|
||||
logger.warn(
|
||||
`[getTeamData] slow team=${teamName} total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
||||
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
||||
'inboxNames'
|
||||
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
||||
'sentMessages'
|
||||
|
|
|
|||
231
src/main/services/team/TeamFsWorkerClient.ts
Normal file
231
src/main/services/team/TeamFsWorkerClient.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
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()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
const msg: WorkerRequest =
|
||||
op === 'listTeams'
|
||||
? ({ id, op, payload } as WorkerRequest)
|
||||
: ({ id, op, payload } as WorkerRequest);
|
||||
worker.postMessage(msg);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||
|
||||
import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamTaskReader');
|
||||
|
|
@ -53,6 +56,7 @@ export class TeamTaskReader {
|
|||
}
|
||||
|
||||
const tasks: TeamTask[] = [];
|
||||
let processed = 0;
|
||||
for (const file of entries) {
|
||||
if (
|
||||
!file.endsWith('.json') ||
|
||||
|
|
@ -172,6 +176,10 @@ export class TeamTaskReader {
|
|||
} catch {
|
||||
logger.debug(`Skipping invalid task file: ${taskPath}`);
|
||||
}
|
||||
processed++;
|
||||
if (processed % 50 === 0) {
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
|
|
@ -191,6 +199,7 @@ export class TeamTaskReader {
|
|||
}
|
||||
|
||||
const tasks: TeamTask[] = [];
|
||||
let processed = 0;
|
||||
for (const file of entries) {
|
||||
if (
|
||||
!file.endsWith('.json') ||
|
||||
|
|
@ -241,12 +250,46 @@ export class TeamTaskReader {
|
|||
} catch {
|
||||
logger.debug(`Skipping invalid task file: ${taskPath}`);
|
||||
}
|
||||
processed++;
|
||||
if (processed % 50 === 0) {
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> {
|
||||
const worker = getTeamFsWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const { tasks, diag } = await worker.getAllTasks({
|
||||
maxTaskBytes: MAX_TASK_FILE_BYTES,
|
||||
});
|
||||
const ms = Date.now() - startedAt;
|
||||
const skipReasons =
|
||||
diag && typeof diag === 'object' ? (diag as Record<string, unknown>).skipReasons : null;
|
||||
if (skipReasons && typeof skipReasons === 'object') {
|
||||
const bad =
|
||||
Number((skipReasons as Record<string, unknown>).task_parse_failed ?? 0) +
|
||||
Number((skipReasons as Record<string, unknown>).task_read_timeout ?? 0);
|
||||
if (bad > 0) {
|
||||
logger.warn(`[getAllTasks] worker skipped broken task files count=${bad}`);
|
||||
}
|
||||
}
|
||||
if (ms >= 2000) {
|
||||
logger.warn(`[getAllTasks] worker slow ms=${ms} diag=${JSON.stringify(diag)}`);
|
||||
}
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[getAllTasks] worker failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
// fall back
|
||||
}
|
||||
}
|
||||
|
||||
const tasksBase = getTasksBasePath();
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
|
|
@ -260,6 +303,7 @@ export class TeamTaskReader {
|
|||
}
|
||||
|
||||
const result: (TeamTask & { teamName: string })[] = [];
|
||||
let dirCount = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
try {
|
||||
|
|
@ -270,6 +314,11 @@ export class TeamTaskReader {
|
|||
} catch {
|
||||
logger.debug(`Skipping tasks dir: ${entry.name}`);
|
||||
}
|
||||
dirCount++;
|
||||
if (dirCount % 2 === 0) {
|
||||
// Yield periodically to keep the main process responsive in worst-case directories.
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -51,8 +51,13 @@ export interface Project {
|
|||
path: string;
|
||||
/** Display name (last path segment) */
|
||||
name: string;
|
||||
/** List of session IDs (JSONL filenames without extension) */
|
||||
/**
|
||||
* List of session IDs (JSONL filenames without extension).
|
||||
* Note: this list may be truncated for performance; use totalSessions for counts.
|
||||
*/
|
||||
sessions: string[];
|
||||
/** Total session count (may exceed sessions.length if sessions list is truncated) */
|
||||
totalSessions?: number;
|
||||
/** Unix timestamp when project directory was created */
|
||||
createdAt: number;
|
||||
/** Unix timestamp of most recent session activity */
|
||||
|
|
@ -184,8 +189,13 @@ export interface Worktree {
|
|||
isMainWorktree: boolean;
|
||||
/** Worktree source for badge display (vibe-kanban, conductor, etc.) */
|
||||
source: WorktreeSource;
|
||||
/** List of session IDs */
|
||||
/**
|
||||
* List of session IDs.
|
||||
* Note: this list may be truncated for performance; use totalSessions for counts.
|
||||
*/
|
||||
sessions: string[];
|
||||
/** Total session count (may exceed sessions.length if sessions list is truncated) */
|
||||
totalSessions?: number;
|
||||
/** Unix timestamp when first session was created */
|
||||
createdAt: number;
|
||||
/** Unix timestamp of most recent session activity */
|
||||
|
|
|
|||
3
src/main/utils/asyncYield.ts
Normal file
3
src/main/utils/asyncYield.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function yieldToEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ import {
|
|||
type ToolCall,
|
||||
} from '../types';
|
||||
|
||||
import { yieldToEventLoop } from './asyncYield';
|
||||
import { extractFirstUserMessagePreview } from './metadataExtraction';
|
||||
// Import from extracted modules
|
||||
import { extractToolCalls, extractToolResults } from './toolExtraction';
|
||||
|
||||
|
|
@ -65,6 +67,7 @@ export async function parseJsonlFile(
|
|||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let lineCount = 0;
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
|
|
@ -76,6 +79,11 @@ export async function parseJsonlFile(
|
|||
} catch (error) {
|
||||
logger.error(`Error parsing line in ${filePath}:`, error);
|
||||
}
|
||||
|
||||
lineCount++;
|
||||
if (lineCount % 250 === 0) {
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
|
|
@ -344,6 +352,41 @@ export async function analyzeSessionFileMetadata(
|
|||
};
|
||||
}
|
||||
|
||||
const MAX_DEEP_SCAN_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
try {
|
||||
const stat = await fsProvider.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return {
|
||||
firstUserMessage: null,
|
||||
messageCount: 0,
|
||||
isOngoing: false,
|
||||
gitBranch: null,
|
||||
};
|
||||
}
|
||||
if (stat.size > MAX_DEEP_SCAN_BYTES) {
|
||||
// Too large for deep scan — avoid blocking main/renderer.
|
||||
// Prefer a best-effort preview from the head (already size/time bounded).
|
||||
try {
|
||||
const preview = await extractFirstUserMessagePreview(filePath, fsProvider);
|
||||
return {
|
||||
firstUserMessage: preview,
|
||||
messageCount: 0,
|
||||
isOngoing: false,
|
||||
gitBranch: null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
firstUserMessage: null,
|
||||
messageCount: 0,
|
||||
isOngoing: false,
|
||||
gitBranch: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best effort — proceed to scan
|
||||
}
|
||||
|
||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
|
|
@ -371,11 +414,16 @@ export async function analyzeSessionFileMetadata(
|
|||
|
||||
let awaitingPostCompaction = false;
|
||||
|
||||
let lineCount = 0;
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
lineCount++;
|
||||
if (lineCount % 250 === 0) {
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
|
||||
let entry: ChatHistoryEntry;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -15,12 +15,20 @@ const logger = createLogger('Util:metadataExtraction');
|
|||
|
||||
const defaultProvider = new LocalFileSystemProvider();
|
||||
|
||||
const JSONL_HEAD_TIMEOUT_MS = 2000;
|
||||
const JSONL_HEAD_MAX_BYTES = 256 * 1024;
|
||||
const JSONL_HEAD_MAX_LINES = 400;
|
||||
|
||||
interface MessagePreview {
|
||||
text: string;
|
||||
timestamp: string;
|
||||
isCommand: boolean;
|
||||
}
|
||||
|
||||
function byteLen(chunk: string): number {
|
||||
return Buffer.byteLength(chunk, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CWD (current working directory) from the first entry.
|
||||
* Used to get the actual project path from encoded directory names.
|
||||
|
|
@ -33,17 +41,47 @@ export async function extractCwd(
|
|||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fsProvider.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
||||
let bytes = 0;
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
fileStream.destroy();
|
||||
}, JSONL_HEAD_TIMEOUT_MS);
|
||||
fileStream.on('data', (chunk: string) => {
|
||||
bytes += byteLen(chunk);
|
||||
if (bytes > JSONL_HEAD_MAX_BYTES) {
|
||||
fileStream.destroy();
|
||||
}
|
||||
});
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
try {
|
||||
let lines = 0;
|
||||
for await (const line of rl) {
|
||||
if (++lines > JSONL_HEAD_MAX_LINES) {
|
||||
break;
|
||||
}
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const entry = JSON.parse(line) as ChatHistoryEntry;
|
||||
let entry: ChatHistoryEntry;
|
||||
try {
|
||||
entry = JSON.parse(line) as ChatHistoryEntry;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Only conversational entries have cwd
|
||||
if ('cwd' in entry && entry.cwd) {
|
||||
rl.close();
|
||||
|
|
@ -52,8 +90,11 @@ export async function extractCwd(
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error extracting cwd from ${filePath}:`, error);
|
||||
if (!timedOut) {
|
||||
logger.debug(`Error extracting cwd from ${filePath}:`, error);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
rl.close();
|
||||
fileStream.destroy();
|
||||
}
|
||||
|
|
@ -71,7 +112,28 @@ export async function extractFirstUserMessagePreview(
|
|||
maxLines: number = 200
|
||||
): Promise<{ text: string; timestamp: string } | null> {
|
||||
const safeMaxLines = Math.max(1, maxLines);
|
||||
try {
|
||||
const stat = await fsProvider.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
||||
let bytes = 0;
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
fileStream.destroy();
|
||||
}, JSONL_HEAD_TIMEOUT_MS);
|
||||
fileStream.on('data', (chunk: string) => {
|
||||
bytes += byteLen(chunk);
|
||||
if (bytes > JSONL_HEAD_MAX_BYTES) {
|
||||
fileStream.destroy();
|
||||
}
|
||||
});
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
|
|
@ -114,9 +176,12 @@ export async function extractFirstUserMessagePreview(
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Error extracting first user preview from ${filePath}:`, error);
|
||||
throw error;
|
||||
if (!timedOut) {
|
||||
logger.debug(`Error extracting first user preview from ${filePath}:`, error);
|
||||
}
|
||||
return commandFallback;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
rl.close();
|
||||
fileStream.destroy();
|
||||
}
|
||||
|
|
@ -192,5 +257,5 @@ function extractPreviewFromUserEntry(entry: UserEntry): MessagePreview | null {
|
|||
|
||||
function extractCommandName(content: string): string {
|
||||
const commandMatch = /<command-name>\/([^<]+)<\/command-name>/.exec(content);
|
||||
return commandMatch ? `/${commandMatch[1]}` : '/command';
|
||||
return commandMatch?.[1] ? `/${commandMatch[1]}` : '/command';
|
||||
}
|
||||
|
|
|
|||
514
src/main/workers/team-fs-worker.ts
Normal file
514
src/main/workers/team-fs-worker.ts
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
|
||||
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?: unknown }
|
||||
| { id: string; ok: false; error: string };
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
'name' in error &&
|
||||
(error as { name?: unknown }).name === 'AbortError'
|
||||
);
|
||||
}
|
||||
|
||||
async function readFileUtf8WithTimeout(filePath: string, timeoutMs: number): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fs.promises.readFile(filePath, { encoding: 'utf8', signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
const err = new Error('READ_TIMEOUT');
|
||||
(err as NodeJS.ErrnoException).code = 'READ_TIMEOUT';
|
||||
throw err;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileHeadUtf8(filePath: string, maxBytes: number): Promise<string> {
|
||||
const handle = await fs.promises.open(filePath, 'r');
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
const bytesToRead = Math.max(0, Math.min(stat.size, maxBytes));
|
||||
if (bytesToRead === 0) return '';
|
||||
const buffer = Buffer.alloc(bytesToRead);
|
||||
await handle.read(buffer, 0, bytesToRead, 0);
|
||||
return buffer.toString('utf8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function extractQuotedString(head: string, key: string): string | null {
|
||||
const re = new RegExp(`"${key}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`);
|
||||
const match = head.match(re);
|
||||
if (!match?.[1]) return null;
|
||||
try {
|
||||
const value = JSON.parse(match[1]) as unknown;
|
||||
return typeof value === 'string' ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function mapLimit<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let index = 0;
|
||||
const workerCount = Math.max(1, Math.min(limit, items.length));
|
||||
const workers = new Array(workerCount).fill(0).map(async () => {
|
||||
while (true) {
|
||||
const i = index++;
|
||||
if (i >= items.length) return;
|
||||
results[i] = await fn(items[i]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; diag: unknown }> {
|
||||
const startedAt = nowMs();
|
||||
const diag: any = {
|
||||
op: 'listTeams',
|
||||
startedAt,
|
||||
teamsDir: payload.teamsDir,
|
||||
totalDirs: 0,
|
||||
returned: 0,
|
||||
skipped: 0,
|
||||
skipReasons: {},
|
||||
slowest: [],
|
||||
totalMs: 0,
|
||||
};
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(payload.teamsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
diag.totalMs = nowMs() - startedAt;
|
||||
return { teams: [], diag };
|
||||
}
|
||||
|
||||
const teamDirs = entries.filter((e) => e.isDirectory());
|
||||
diag.totalDirs = teamDirs.length;
|
||||
|
||||
const perTeam = await mapLimit(teamDirs, payload.concurrency, async (entry) => {
|
||||
const teamName = entry.name;
|
||||
const t0 = nowMs();
|
||||
const configPath = path.join(payload.teamsDir, teamName, 'config.json');
|
||||
|
||||
const skip = (reason: string): null => {
|
||||
diag.skipped++;
|
||||
diag.skipReasons[reason] = (diag.skipReasons[reason] || 0) + 1;
|
||||
return null;
|
||||
};
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = await fs.promises.stat(configPath);
|
||||
} catch {
|
||||
return skip('config_stat_failed');
|
||||
}
|
||||
if (!stat.isFile()) return skip('config_not_file');
|
||||
if (stat.size > payload.maxConfigBytes) return skip('config_too_large');
|
||||
|
||||
let config: any = null;
|
||||
let displayName: string | null = null;
|
||||
let description = '';
|
||||
let color: string | undefined;
|
||||
let projectPath: string | undefined;
|
||||
let leadSessionId: string | undefined;
|
||||
let deletedAt: string | undefined;
|
||||
let projectPathHistory: string[] | undefined;
|
||||
let sessionHistory: string[] | undefined;
|
||||
|
||||
try {
|
||||
if (stat.size > payload.largeConfigBytes) {
|
||||
const head = await readFileHeadUtf8(configPath, payload.configHeadBytes);
|
||||
displayName = extractQuotedString(head, 'name');
|
||||
const desc = extractQuotedString(head, 'description');
|
||||
description = typeof desc === 'string' ? desc : '';
|
||||
const c = extractQuotedString(head, 'color');
|
||||
color = typeof c === 'string' && c.trim().length > 0 ? c : undefined;
|
||||
const pp = extractQuotedString(head, 'projectPath');
|
||||
projectPath = typeof pp === 'string' && pp.trim().length > 0 ? pp : undefined;
|
||||
const lead = extractQuotedString(head, 'leadSessionId');
|
||||
leadSessionId = typeof lead === 'string' && lead.trim().length > 0 ? lead : undefined;
|
||||
const del = extractQuotedString(head, 'deletedAt');
|
||||
deletedAt = typeof del === 'string' ? del : undefined;
|
||||
} else {
|
||||
const raw = await readFileUtf8WithTimeout(configPath, payload.maxConfigReadMs);
|
||||
config = JSON.parse(raw);
|
||||
displayName = typeof config.name === 'string' ? config.name : null;
|
||||
description = typeof config.description === 'string' ? config.description : '';
|
||||
color =
|
||||
typeof config.color === 'string' && config.color.trim().length > 0
|
||||
? config.color
|
||||
: undefined;
|
||||
projectPath =
|
||||
typeof config.projectPath === 'string' && config.projectPath.trim().length > 0
|
||||
? config.projectPath
|
||||
: undefined;
|
||||
leadSessionId =
|
||||
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
|
||||
? config.leadSessionId
|
||||
: undefined;
|
||||
projectPathHistory = Array.isArray(config.projectPathHistory)
|
||||
? config.projectPathHistory.slice(-payload.maxProjectPathHistoryInSummary)
|
||||
: undefined;
|
||||
sessionHistory = Array.isArray(config.sessionHistory)
|
||||
? config.sessionHistory.slice(-payload.maxSessionHistoryInSummary)
|
||||
: undefined;
|
||||
deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'READ_TIMEOUT') return skip('config_read_timeout');
|
||||
return skip('config_parse_failed');
|
||||
}
|
||||
|
||||
if (typeof displayName !== 'string' || displayName.trim() === '') {
|
||||
return skip('invalid_display_name');
|
||||
}
|
||||
|
||||
const memberMap = new Map<string, { name: string; role?: string; color?: string }>();
|
||||
const mergeMember = (m: any): void => {
|
||||
const name = typeof m?.name === 'string' ? m.name.trim() : '';
|
||||
if (!name) return;
|
||||
const key = name.toLowerCase();
|
||||
const existing = memberMap.get(key);
|
||||
memberMap.set(key, {
|
||||
name: existing?.name ?? name,
|
||||
role: (typeof m.role === 'string' && m.role.trim()) || existing?.role,
|
||||
color: (typeof m.color === 'string' && m.color.trim()) || existing?.color,
|
||||
});
|
||||
};
|
||||
|
||||
if (config && Array.isArray(config.members)) {
|
||||
for (const member of config.members) {
|
||||
mergeMember(member);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json');
|
||||
const metaStat = await fs.promises.stat(metaPath);
|
||||
if (metaStat.isFile() && metaStat.size <= payload.maxMembersMetaBytes) {
|
||||
const raw = await readFileUtf8WithTimeout(metaPath, payload.maxConfigReadMs);
|
||||
const parsed = JSON.parse(raw);
|
||||
const members: any[] = Array.isArray(parsed?.members) ? parsed.members : [];
|
||||
for (const member of members) {
|
||||
if (member && typeof member === 'object' && !member.removedAt) {
|
||||
mergeMember(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const members = Array.from(memberMap.values());
|
||||
const summary = {
|
||||
teamName,
|
||||
displayName,
|
||||
description,
|
||||
memberCount: memberMap.size,
|
||||
taskCount: 0,
|
||||
lastActivity: null,
|
||||
...(members.length > 0 ? { members } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(projectPath ? { projectPath } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
...(projectPathHistory ? { projectPathHistory } : {}),
|
||||
...(sessionHistory ? { sessionHistory } : {}),
|
||||
...(deletedAt ? { deletedAt } : {}),
|
||||
};
|
||||
|
||||
const ms = nowMs() - t0;
|
||||
if (ms >= 250) {
|
||||
diag.slowest.push({ teamName, ms });
|
||||
diag.slowest.sort((a: any, b: any) => b.ms - a.ms);
|
||||
if (diag.slowest.length > 10) diag.slowest.length = 10;
|
||||
}
|
||||
return summary;
|
||||
});
|
||||
|
||||
const teams = perTeam.filter((t): t is NonNullable<typeof t> => t !== null);
|
||||
diag.returned = teams.length;
|
||||
diag.totalMs = nowMs() - startedAt;
|
||||
return { teams, diag };
|
||||
}
|
||||
|
||||
function normalizeWorkIntervals(
|
||||
parsed: any
|
||||
): { startedAt: string; completedAt?: string }[] | undefined {
|
||||
if (!Array.isArray(parsed?.workIntervals)) return undefined;
|
||||
return (parsed.workIntervals as unknown[])
|
||||
.filter(
|
||||
(i): i is { startedAt: string; completedAt?: string } =>
|
||||
Boolean(i) &&
|
||||
typeof i === 'object' &&
|
||||
typeof (i as any).startedAt === 'string' &&
|
||||
((i as any).completedAt === undefined || typeof (i as any).completedAt === 'string')
|
||||
)
|
||||
.map((i) => ({ startedAt: i.startedAt, completedAt: i.completedAt }));
|
||||
}
|
||||
|
||||
function normalizeComments(parsed: any): unknown[] | undefined {
|
||||
if (!Array.isArray(parsed?.comments)) return undefined;
|
||||
return (parsed.comments as unknown[])
|
||||
.filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c === 'object' &&
|
||||
typeof (c as any).id === 'string' &&
|
||||
typeof (c as any).author === 'string' &&
|
||||
typeof (c as any).text === 'string' &&
|
||||
typeof (c as any).createdAt === 'string'
|
||||
)
|
||||
.map((c) => ({
|
||||
id: (c as any).id,
|
||||
author: (c as any).author,
|
||||
text: (c as any).text,
|
||||
createdAt: (c as any).createdAt,
|
||||
type:
|
||||
(c as any).type === 'regular' ||
|
||||
(c as any).type === 'review_request' ||
|
||||
(c as any).type === 'review_approved'
|
||||
? (c as any).type
|
||||
: 'regular',
|
||||
}));
|
||||
}
|
||||
|
||||
async function readTasksDirForTeam(
|
||||
tasksDir: string,
|
||||
teamName: string,
|
||||
payload: GetAllTasksPayload,
|
||||
diag: any
|
||||
): Promise<unknown[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(tasksDir);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const tasks: unknown[] = [];
|
||||
for (const file of entries) {
|
||||
if (
|
||||
!file.endsWith('.json') ||
|
||||
file.startsWith('.') ||
|
||||
file === '.lock' ||
|
||||
file === '.highwatermark'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const taskPath = path.join(tasksDir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(taskPath);
|
||||
if (!stat.isFile() || stat.size > payload.maxTaskBytes) {
|
||||
diag.skipped++;
|
||||
diag.skipReasons.task_not_file_or_large =
|
||||
(diag.skipReasons.task_not_file_or_large || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = await readFileUtf8WithTimeout(taskPath, payload.maxTaskReadMs);
|
||||
const parsed = JSON.parse(raw);
|
||||
const metadata = parsed?.metadata;
|
||||
if (metadata?._internal === true) {
|
||||
diag.skipped++;
|
||||
diag.skipReasons.task_internal = (diag.skipReasons.task_internal || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
if (parsed?.status === 'deleted') {
|
||||
diag.skipped++;
|
||||
diag.skipReasons.task_deleted = (diag.skipReasons.task_deleted || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const subject =
|
||||
typeof parsed.subject === 'string'
|
||||
? parsed.subject
|
||||
: typeof parsed.title === 'string'
|
||||
? parsed.title
|
||||
: '';
|
||||
|
||||
let createdAt: string | undefined =
|
||||
typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined;
|
||||
let updatedAt: string | undefined;
|
||||
try {
|
||||
if (!createdAt) {
|
||||
const bt = stat.birthtime.getTime();
|
||||
createdAt = (bt > 0 ? stat.birthtime : stat.mtime).toISOString();
|
||||
}
|
||||
updatedAt = stat.mtime.toISOString();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const needsClarification =
|
||||
parsed.needsClarification === 'lead' || parsed.needsClarification === 'user'
|
||||
? parsed.needsClarification
|
||||
: undefined;
|
||||
|
||||
tasks.push({
|
||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
subject,
|
||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
||||
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||
status:
|
||||
parsed.status === 'pending' ||
|
||||
parsed.status === 'in_progress' ||
|
||||
parsed.status === 'completed' ||
|
||||
parsed.status === 'deleted'
|
||||
? parsed.status
|
||||
: 'pending',
|
||||
workIntervals: normalizeWorkIntervals(parsed),
|
||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined,
|
||||
related: Array.isArray(parsed.related)
|
||||
? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string')
|
||||
: undefined,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
|
||||
comments: normalizeComments(parsed),
|
||||
needsClarification,
|
||||
deletedAt: undefined,
|
||||
teamName,
|
||||
});
|
||||
} catch (error) {
|
||||
diag.skipped++;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'READ_TIMEOUT') {
|
||||
diag.skipReasons.task_read_timeout = (diag.skipReasons.task_read_timeout || 0) + 1;
|
||||
} else {
|
||||
diag.skipReasons.task_parse_failed = (diag.skipReasons.task_parse_failed || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async function getAllTasks(
|
||||
payload: GetAllTasksPayload
|
||||
): Promise<{ tasks: unknown[]; diag: unknown }> {
|
||||
const startedAt = nowMs();
|
||||
const diag: any = {
|
||||
op: 'getAllTasks',
|
||||
startedAt,
|
||||
tasksBase: payload.tasksBase,
|
||||
teamDirs: 0,
|
||||
returned: 0,
|
||||
skipped: 0,
|
||||
skipReasons: {},
|
||||
slowestTeams: [],
|
||||
totalMs: 0,
|
||||
};
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(payload.tasksBase, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
diag.totalMs = nowMs() - startedAt;
|
||||
return { tasks: [], diag };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const dirs = entries.filter((e) => e.isDirectory());
|
||||
diag.teamDirs = dirs.length;
|
||||
|
||||
const chunks = await mapLimit(dirs, payload.concurrency, async (entry) => {
|
||||
const teamName = entry.name;
|
||||
const t0 = nowMs();
|
||||
try {
|
||||
const tasksDir = path.join(payload.tasksBase, teamName);
|
||||
const tasks = await readTasksDirForTeam(tasksDir, teamName, payload, diag);
|
||||
const ms = nowMs() - t0;
|
||||
if (ms >= 250) {
|
||||
diag.slowestTeams.push({ teamName, ms });
|
||||
diag.slowestTeams.sort((a: any, b: any) => b.ms - a.ms);
|
||||
if (diag.slowestTeams.length > 10) diag.slowestTeams.length = 10;
|
||||
}
|
||||
return tasks;
|
||||
} catch {
|
||||
diag.skipped++;
|
||||
diag.skipReasons.team_dir_failed = (diag.skipReasons.team_dir_failed || 0) + 1;
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const tasks = chunks.flat();
|
||||
diag.returned = tasks.length;
|
||||
diag.totalMs = nowMs() - startedAt;
|
||||
return { tasks, diag };
|
||||
}
|
||||
|
||||
function post(msg: WorkerResponse): void {
|
||||
parentPort?.postMessage(msg);
|
||||
}
|
||||
|
||||
parentPort?.on('message', async (msg: WorkerRequest) => {
|
||||
const { id, op } = msg;
|
||||
try {
|
||||
if (op === 'listTeams') {
|
||||
const { teams, diag } = await listTeams(msg.payload);
|
||||
post({ id, ok: true, result: teams, diag });
|
||||
return;
|
||||
}
|
||||
if (op === 'getAllTasks') {
|
||||
const { tasks, diag } = await getAllTasks(msg.payload);
|
||||
post({ id, ok: true, result: tasks, diag });
|
||||
return;
|
||||
}
|
||||
post({ id, ok: false, error: `Unknown op: ${String((msg as any).op)}` });
|
||||
} catch (error) {
|
||||
post({ id, ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
|
@ -4,6 +4,19 @@
|
|||
* Centralized IPC channel names to avoid string duplication in preload bridge.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Diagnostics / Logging Channels
|
||||
// =============================================================================
|
||||
|
||||
/** Renderer -> main log forwarding (filtered in preload) */
|
||||
export const RENDERER_LOG = 'renderer:log';
|
||||
|
||||
/** Renderer -> main lifecycle signal (preload executed) */
|
||||
export const RENDERER_BOOT = 'renderer:boot';
|
||||
|
||||
/** Renderer -> main heartbeat (detect renderer stalls) */
|
||||
export const RENDERER_HEARTBEAT = 'renderer:heartbeat';
|
||||
|
||||
// =============================================================================
|
||||
// Config API Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ import {
|
|||
HTTP_SERVER_START,
|
||||
HTTP_SERVER_STOP,
|
||||
PROJECT_LIST_FILES,
|
||||
RENDERER_BOOT,
|
||||
RENDERER_HEARTBEAT,
|
||||
RENDERER_LOG,
|
||||
REVIEW_APPLY_DECISIONS,
|
||||
REVIEW_CHECK_CONFLICT,
|
||||
REVIEW_CLEAR_DECISIONS,
|
||||
|
|
@ -246,6 +249,63 @@ async function invokeIpcWithResult<T>(channel: string, ...args: unknown[]): Prom
|
|||
return result.data as T;
|
||||
}
|
||||
|
||||
function formatConsoleArg(arg: unknown): string {
|
||||
if (typeof arg === 'string') return arg;
|
||||
if (arg instanceof Error) return arg.stack ?? arg.message;
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldForwardConsoleText(text: string): boolean {
|
||||
return (
|
||||
text.startsWith('[Store:') ||
|
||||
text.startsWith('[Component:') ||
|
||||
text.startsWith('[IPC:') ||
|
||||
text.startsWith('[Service:') ||
|
||||
text.startsWith('[Perf:')
|
||||
);
|
||||
}
|
||||
|
||||
function installRendererLogForwarding(): void {
|
||||
const originalWarn = console.warn.bind(console);
|
||||
const originalError = console.error.bind(console);
|
||||
|
||||
console.warn = (...args: unknown[]): void => {
|
||||
originalWarn(...args);
|
||||
try {
|
||||
const text = args.map(formatConsoleArg).join(' ').trim();
|
||||
if (!text || !shouldForwardConsoleText(text)) return;
|
||||
ipcRenderer.send(RENDERER_LOG, { level: 'warn', message: text });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
console.error = (...args: unknown[]): void => {
|
||||
originalError(...args);
|
||||
try {
|
||||
const text = args.map(formatConsoleArg).join(' ').trim();
|
||||
if (!text || !shouldForwardConsoleText(text)) return;
|
||||
ipcRenderer.send(RENDERER_LOG, { level: 'error', message: text });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
installRendererLogForwarding();
|
||||
|
||||
// Signal that preload executed (helps diagnose "UI stuck" with no logs).
|
||||
ipcRenderer.send(RENDERER_BOOT);
|
||||
|
||||
// Heartbeat to detect renderer thread stalls.
|
||||
setInterval(() => {
|
||||
ipcRenderer.send(RENDERER_HEARTBEAT, Date.now());
|
||||
}, 1000);
|
||||
|
||||
// Keep latest zoom factor cached even before renderer UI subscribes.
|
||||
let currentZoomFactor = 1;
|
||||
ipcRenderer.on(
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ const ChatHistoryItemInner = ({
|
|||
registerToolRef,
|
||||
}: ChatHistoryItemProps): JSX.Element | null => {
|
||||
const enterClass = isNew ? 'chat-message-enter-animate' : '';
|
||||
const transitionStyle: React.CSSProperties = { transitionDuration: '3000ms' };
|
||||
|
||||
switch (item.type) {
|
||||
case 'user': {
|
||||
|
|
@ -75,8 +76,8 @@ const ChatHistoryItemInner = ({
|
|||
return (
|
||||
<div
|
||||
ref={registerChatItemRef(item.group.id)}
|
||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
style={hl.style}
|
||||
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
|
||||
>
|
||||
<UserChatGroup userGroup={item.group} />
|
||||
</div>
|
||||
|
|
@ -93,8 +94,8 @@ const ChatHistoryItemInner = ({
|
|||
return (
|
||||
<div
|
||||
ref={registerChatItemRef(item.group.id)}
|
||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
style={hl.style}
|
||||
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
|
||||
>
|
||||
<SystemChatGroup systemGroup={item.group} />
|
||||
</div>
|
||||
|
|
@ -115,8 +116,8 @@ const ChatHistoryItemInner = ({
|
|||
return (
|
||||
<div
|
||||
ref={registerAIGroupRef(item.group.id)}
|
||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
style={hl.style}
|
||||
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
|
||||
>
|
||||
<AIChatGroup
|
||||
aiGroup={item.group}
|
||||
|
|
|
|||
|
|
@ -131,8 +131,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
|
||||
useEffect(() => {
|
||||
if (!isElectron) return;
|
||||
|
||||
void fetchCliStatus();
|
||||
// IMPORTANT: do NOT auto-fetch on mount.
|
||||
// Store initialization already schedules a deferred CLI status check to avoid
|
||||
// competing with initial teams/tasks/project scans.
|
||||
// Keep a low-frequency refresh, but only after we've successfully loaded a status.
|
||||
if (!cliStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
|
|
@ -142,7 +147,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isElectron, fetchCliStatus]);
|
||||
}, [isElectron, cliStatus, fetchCliStatus]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
installCli();
|
||||
|
|
@ -200,7 +205,31 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
// Still loading or initial render
|
||||
|
||||
// If we aren't currently loading, avoid showing a "stuck" spinner.
|
||||
// The initial CLI status check is deferred; allow user to trigger manually.
|
||||
if (!cliStatusLoading) {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center justify-between gap-3 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Claude CLI status will be checked in the background.
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Check now
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state: show spinner only while an actual request is in-flight.
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"
|
||||
|
|
|
|||
|
|
@ -431,6 +431,7 @@ const NewProjectCard = (): React.JSX.Element => {
|
|||
isMainWorktree: true,
|
||||
source: 'unknown',
|
||||
sessions: [],
|
||||
totalSessions: 0,
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
|
|
@ -481,6 +482,7 @@ const ProjectsGrid = ({
|
|||
const {
|
||||
repositoryGroups,
|
||||
repositoryGroupsLoading,
|
||||
repositoryGroupsError,
|
||||
fetchRepositoryGroups,
|
||||
selectRepository,
|
||||
globalTasks,
|
||||
|
|
@ -491,6 +493,7 @@ const ProjectsGrid = ({
|
|||
useShallow((s) => ({
|
||||
repositoryGroups: s.repositoryGroups,
|
||||
repositoryGroupsLoading: s.repositoryGroupsLoading,
|
||||
repositoryGroupsError: s.repositoryGroupsError,
|
||||
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
||||
selectRepository: s.selectRepository,
|
||||
globalTasks: s.globalTasks,
|
||||
|
|
@ -503,14 +506,17 @@ const ProjectsGrid = ({
|
|||
const hasFetchedTasksRef = React.useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (repositoryGroups.length === 0) {
|
||||
if (repositoryGroups.length === 0 && !repositoryGroupsLoading) {
|
||||
void fetchRepositoryGroups();
|
||||
}
|
||||
if (!hasFetchedTasksRef.current) {
|
||||
}, [repositoryGroups.length, repositoryGroupsLoading, fetchRepositoryGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (repositoryGroups.length > 0 && !hasFetchedTasksRef.current && !repositoryGroupsLoading) {
|
||||
hasFetchedTasksRef.current = true;
|
||||
void fetchAllTasks();
|
||||
}
|
||||
}, [repositoryGroups.length, fetchRepositoryGroups, fetchAllTasks]);
|
||||
}, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]);
|
||||
|
||||
const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
||||
|
||||
|
|
@ -587,6 +593,26 @@ const ProjectsGrid = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (repositoryGroupsError && repositoryGroups.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
|
||||
<div className="mb-1 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
||||
<FolderGit2 className="size-6 text-text-muted" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="mb-1 text-sm text-text-secondary">Failed to load projects</p>
|
||||
<p className="max-w-xl text-xs text-text-muted">{repositoryGroupsError}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void fetchRepositoryGroups()}
|
||||
className="rounded-sm border border-border bg-surface-raised px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredRepos.length === 0 && searchQuery.trim()) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ const WorktreeItem = ({
|
|||
{truncateMiddle(worktree.name, 28)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{worktree.sessions.length}
|
||||
{worktree.totalSessions ?? worktree.sessions.length}
|
||||
</span>
|
||||
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
|
||||
</button>
|
||||
|
|
@ -263,12 +263,13 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
const items =
|
||||
viewMode === 'grouped'
|
||||
? repositoryGroups.filter((r) => r.totalSessions > 0)
|
||||
: projects.filter((p) => p.sessions.length > 0);
|
||||
: projects.filter((p) => (p.totalSessions ?? p.sessions.length) > 0);
|
||||
return items.map((item) => {
|
||||
const sessionCount =
|
||||
viewMode === 'grouped'
|
||||
? (item as (typeof repositoryGroups)[0]).totalSessions
|
||||
: (item as (typeof projects)[0]).sessions.length;
|
||||
: ((item as (typeof projects)[0]).totalSessions ??
|
||||
(item as (typeof projects)[0]).sessions.length);
|
||||
const path =
|
||||
viewMode === 'grouped'
|
||||
? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path
|
||||
|
|
@ -292,7 +293,9 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
// Worktree state
|
||||
const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
|
||||
const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId);
|
||||
const worktrees = (activeRepo?.worktrees ?? []).filter((w) => w.sessions.length > 0);
|
||||
const worktrees = (activeRepo?.worktrees ?? []).filter(
|
||||
(w) => (w.totalSessions ?? w.sessions.length) > 0
|
||||
);
|
||||
const hasMultipleWorktrees = worktrees.length > 1;
|
||||
const worktreeGroupingResult = useMemo(() => groupWorktreesBySource(worktrees), [worktrees]);
|
||||
const mainWorktree = worktreeGroupingResult.mainWorktree;
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export const GlobalTaskList = ({
|
|||
path: r.worktrees[0]?.path,
|
||||
}))
|
||||
: projects
|
||||
.filter((p) => p.sessions.length > 0)
|
||||
.filter((p) => (p.totalSessions ?? p.sessions.length) > 0)
|
||||
.map((p) => ({
|
||||
value: p.path,
|
||||
label: p.name,
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ export const LaunchTeamDialog = ({
|
|||
path: wt.path,
|
||||
name: wt.name,
|
||||
sessions: [],
|
||||
totalSessions: 0,
|
||||
createdAt: wt.createdAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,7 +200,20 @@ export const ChangeReviewDialog = ({
|
|||
rejectAllChunks(view);
|
||||
}
|
||||
});
|
||||
}, [activeChangeSet, rejectAllFile, pushReviewUndoSnapshot]);
|
||||
if (REVIEW_INSTANT_APPLY) {
|
||||
// In instant-apply mode we don't show an "Apply" button, so bulk reject must
|
||||
// be applied immediately to match Cursor-like UX (including deleting new files).
|
||||
void applyReview(teamName, taskId, memberName);
|
||||
}
|
||||
}, [
|
||||
activeChangeSet,
|
||||
rejectAllFile,
|
||||
pushReviewUndoSnapshot,
|
||||
applyReview,
|
||||
teamName,
|
||||
taskId,
|
||||
memberName,
|
||||
]);
|
||||
|
||||
// Per-file callbacks for ContinuousScrollView
|
||||
const handleHunkAccepted = useCallback(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ declare global {
|
|||
// module-level side effect guarded by a global flag.
|
||||
if (!window.__claudeTeamsUiDidInit) {
|
||||
window.__claudeTeamsUiDidInit = true;
|
||||
if (import.meta.env.DEV) {
|
||||
// Intentionally console.warn so it shows up in main terminal via preload forwarding.
|
||||
console.warn('[Perf:Renderer] boot renderer/main.tsx');
|
||||
}
|
||||
initializeNotificationListeners();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,20 +81,43 @@ export function initializeNotificationListeners(): () => void {
|
|||
// Components also fire these from useEffect — loading guards in each action
|
||||
// prevent duplicate IPC calls (whichever caller starts first wins).
|
||||
void (async () => {
|
||||
const isDev = import.meta.env.DEV;
|
||||
const log = (msg: string): void => {
|
||||
if (!isDev) return;
|
||||
console.warn(`[Perf:Renderer] init ${msg}`);
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
|
||||
// Config: fast (in-memory read) — needed for theme before first paint.
|
||||
log('fetchConfig:start');
|
||||
const configStartedAt = Date.now();
|
||||
await useStore.getState().fetchConfig();
|
||||
log(`fetchConfig:done ms=${Date.now() - configStartedAt}`);
|
||||
|
||||
// Remaining fetches have no data dependency on each other — run in parallel
|
||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||
const run = async (label: string, fn: () => Promise<void>): Promise<void> => {
|
||||
log(`${label}:start`);
|
||||
const s = Date.now();
|
||||
try {
|
||||
await fn();
|
||||
log(`${label}:done ms=${Date.now() - s}`);
|
||||
} catch (e) {
|
||||
log(
|
||||
`${label}:error ms=${Date.now() - s} msg=${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
// Repository groups: heavy — full project directory scan for sidebar.
|
||||
useStore.getState().fetchRepositoryGroups(),
|
||||
// Global tasks: moderate — reads team task files for sidebar.
|
||||
useStore.getState().fetchAllTasks(),
|
||||
// Team summaries: moderate — reads team config files.
|
||||
useStore.getState().fetchTeams(),
|
||||
// Notification count: light — reads from in-memory store in main process.
|
||||
useStore.getState().fetchNotifications(),
|
||||
run('fetchRepositoryGroups', () => useStore.getState().fetchRepositoryGroups()),
|
||||
run('fetchAllTasks', () => useStore.getState().fetchAllTasks()),
|
||||
run('fetchTeams', () => useStore.getState().fetchTeams()),
|
||||
run('fetchNotifications', () => useStore.getState().fetchNotifications()),
|
||||
]);
|
||||
|
||||
log(`init:done ms=${Date.now() - startedAt}`);
|
||||
})();
|
||||
|
||||
// CLI status check is non-critical for initial render (spawns child processes
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export interface CliInstallerSlice {
|
|||
installCli: () => void;
|
||||
}
|
||||
|
||||
let cliStatusInFlight: Promise<void> | null = null;
|
||||
|
||||
// =============================================================================
|
||||
// Slice Creator
|
||||
// =============================================================================
|
||||
|
|
@ -67,17 +69,25 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
cliCompletedVersion: null,
|
||||
|
||||
fetchCliStatus: async () => {
|
||||
set({ cliStatusLoading: true, cliStatusError: null });
|
||||
try {
|
||||
const status = await api.cliInstaller.getStatus();
|
||||
set({ cliStatus: status });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to check CLI status';
|
||||
logger.error('Failed to fetch CLI status:', error);
|
||||
set({ cliStatusError: message });
|
||||
} finally {
|
||||
set({ cliStatusLoading: false });
|
||||
}
|
||||
if (!api.cliInstaller) return;
|
||||
if (cliStatusInFlight) return cliStatusInFlight;
|
||||
|
||||
cliStatusInFlight = (async () => {
|
||||
set({ cliStatusLoading: true, cliStatusError: null });
|
||||
try {
|
||||
const status = await api.cliInstaller.getStatus();
|
||||
set({ cliStatus: status });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to check CLI status';
|
||||
logger.error('Failed to fetch CLI status:', error);
|
||||
set({ cliStatusError: message });
|
||||
} finally {
|
||||
set({ cliStatusLoading: false });
|
||||
cliStatusInFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return cliStatusInFlight;
|
||||
},
|
||||
|
||||
installCli: () => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,22 @@ import type { RepositoryGroup } from '@renderer/types/data';
|
|||
import type { StateCreator } from 'zustand';
|
||||
|
||||
const logger = createLogger('Store:repository');
|
||||
const FETCH_REPOSITORY_GROUPS_TIMEOUT_MS = 30_000;
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<T>((_resolve, reject) => {
|
||||
timeoutId = setTimeout(
|
||||
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Slice Interface
|
||||
|
|
@ -53,12 +69,25 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
|
|||
fetchRepositoryGroups: async () => {
|
||||
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
||||
if (get().repositoryGroupsLoading) return;
|
||||
const startedAt = Date.now();
|
||||
set({ repositoryGroupsLoading: true, repositoryGroupsError: null });
|
||||
try {
|
||||
const groups = await api.getRepositoryGroups();
|
||||
const groups = await withTimeout(
|
||||
api.getRepositoryGroups(),
|
||||
FETCH_REPOSITORY_GROUPS_TIMEOUT_MS,
|
||||
'get-repository-groups'
|
||||
);
|
||||
// Already sorted by most recent session in the scanner
|
||||
set({ repositoryGroups: groups, repositoryGroupsLoading: false });
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 2000) {
|
||||
logger.warn(`fetchRepositoryGroups slow ms=${ms} count=${groups.length}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const ms = Date.now() - startedAt;
|
||||
logger.warn(
|
||||
`fetchRepositoryGroups failed ms=${ms}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
set({
|
||||
repositoryGroupsError:
|
||||
error instanceof Error ? error.message : 'Failed to fetch repository groups',
|
||||
|
|
|
|||
|
|
@ -361,6 +361,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
fetchAllTasks: async () => {
|
||||
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
||||
if (get().globalTasksLoading) return;
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Perf:Renderer] fetchAllTasks:enter');
|
||||
}
|
||||
// Show skeleton only on the very first fetch — not on subsequent refreshes
|
||||
// even when the task list is empty (avoids flickering skeleton on every watcher event).
|
||||
const isInitialLoad = !get().globalTasksInitialized;
|
||||
|
|
@ -371,11 +374,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
const wasFirst = isFirstFetchAllTasks;
|
||||
isFirstFetchAllTasks = false;
|
||||
try {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Perf:Renderer] fetchAllTasks:invoke');
|
||||
}
|
||||
const tasks = await withTimeout(
|
||||
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
||||
TEAM_FETCH_TIMEOUT_MS,
|
||||
'fetchAllTasks'
|
||||
);
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[Perf:Renderer] fetchAllTasks:received count=${tasks.length}`);
|
||||
}
|
||||
|
||||
if (!wasFirst) {
|
||||
const notifyOnClarifications =
|
||||
|
|
@ -396,6 +405,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
globalTasksInitialized: true,
|
||||
globalTasksError: null,
|
||||
});
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Perf:Renderer] fetchAllTasks:setState:done');
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
globalTasksLoading: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { getMemberColor, MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
|
||||
|
||||
import type {
|
||||
LeadActivityState,
|
||||
|
|
@ -87,12 +87,20 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
|
|||
const removed = members.filter((m) => m.removedAt);
|
||||
const usedColors = new Set<string>();
|
||||
|
||||
const paletteSize = MEMBER_COLOR_PALETTE.length;
|
||||
let nextFallback = 0;
|
||||
for (const member of active) {
|
||||
let color = member.color;
|
||||
if (!color || usedColors.has(color)) {
|
||||
// Search for an unused palette color, but cap iterations to avoid
|
||||
// an infinite loop when there are more members than palette colors.
|
||||
const searchStart = nextFallback;
|
||||
while (usedColors.has(getMemberColor(nextFallback))) {
|
||||
nextFallback++;
|
||||
if (nextFallback - searchStart >= paletteSize) {
|
||||
// All palette colors exhausted — reuse by cycling
|
||||
break;
|
||||
}
|
||||
}
|
||||
color = getMemberColor(nextFallback);
|
||||
nextFallback++;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,91 @@
|
|||
/**
|
||||
* Default color palette for team members.
|
||||
* Used during team creation and for preview in the UI.
|
||||
* Default color palette for team members — 64 contrasting colors.
|
||||
* Designed for high contrast on dark backgrounds.
|
||||
* Colors cycle by index: member[i] gets MEMBER_COLOR_PALETTE[i % length].
|
||||
*/
|
||||
export const MEMBER_COLOR_PALETTE = ['blue', 'green', 'yellow', 'cyan', 'purple', 'red'] as const;
|
||||
export const MEMBER_COLOR_PALETTE = [
|
||||
// ── Primary & classic ──
|
||||
'blue',
|
||||
'green',
|
||||
'yellow',
|
||||
'cyan',
|
||||
'purple',
|
||||
'red',
|
||||
'orange',
|
||||
'pink',
|
||||
|
||||
// ── Red family ──
|
||||
'rose',
|
||||
'coral',
|
||||
'crimson',
|
||||
'scarlet',
|
||||
'tomato',
|
||||
'salmon',
|
||||
'brick',
|
||||
'ruby',
|
||||
|
||||
// ── Orange / warm family ──
|
||||
'amber',
|
||||
'tangerine',
|
||||
'peach',
|
||||
'rust',
|
||||
'copper',
|
||||
'apricot',
|
||||
'bronze',
|
||||
'sienna',
|
||||
|
||||
// ── Yellow / gold family ──
|
||||
'gold',
|
||||
'lemon',
|
||||
'mustard',
|
||||
'honey',
|
||||
'saffron',
|
||||
'marigold',
|
||||
'canary',
|
||||
'sunflower',
|
||||
|
||||
// ── Green family ──
|
||||
'emerald',
|
||||
'lime',
|
||||
'mint',
|
||||
'forest',
|
||||
'olive',
|
||||
'jade',
|
||||
'sage',
|
||||
'chartreuse',
|
||||
|
||||
// ── Cyan / teal family ──
|
||||
'teal',
|
||||
'aqua',
|
||||
'turquoise',
|
||||
'sky',
|
||||
'azure',
|
||||
'cerulean',
|
||||
'seafoam',
|
||||
'arctic',
|
||||
|
||||
// ── Blue / indigo family ──
|
||||
'cobalt',
|
||||
'indigo',
|
||||
'sapphire',
|
||||
'periwinkle',
|
||||
'denim',
|
||||
'steel',
|
||||
'royal',
|
||||
'cornflower',
|
||||
|
||||
// ── Purple / pink family ──
|
||||
'violet',
|
||||
'plum',
|
||||
'amethyst',
|
||||
'lavender',
|
||||
'orchid',
|
||||
'magenta',
|
||||
'fuchsia',
|
||||
'berry',
|
||||
] as const;
|
||||
|
||||
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
|
||||
|
||||
export function getMemberColor(index: number): string {
|
||||
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
|
||||
|
|
|
|||
52
test/main/services/team/FileContentResolver.test.ts
Normal file
52
test/main/services/team/FileContentResolver.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SnippetDiff } from '@shared/types';
|
||||
|
||||
vi.mock('fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs/promises')>();
|
||||
const access = vi.fn();
|
||||
const readFile = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
access,
|
||||
readFile,
|
||||
// ESM interop: some code paths expect a default export
|
||||
default: { ...actual, access, readFile },
|
||||
};
|
||||
});
|
||||
|
||||
describe('FileContentResolver', () => {
|
||||
it('treats empty on-disk content as valid for write-new reconstruction', async () => {
|
||||
const fsPromises = await import('fs/promises');
|
||||
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
||||
readFile.mockResolvedValue('');
|
||||
|
||||
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
|
||||
|
||||
const logsFinder = {
|
||||
findMemberLogPaths: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const resolver = new FileContentResolver(logsFinder as any);
|
||||
|
||||
const snippets: SnippetDiff[] = [
|
||||
{
|
||||
toolUseId: 't1',
|
||||
filePath: '/tmp/empty-new.txt',
|
||||
toolName: 'Write',
|
||||
type: 'write-new',
|
||||
oldString: '',
|
||||
newString: '',
|
||||
replaceAll: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
isError: false,
|
||||
},
|
||||
];
|
||||
|
||||
const content = await resolver.getFileContent('team', 'member', '/tmp/empty-new.txt', snippets);
|
||||
expect(content.isNewFile).toBe(true);
|
||||
expect(content.originalFullContent).toBe('');
|
||||
expect(content.modifiedFullContent).toBe('');
|
||||
expect(content.contentSource).toBe('snippet-reconstruction');
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,21 @@ import { structuredPatch } from 'diff';
|
|||
|
||||
import type { SnippetDiff } from '@shared/types';
|
||||
|
||||
vi.mock('fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs/promises')>();
|
||||
const readFile = vi.fn();
|
||||
const writeFile = vi.fn();
|
||||
const unlink = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
readFile,
|
||||
writeFile,
|
||||
unlink,
|
||||
// ESM interop: some code paths expect a default export
|
||||
default: { ...actual, readFile, writeFile, unlink },
|
||||
};
|
||||
});
|
||||
|
||||
describe('ReviewApplierService', () => {
|
||||
it('previewReject avoids write-update snippet-level replacement', async () => {
|
||||
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
||||
|
|
@ -35,4 +50,65 @@ describe('ReviewApplierService', () => {
|
|||
expect(preview.hasConflicts).toBe(false);
|
||||
expect(preview.preview).toBe(original);
|
||||
});
|
||||
|
||||
it('deletes a newly created file when fully rejected', async () => {
|
||||
const fsPromises = await import('fs/promises');
|
||||
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
||||
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
|
||||
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
readFile.mockResolvedValue('content\n');
|
||||
unlink.mockResolvedValue(undefined);
|
||||
|
||||
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
||||
const svc = new ReviewApplierService();
|
||||
|
||||
const filePath = '/tmp/new-file.txt';
|
||||
const snippets: SnippetDiff[] = [
|
||||
{
|
||||
toolUseId: 't1',
|
||||
filePath,
|
||||
toolName: 'Write',
|
||||
type: 'write-new',
|
||||
oldString: '',
|
||||
newString: 'content\n',
|
||||
replaceAll: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
isError: false,
|
||||
},
|
||||
];
|
||||
|
||||
const res = await svc.applyReviewDecisions(
|
||||
{
|
||||
teamName: 'team',
|
||||
decisions: [
|
||||
{
|
||||
filePath,
|
||||
fileDecision: 'rejected',
|
||||
hunkDecisions: { 0: 'rejected' },
|
||||
},
|
||||
],
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
filePath,
|
||||
{
|
||||
filePath,
|
||||
relativePath: 'new-file.txt',
|
||||
snippets,
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
isNewFile: true,
|
||||
originalFullContent: '',
|
||||
modifiedFullContent: 'content\n',
|
||||
contentSource: 'snippet-reconstruction',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
expect(res.applied).toBe(1);
|
||||
expect(unlink).toHaveBeenCalledWith(filePath);
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue