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',
|
outDir: 'dist-electron/main',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
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: {
|
output: {
|
||||||
// CJS format so bundled deps can use __dirname/require.
|
// CJS format so bundled deps can use __dirname/require.
|
||||||
|
|
|
||||||
|
|
@ -2702,6 +2702,30 @@
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 159
|
"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": {
|
"openrouter/anthropic/claude-opus-4.5": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
"cache_read_input_token_cost": 5e-7,
|
"cache_read_input_token_cost": 5e-7,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import { join } from 'path';
|
||||||
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
||||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||||
import { showTeamNativeNotification } from './ipc/teams';
|
import { showTeamNativeNotification } from './ipc/teams';
|
||||||
|
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||||
|
|
@ -69,6 +70,7 @@ import type { FileChangeEvent } from '@main/types';
|
||||||
import type { TeamChangeEvent } from '@shared/types';
|
import type { TeamChangeEvent } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('App');
|
const logger = createLogger('App');
|
||||||
|
startEventLoopLagMonitor();
|
||||||
|
|
||||||
// --- Team message notification tracking ---
|
// --- Team message notification tracking ---
|
||||||
const teamInboxReader = new TeamInboxReader();
|
const teamInboxReader = new TeamInboxReader();
|
||||||
|
|
@ -792,6 +794,7 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
|
||||||
*/
|
*/
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === 'darwin';
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const iconPath = isMac ? undefined : getAppIconPath();
|
const iconPath = isMac ? undefined : getAppIconPath();
|
||||||
const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar;
|
const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar;
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
|
|
@ -802,6 +805,10 @@ function createWindow(): void {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
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',
|
backgroundColor: '#1a1a1a',
|
||||||
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
||||||
|
|
@ -809,9 +816,53 @@ function createWindow(): void {
|
||||||
title: 'Claude Agent Teams UI',
|
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
|
// Load the renderer
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (isDev) {
|
||||||
void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`);
|
// 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();
|
mainWindow.webContents.openDevTools();
|
||||||
} else {
|
} else {
|
||||||
void mainWindow.loadFile(getRendererIndexPath()).catch((error: unknown) => {
|
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
|
// Set traffic light position + notify renderer on first load, and auto-check for updates
|
||||||
mainWindow.webContents.on('did-finish-load', () => {
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
logger.warn('[startup] renderer did-finish-load');
|
||||||
syncTrafficLightPosition(mainWindow);
|
syncTrafficLightPosition(mainWindow);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
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)
|
// Log top-level renderer load failures (helps diagnose blank/black window issues in packaged apps)
|
||||||
mainWindow.webContents.on(
|
mainWindow.webContents.on(
|
||||||
'did-fail-load',
|
'did-fail-load',
|
||||||
|
|
@ -986,10 +1042,13 @@ void app.whenReady().then(() => {
|
||||||
// Apply configuration settings
|
// Apply configuration settings
|
||||||
const config = configManager.getConfig();
|
const config = configManager.getConfig();
|
||||||
|
|
||||||
// Apply launch at login setting
|
// Apply launch-at-login setting only in packaged builds.
|
||||||
app.setLoginItemSettings({
|
// In dev, macOS may deny this (and Electron logs a noisy error to stderr).
|
||||||
openAtLogin: config.general.launchAtLogin,
|
if (app.isPackaged) {
|
||||||
});
|
app.setLoginItemSettings({
|
||||||
|
openAtLogin: config.general.launchAtLogin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Apply dock visibility and icon (macOS)
|
// Apply dock visibility and icon (macOS)
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||||
const logger = createLogger('IPC:cliInstaller');
|
const logger = createLogger('IPC:cliInstaller');
|
||||||
|
|
||||||
let service: CliInstallerService;
|
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.
|
* Initializes CLI installer handlers with the service instance.
|
||||||
|
|
@ -58,7 +61,28 @@ async function handleGetStatus(
|
||||||
_event: IpcMainInvokeEvent
|
_event: IpcMainInvokeEvent
|
||||||
): Promise<IpcResult<CliInstallationStatus>> {
|
): Promise<IpcResult<CliInstallationStatus>> {
|
||||||
try {
|
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 };
|
return { success: true, data: status };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = getErrorMessage(error);
|
const msg = getErrorMessage(error);
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ import {
|
||||||
import { registerUtilityHandlers, removeUtilityHandlers } from './utility';
|
import { registerUtilityHandlers, removeUtilityHandlers } from './utility';
|
||||||
import { registerValidationHandlers, removeValidationHandlers } from './validation';
|
import { registerValidationHandlers, removeValidationHandlers } from './validation';
|
||||||
import { registerWindowHandlers, removeWindowHandlers } from './window';
|
import { registerWindowHandlers, removeWindowHandlers } from './window';
|
||||||
|
import { registerRendererLogHandlers, removeRendererLogHandlers } from './rendererLogs';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChangeExtractorService,
|
ChangeExtractorService,
|
||||||
|
|
@ -171,6 +172,7 @@ export function initializeIpcHandlers(
|
||||||
registerReviewHandlers(ipcMain);
|
registerReviewHandlers(ipcMain);
|
||||||
registerEditorHandlers(ipcMain);
|
registerEditorHandlers(ipcMain);
|
||||||
registerWindowHandlers(ipcMain);
|
registerWindowHandlers(ipcMain);
|
||||||
|
registerRendererLogHandlers(ipcMain);
|
||||||
if (cliInstaller) {
|
if (cliInstaller) {
|
||||||
registerCliInstallerHandlers(ipcMain);
|
registerCliInstallerHandlers(ipcMain);
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +206,7 @@ export function removeIpcHandlers(): void {
|
||||||
removeReviewHandlers(ipcMain);
|
removeReviewHandlers(ipcMain);
|
||||||
removeEditorHandlers(ipcMain);
|
removeEditorHandlers(ipcMain);
|
||||||
removeWindowHandlers(ipcMain);
|
removeWindowHandlers(ipcMain);
|
||||||
|
removeRendererLogHandlers(ipcMain);
|
||||||
removeCliInstallerHandlers(ipcMain);
|
removeCliInstallerHandlers(ipcMain);
|
||||||
removeTerminalHandlers(ipcMain);
|
removeTerminalHandlers(ipcMain);
|
||||||
removeHttpServerHandlers(ipcMain);
|
removeHttpServerHandlers(ipcMain);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
||||||
|
|
||||||
|
import { setCurrentMainOp } from '../services/infrastructure/EventLoopLagMonitor';
|
||||||
import { type Project, type RepositoryGroup, type Session } from '../types';
|
import { type Project, type RepositoryGroup, type Session } from '../types';
|
||||||
|
|
||||||
import { validateProjectId } from './guards';
|
import { validateProjectId } from './guards';
|
||||||
|
|
@ -59,6 +60,12 @@ export function removeProjectHandlers(ipcMain: IpcMain): void {
|
||||||
* Lists all projects from ~/.claude/projects/
|
* Lists all projects from ~/.claude/projects/
|
||||||
*/
|
*/
|
||||||
async function handleGetProjects(_event: IpcMainInvokeEvent): Promise<Project[]> {
|
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 {
|
try {
|
||||||
const { projectScanner } = registry.getActive();
|
const { projectScanner } = registry.getActive();
|
||||||
const projects = await projectScanner.scan();
|
const projects = await projectScanner.scan();
|
||||||
|
|
@ -66,6 +73,13 @@ async function handleGetProjects(_event: IpcMainInvokeEvent): Promise<Project[]>
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in get-projects:', error);
|
logger.error('Error in get-projects:', error);
|
||||||
return [];
|
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.
|
* Worktrees of the same repo are grouped together.
|
||||||
*/
|
*/
|
||||||
async function handleGetRepositoryGroups(_event: IpcMainInvokeEvent): Promise<RepositoryGroup[]> {
|
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 {
|
try {
|
||||||
const { projectScanner } = registry.getActive();
|
const { projectScanner } = registry.getActive();
|
||||||
const groups = await projectScanner.scanWithWorktreeGrouping();
|
const groups = await projectScanner.scanWithWorktreeGrouping();
|
||||||
|
|
@ -82,6 +102,13 @@ async function handleGetRepositoryGroups(_event: IpcMainInvokeEvent): Promise<Re
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in get-repository-groups:', error);
|
logger.error('Error in get-repository-groups:', error);
|
||||||
return [];
|
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 { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
import {
|
import {
|
||||||
TEAM_ADD_MEMBER,
|
TEAM_ADD_MEMBER,
|
||||||
|
|
@ -319,7 +320,17 @@ async function handleGetProjectBranch(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
|
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(
|
async function handleGetData(
|
||||||
|
|
@ -332,7 +343,6 @@ async function handleGetData(
|
||||||
}
|
}
|
||||||
const tn = validated.value!;
|
const tn = validated.value!;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logger.info(`[teams:getData] start team=${tn}`);
|
|
||||||
let data: TeamData;
|
let data: TeamData;
|
||||||
try {
|
try {
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
|
|
@ -348,12 +358,8 @@ async function handleGetData(
|
||||||
return { success: false, error: message };
|
return { success: false, error: message };
|
||||||
}
|
}
|
||||||
const getDataMs = Date.now() - startedAt;
|
const getDataMs = Date.now() - startedAt;
|
||||||
if (getDataMs >= 1000) {
|
if (getDataMs >= 1500) {
|
||||||
logger.warn(
|
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`);
|
||||||
`[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}`);
|
|
||||||
}
|
}
|
||||||
const provisioning = getTeamProvisioningService();
|
const provisioning = getTeamProvisioningService();
|
||||||
const isAlive = provisioning.isTeamAlive(tn);
|
const isAlive = provisioning.isTeamAlive(tn);
|
||||||
|
|
@ -1517,7 +1523,17 @@ async function handleStartTask(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<GlobalTask[]>> {
|
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(
|
async function handleAddMember(
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,11 @@ export class ProjectPathResolver {
|
||||||
|
|
||||||
// In SSH mode, avoid scanning every remote session file just to resolve display path.
|
// In SSH mode, avoid scanning every remote session file just to resolve display path.
|
||||||
// One successful cwd extraction is sufficient.
|
// 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)) {
|
for (const sessionPath of sessionPaths.slice(0, maxPathsToInspect)) {
|
||||||
try {
|
try {
|
||||||
const cwd = await extractCwd(sessionPath, this.fsProvider);
|
const cwd = await extractCwd(sessionPath, this.fsProvider);
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,12 @@ import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemP
|
||||||
|
|
||||||
const logger = createLogger('Discovery:ProjectScanner');
|
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 {
|
export class ProjectScanner {
|
||||||
private readonly projectsDir: string;
|
private readonly projectsDir: string;
|
||||||
private readonly todosDir: string;
|
private readonly todosDir: string;
|
||||||
|
|
@ -84,8 +90,8 @@ export class ProjectScanner {
|
||||||
private static readonly SCAN_CACHE_TTL_MS = 2000;
|
private static readonly SCAN_CACHE_TTL_MS = 2000;
|
||||||
|
|
||||||
// Platform-aware batch sizes to avoid UV thread pool saturation on Windows
|
// 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_SESSION_BATCH = process.platform === 'win32' ? 16 : 64;
|
||||||
private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 8 : 24;
|
private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 4 : 12;
|
||||||
|
|
||||||
// Delegated services
|
// Delegated services
|
||||||
private readonly fsProvider: FileSystemProvider;
|
private readonly fsProvider: FileSystemProvider;
|
||||||
|
|
@ -127,7 +133,15 @@ export class ProjectScanner {
|
||||||
}
|
}
|
||||||
|
|
||||||
const startedAt = Date.now();
|
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 {
|
try {
|
||||||
|
stage = 'exists';
|
||||||
if (!(await this.fsProvider.exists(this.projectsDir))) {
|
if (!(await this.fsProvider.exists(this.projectsDir))) {
|
||||||
logger.warn(`Projects directory does not exist: ${this.projectsDir}`);
|
logger.warn(`Projects directory does not exist: ${this.projectsDir}`);
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -136,7 +150,13 @@ export class ProjectScanner {
|
||||||
// Clear the subproject registry on full re-scan
|
// Clear the subproject registry on full re-scan
|
||||||
subprojectRegistry.clear();
|
subprojectRegistry.clear();
|
||||||
|
|
||||||
|
stage = 'readdirProjectsDir';
|
||||||
|
const readdirStartedAt = Date.now();
|
||||||
const entries = await this.fsProvider.readdir(this.projectsDir);
|
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
|
// Filter to only directories with valid encoding pattern
|
||||||
const projectDirs = entries.filter(
|
const projectDirs = entries.filter(
|
||||||
|
|
@ -144,6 +164,7 @@ export class ProjectScanner {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process each project directory (may return multiple projects per dir)
|
// Process each project directory (may return multiple projects per dir)
|
||||||
|
stage = 'scanProjects';
|
||||||
const projectArrays = await this.collectFulfilledInBatches(
|
const projectArrays = await this.collectFulfilledInBatches(
|
||||||
projectDirs,
|
projectDirs,
|
||||||
this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH,
|
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() };
|
this.scanCache = { projects: validProjects, timestamp: Date.now() };
|
||||||
return validProjects;
|
return validProjects;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error scanning projects directory:', error);
|
logger.error('Error scanning projects directory:', error);
|
||||||
return [];
|
return [];
|
||||||
|
} finally {
|
||||||
|
clearTimeout(slowWarnTimer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,25 +228,29 @@ export class ProjectScanner {
|
||||||
// 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
|
// 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
|
||||||
// Git identity resolution is bypassed to avoid blocking I/O on startup.
|
// Git identity resolution is bypassed to avoid blocking I/O on startup.
|
||||||
// Each project becomes a single-worktree group.
|
// Each project becomes a single-worktree group.
|
||||||
const groups: RepositoryGroup[] = projects.map((project) => ({
|
const groups: RepositoryGroup[] = projects.map((project) => {
|
||||||
id: project.id,
|
const totalSessions = project.totalSessions ?? project.sessions.length;
|
||||||
identity: null,
|
return {
|
||||||
worktrees: [
|
id: project.id,
|
||||||
{
|
identity: null,
|
||||||
id: project.id,
|
worktrees: [
|
||||||
path: project.path,
|
{
|
||||||
name: project.name,
|
id: project.id,
|
||||||
isMainWorktree: true,
|
path: project.path,
|
||||||
source: 'unknown' as const,
|
name: project.name,
|
||||||
sessions: project.sessions,
|
isMainWorktree: true,
|
||||||
createdAt: project.createdAt,
|
source: 'unknown' as const,
|
||||||
mostRecentSession: project.mostRecentSession,
|
sessions: project.sessions,
|
||||||
},
|
totalSessions,
|
||||||
],
|
createdAt: project.createdAt,
|
||||||
name: project.name,
|
mostRecentSession: project.mostRecentSession,
|
||||||
mostRecentSession: project.mostRecentSession,
|
},
|
||||||
totalSessions: project.sessions.length,
|
],
|
||||||
}));
|
name: project.name,
|
||||||
|
mostRecentSession: project.mostRecentSession,
|
||||||
|
totalSessions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 3. Merge custom project paths from config (persisted "Select Folder" picks)
|
// 3. Merge custom project paths from config (persisted "Select Folder" picks)
|
||||||
const { configManager } = await import('../infrastructure/ConfigManager');
|
const { configManager } = await import('../infrastructure/ConfigManager');
|
||||||
|
|
@ -244,6 +277,7 @@ export class ProjectScanner {
|
||||||
isMainWorktree: true,
|
isMainWorktree: true,
|
||||||
source: 'unknown' as const,
|
source: 'unknown' as const,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
|
totalSessions: 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -305,7 +339,13 @@ export class ProjectScanner {
|
||||||
cwd: string | null;
|
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(
|
const sessionInfos = await this.collectFulfilledInBatches(
|
||||||
sessionFiles,
|
sessionFiles,
|
||||||
this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH,
|
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__'));
|
const realCwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__'));
|
||||||
if (realCwdKeys.length <= 1) {
|
if (realCwdKeys.length <= 1) {
|
||||||
const allSessionIds = sessionInfos.map((s) => s.sessionId);
|
const allSessionIds = sessionInfos.map((s) => s.sessionId);
|
||||||
|
const exportedSessionIds = allSessionIds.slice(0, MAX_SESSION_IDS_EXPORTED);
|
||||||
let mostRecentSession: number | undefined;
|
let mostRecentSession: number | undefined;
|
||||||
let createdAt = Date.now();
|
let createdAt = Date.now();
|
||||||
for (const info of sessionInfos) {
|
for (const info of sessionInfos) {
|
||||||
|
|
@ -369,6 +410,7 @@ export class ProjectScanner {
|
||||||
|
|
||||||
const sessionPaths = sessionInfos.map((s) => s.filePath);
|
const sessionPaths = sessionInfos.map((s) => s.filePath);
|
||||||
const actualPath = await this.projectPathResolver.resolveProjectPath(encodedName, {
|
const actualPath = await this.projectPathResolver.resolveProjectPath(encodedName, {
|
||||||
|
cwdHint: firstCwd ?? undefined,
|
||||||
sessionPaths,
|
sessionPaths,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -381,7 +423,8 @@ export class ProjectScanner {
|
||||||
id: encodedName,
|
id: encodedName,
|
||||||
path: actualPath,
|
path: actualPath,
|
||||||
name: resolvedName,
|
name: resolvedName,
|
||||||
sessions: allSessionIds,
|
sessions: exportedSessionIds,
|
||||||
|
totalSessions: allSessionIds.length,
|
||||||
createdAt: Math.floor(createdAt),
|
createdAt: Math.floor(createdAt),
|
||||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -411,6 +454,7 @@ export class ProjectScanner {
|
||||||
actualCwd ?? decodedFallback,
|
actualCwd ?? decodedFallback,
|
||||||
sessionIds
|
sessionIds
|
||||||
);
|
);
|
||||||
|
const exportedSessionIds = sessionIds.slice(0, MAX_SESSION_IDS_EXPORTED);
|
||||||
|
|
||||||
// Compute timestamps
|
// Compute timestamps
|
||||||
let mostRecentSession: number | undefined;
|
let mostRecentSession: number | undefined;
|
||||||
|
|
@ -438,7 +482,8 @@ export class ProjectScanner {
|
||||||
id: compositeId,
|
id: compositeId,
|
||||||
path: actualCwd ?? decodedFallback,
|
path: actualCwd ?? decodedFallback,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
sessions: sessionIds,
|
sessions: exportedSessionIds,
|
||||||
|
totalSessions: sessionIds.length,
|
||||||
createdAt: Math.floor(createdAt),
|
createdAt: Math.floor(createdAt),
|
||||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -504,8 +549,10 @@ export class ProjectScanner {
|
||||||
const sessionPaths = sessionFiles.map((file) => path.join(projectPath, file.name));
|
const sessionPaths = sessionFiles.map((file) => path.join(projectPath, file.name));
|
||||||
const decodedPath = await this.resolveProjectPathForId(projectId, sessionPaths);
|
const decodedPath = await this.resolveProjectPathForId(projectId, sessionPaths);
|
||||||
|
|
||||||
const sessions = await Promise.all(
|
const sessions = await this.collectFulfilledInBatches(
|
||||||
sessionFiles.map(async (file) => {
|
sessionFiles,
|
||||||
|
this.fsProvider.type === 'ssh' ? 8 : 16,
|
||||||
|
async (file) => {
|
||||||
const sessionId = extractSessionId(file.name);
|
const sessionId = extractSessionId(file.name);
|
||||||
const filePath = path.join(projectPath, file.name);
|
const filePath = path.join(projectPath, file.name);
|
||||||
const fileDetails = await this.resolveFileDetails(file, filePath);
|
const fileDetails = await this.resolveFileDetails(file, filePath);
|
||||||
|
|
@ -535,7 +582,7 @@ export class ProjectScanner {
|
||||||
prefetchedSize,
|
prefetchedSize,
|
||||||
prefetchedBirthtimeMs
|
prefetchedBirthtimeMs
|
||||||
);
|
);
|
||||||
})
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out null results (noise-only sessions)
|
// Filter out null results (noise-only sessions)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,14 @@ const logger = createLogger('Service:SessionContentFilter');
|
||||||
|
|
||||||
const defaultProvider = new LocalFileSystemProvider();
|
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.
|
* Hard noise tags - user messages with ONLY these tags are filtered out.
|
||||||
*/
|
*/
|
||||||
|
|
@ -66,14 +74,39 @@ export class SessionContentFilter {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fsProvider.stat(filePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
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({
|
const rl = readline.createInterface({
|
||||||
input: fileStream,
|
input: fileStream,
|
||||||
crlfDelay: Infinity,
|
crlfDelay: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let lines = 0;
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
|
if (++lines > SESSION_SCAN_MAX_LINES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -87,6 +120,7 @@ export class SessionContentFilter {
|
||||||
// Check if this entry would create a displayable chunk
|
// Check if this entry would create a displayable chunk
|
||||||
// This aligns with ChunkBuilder.categorizeMessage() logic
|
// This aligns with ChunkBuilder.categorizeMessage() logic
|
||||||
if (SessionContentFilter.isDisplayableEntry(entry)) {
|
if (SessionContentFilter.isDisplayableEntry(entry)) {
|
||||||
|
rl.close();
|
||||||
fileStream.destroy();
|
fileStream.destroy();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -96,10 +130,21 @@ export class SessionContentFilter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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;
|
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,
|
ReadStreamOptions,
|
||||||
} from './FileSystemProvider';
|
} 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 {
|
export class LocalFileSystemProvider implements FileSystemProvider {
|
||||||
readonly type = 'local' as const;
|
readonly type = 'local' as const;
|
||||||
|
|
||||||
|
|
@ -31,7 +57,12 @@ export class LocalFileSystemProvider implements FileSystemProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
async stat(filePath: string): Promise<FsStatResult> {
|
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 {
|
return {
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
mtimeMs: stats.mtimeMs,
|
mtimeMs: stats.mtimeMs,
|
||||||
|
|
@ -43,32 +74,44 @@ export class LocalFileSystemProvider implements FileSystemProvider {
|
||||||
|
|
||||||
async readdir(dirPath: string): Promise<FsDirent[]> {
|
async readdir(dirPath: string): Promise<FsDirent[]> {
|
||||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||||
// Stat all entries concurrently to populate mtimeMs/birthtimeMs/size.
|
if (entries.length > STAT_PREFETCH_LIMIT) {
|
||||||
// Populating all three avoids a second stat() call in resolveFileDetails().
|
return entries.map((entry) => ({
|
||||||
// Failures are silently ignored (fields stay undefined).
|
name: entry.name,
|
||||||
return Promise.all(
|
isFile: () => entry.isFile(),
|
||||||
entries.map(async (entry) => {
|
isDirectory: () => entry.isDirectory(),
|
||||||
let mtimeMs: number | undefined;
|
}));
|
||||||
let birthtimeMs: number | undefined;
|
}
|
||||||
let size: number | undefined;
|
// Stat entries with bounded concurrency.
|
||||||
try {
|
// Unbounded Promise.all(stat(...)) can saturate the UV thread pool (even with
|
||||||
const stat = await fs.promises.stat(`${dirPath}/${entry.name}`);
|
// increased UV_THREADPOOL_SIZE) when directories contain thousands of files,
|
||||||
mtimeMs = stat.mtimeMs;
|
// causing unrelated operations (teams/tasks/CLI checks) to time out.
|
||||||
birthtimeMs = stat.birthtimeMs;
|
return mapLimit(entries, STAT_CONCURRENCY, async (entry) => {
|
||||||
size = stat.size;
|
let mtimeMs: number | undefined;
|
||||||
} catch {
|
let birthtimeMs: number | undefined;
|
||||||
// ignore
|
let size: number | undefined;
|
||||||
}
|
try {
|
||||||
return {
|
const fullPath = `${dirPath}/${entry.name}`;
|
||||||
name: entry.name,
|
const stat = await Promise.race([
|
||||||
mtimeMs,
|
fs.promises.stat(fullPath),
|
||||||
birthtimeMs,
|
new Promise<fs.Stats>((_resolve, reject) =>
|
||||||
size,
|
setTimeout(() => reject(new Error('stat timeout')), STAT_TIMEOUT_MS)
|
||||||
isFile: () => entry.isFile(),
|
),
|
||||||
isDirectory: () => entry.isDirectory(),
|
]);
|
||||||
};
|
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 {
|
createReadStream(filePath: string, opts?: ReadStreamOptions): fs.ReadStream {
|
||||||
|
|
|
||||||
|
|
@ -374,7 +374,9 @@ export class FileContentResolver {
|
||||||
currentContent: string | null,
|
currentContent: string | null,
|
||||||
snippets: SnippetDiff[]
|
snippets: SnippetDiff[]
|
||||||
): string | null {
|
): 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;
|
if (snippets.length === 0) return null;
|
||||||
|
|
||||||
// Filter out errored snippets
|
// Filter out errored snippets
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
|
||||||
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
||||||
|
|
@ -79,6 +80,43 @@ export class TeamConfigReader {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listTeams(): Promise<TeamSummary[]> {
|
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();
|
const teamsDir = getTeamsBasePath();
|
||||||
|
|
||||||
let entries: fs.Dirent[];
|
let entries: fs.Dirent[];
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||||
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import {
|
import {
|
||||||
encodePath,
|
encodePath,
|
||||||
|
|
@ -59,6 +60,7 @@ const MIN_TEXT_LENGTH = 30;
|
||||||
const MAX_LEAD_TEXTS = 50;
|
const MAX_LEAD_TEXTS = 50;
|
||||||
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
||||||
const MAX_PROCESSES_FILE_BYTES = 2 * 1024 * 1024;
|
const MAX_PROCESSES_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
const TASK_MAP_YIELD_EVERY = 250;
|
||||||
|
|
||||||
export class TeamDataService {
|
export class TeamDataService {
|
||||||
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
@ -82,10 +84,8 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllTasks(): Promise<GlobalTask[]> {
|
async getAllTasks(): Promise<GlobalTask[]> {
|
||||||
const [rawTasks, teams] = await Promise.all([
|
const rawTasks = await this.taskReader.getAllTasks();
|
||||||
this.taskReader.getAllTasks(),
|
const teams = await this.configReader.listTeams();
|
||||||
this.configReader.listTeams(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const teamInfoMap = new Map<
|
const teamInfoMap = new Map<
|
||||||
string,
|
string,
|
||||||
|
|
@ -116,24 +116,62 @@ export class TeamDataService {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return rawTasks
|
const out: GlobalTask[] = [];
|
||||||
.filter((task) => teamInfoMap.has(task.teamName))
|
let processed = 0;
|
||||||
.map((task) => {
|
for (const task of rawTasks) {
|
||||||
const info = teamInfoMap.get(task.teamName)!;
|
if (!teamInfoMap.has(task.teamName)) {
|
||||||
const kanban = kanbanByTeam.get(task.teamName);
|
continue;
|
||||||
const kanbanEntry = kanban?.tasks[task.id];
|
}
|
||||||
const kanbanColumn =
|
const info = teamInfoMap.get(task.teamName)!;
|
||||||
kanbanEntry?.column === 'review' || kanbanEntry?.column === 'approved'
|
const kanban = kanbanByTeam.get(task.teamName);
|
||||||
? kanbanEntry.column
|
const kanbanEntry = kanban?.tasks[task.id];
|
||||||
: undefined;
|
const kanbanColumn =
|
||||||
return {
|
kanbanEntry?.column === 'review' || kanbanEntry?.column === 'approved'
|
||||||
...task,
|
? kanbanEntry.column
|
||||||
teamDisplayName: info.displayName,
|
: undefined;
|
||||||
projectPath: task.projectPath ?? info.projectPath,
|
|
||||||
kanbanColumn,
|
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
||||||
teamDeleted: deletedTeams.has(task.teamName) || undefined,
|
// 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(
|
async updateConfig(
|
||||||
|
|
@ -320,7 +358,7 @@ export class TeamDataService {
|
||||||
const totalMs = Date.now() - startedAt;
|
const totalMs = Date.now() - startedAt;
|
||||||
if (totalMs >= 1500) {
|
if (totalMs >= 1500) {
|
||||||
logger.warn(
|
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'
|
'inboxNames'
|
||||||
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
||||||
'sentMessages'
|
'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 { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
|
|
||||||
import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types';
|
import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamTaskReader');
|
const logger = createLogger('Service:TeamTaskReader');
|
||||||
|
|
@ -53,6 +56,7 @@ export class TeamTaskReader {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasks: TeamTask[] = [];
|
const tasks: TeamTask[] = [];
|
||||||
|
let processed = 0;
|
||||||
for (const file of entries) {
|
for (const file of entries) {
|
||||||
if (
|
if (
|
||||||
!file.endsWith('.json') ||
|
!file.endsWith('.json') ||
|
||||||
|
|
@ -172,6 +176,10 @@ export class TeamTaskReader {
|
||||||
} catch {
|
} catch {
|
||||||
logger.debug(`Skipping invalid task file: ${taskPath}`);
|
logger.debug(`Skipping invalid task file: ${taskPath}`);
|
||||||
}
|
}
|
||||||
|
processed++;
|
||||||
|
if (processed % 50 === 0) {
|
||||||
|
await yieldToEventLoop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tasks;
|
return tasks;
|
||||||
|
|
@ -191,6 +199,7 @@ export class TeamTaskReader {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasks: TeamTask[] = [];
|
const tasks: TeamTask[] = [];
|
||||||
|
let processed = 0;
|
||||||
for (const file of entries) {
|
for (const file of entries) {
|
||||||
if (
|
if (
|
||||||
!file.endsWith('.json') ||
|
!file.endsWith('.json') ||
|
||||||
|
|
@ -241,12 +250,46 @@ export class TeamTaskReader {
|
||||||
} catch {
|
} catch {
|
||||||
logger.debug(`Skipping invalid task file: ${taskPath}`);
|
logger.debug(`Skipping invalid task file: ${taskPath}`);
|
||||||
}
|
}
|
||||||
|
processed++;
|
||||||
|
if (processed % 50 === 0) {
|
||||||
|
await yieldToEventLoop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> {
|
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();
|
const tasksBase = getTasksBasePath();
|
||||||
|
|
||||||
let entries: fs.Dirent[];
|
let entries: fs.Dirent[];
|
||||||
|
|
@ -260,6 +303,7 @@ export class TeamTaskReader {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: (TeamTask & { teamName: string })[] = [];
|
const result: (TeamTask & { teamName: string })[] = [];
|
||||||
|
let dirCount = 0;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
try {
|
try {
|
||||||
|
|
@ -270,6 +314,11 @@ export class TeamTaskReader {
|
||||||
} catch {
|
} catch {
|
||||||
logger.debug(`Skipping tasks dir: ${entry.name}`);
|
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;
|
return result;
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,13 @@ export interface Project {
|
||||||
path: string;
|
path: string;
|
||||||
/** Display name (last path segment) */
|
/** Display name (last path segment) */
|
||||||
name: string;
|
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[];
|
sessions: string[];
|
||||||
|
/** Total session count (may exceed sessions.length if sessions list is truncated) */
|
||||||
|
totalSessions?: number;
|
||||||
/** Unix timestamp when project directory was created */
|
/** Unix timestamp when project directory was created */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
/** Unix timestamp of most recent session activity */
|
/** Unix timestamp of most recent session activity */
|
||||||
|
|
@ -184,8 +189,13 @@ export interface Worktree {
|
||||||
isMainWorktree: boolean;
|
isMainWorktree: boolean;
|
||||||
/** Worktree source for badge display (vibe-kanban, conductor, etc.) */
|
/** Worktree source for badge display (vibe-kanban, conductor, etc.) */
|
||||||
source: WorktreeSource;
|
source: WorktreeSource;
|
||||||
/** List of session IDs */
|
/**
|
||||||
|
* List of session IDs.
|
||||||
|
* Note: this list may be truncated for performance; use totalSessions for counts.
|
||||||
|
*/
|
||||||
sessions: string[];
|
sessions: string[];
|
||||||
|
/** Total session count (may exceed sessions.length if sessions list is truncated) */
|
||||||
|
totalSessions?: number;
|
||||||
/** Unix timestamp when first session was created */
|
/** Unix timestamp when first session was created */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
/** Unix timestamp of most recent session activity */
|
/** 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,
|
type ToolCall,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
import { yieldToEventLoop } from './asyncYield';
|
||||||
|
import { extractFirstUserMessagePreview } from './metadataExtraction';
|
||||||
// Import from extracted modules
|
// Import from extracted modules
|
||||||
import { extractToolCalls, extractToolResults } from './toolExtraction';
|
import { extractToolCalls, extractToolResults } from './toolExtraction';
|
||||||
|
|
||||||
|
|
@ -65,6 +67,7 @@ export async function parseJsonlFile(
|
||||||
crlfDelay: Infinity,
|
crlfDelay: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let lineCount = 0;
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
|
@ -76,6 +79,11 @@ export async function parseJsonlFile(
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error parsing line in ${filePath}:`, error);
|
logger.error(`Error parsing line in ${filePath}:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lineCount++;
|
||||||
|
if (lineCount % 250 === 0) {
|
||||||
|
await yieldToEventLoop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
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 fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: fileStream,
|
input: fileStream,
|
||||||
|
|
@ -371,11 +414,16 @@ export async function analyzeSessionFileMetadata(
|
||||||
|
|
||||||
let awaitingPostCompaction = false;
|
let awaitingPostCompaction = false;
|
||||||
|
|
||||||
|
let lineCount = 0;
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
lineCount++;
|
||||||
|
if (lineCount % 250 === 0) {
|
||||||
|
await yieldToEventLoop();
|
||||||
|
}
|
||||||
|
|
||||||
let entry: ChatHistoryEntry;
|
let entry: ChatHistoryEntry;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,20 @@ const logger = createLogger('Util:metadataExtraction');
|
||||||
|
|
||||||
const defaultProvider = new LocalFileSystemProvider();
|
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 {
|
interface MessagePreview {
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
isCommand: boolean;
|
isCommand: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function byteLen(chunk: string): number {
|
||||||
|
return Buffer.byteLength(chunk, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract CWD (current working directory) from the first entry.
|
* Extract CWD (current working directory) from the first entry.
|
||||||
* Used to get the actual project path from encoded directory names.
|
* Used to get the actual project path from encoded directory names.
|
||||||
|
|
@ -33,17 +41,47 @@ export async function extractCwd(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fsProvider.stat(filePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
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({
|
const rl = readline.createInterface({
|
||||||
input: fileStream,
|
input: fileStream,
|
||||||
crlfDelay: Infinity,
|
crlfDelay: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let lines = 0;
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
|
if (++lines > JSONL_HEAD_MAX_LINES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (!line.trim()) continue;
|
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
|
// Only conversational entries have cwd
|
||||||
if ('cwd' in entry && entry.cwd) {
|
if ('cwd' in entry && entry.cwd) {
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|
@ -52,8 +90,11 @@ export async function extractCwd(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error extracting cwd from ${filePath}:`, error);
|
if (!timedOut) {
|
||||||
|
logger.debug(`Error extracting cwd from ${filePath}:`, error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
rl.close();
|
rl.close();
|
||||||
fileStream.destroy();
|
fileStream.destroy();
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +112,28 @@ export async function extractFirstUserMessagePreview(
|
||||||
maxLines: number = 200
|
maxLines: number = 200
|
||||||
): Promise<{ text: string; timestamp: string } | null> {
|
): Promise<{ text: string; timestamp: string } | null> {
|
||||||
const safeMaxLines = Math.max(1, maxLines);
|
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' });
|
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({
|
const rl = readline.createInterface({
|
||||||
input: fileStream,
|
input: fileStream,
|
||||||
crlfDelay: Infinity,
|
crlfDelay: Infinity,
|
||||||
|
|
@ -114,9 +176,12 @@ export async function extractFirstUserMessagePreview(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Error extracting first user preview from ${filePath}:`, error);
|
if (!timedOut) {
|
||||||
throw error;
|
logger.debug(`Error extracting first user preview from ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
return commandFallback;
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
rl.close();
|
rl.close();
|
||||||
fileStream.destroy();
|
fileStream.destroy();
|
||||||
}
|
}
|
||||||
|
|
@ -192,5 +257,5 @@ function extractPreviewFromUserEntry(entry: UserEntry): MessagePreview | null {
|
||||||
|
|
||||||
function extractCommandName(content: string): string {
|
function extractCommandName(content: string): string {
|
||||||
const commandMatch = /<command-name>\/([^<]+)<\/command-name>/.exec(content);
|
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.
|
* 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
|
// Config API Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ import {
|
||||||
HTTP_SERVER_START,
|
HTTP_SERVER_START,
|
||||||
HTTP_SERVER_STOP,
|
HTTP_SERVER_STOP,
|
||||||
PROJECT_LIST_FILES,
|
PROJECT_LIST_FILES,
|
||||||
|
RENDERER_BOOT,
|
||||||
|
RENDERER_HEARTBEAT,
|
||||||
|
RENDERER_LOG,
|
||||||
REVIEW_APPLY_DECISIONS,
|
REVIEW_APPLY_DECISIONS,
|
||||||
REVIEW_CHECK_CONFLICT,
|
REVIEW_CHECK_CONFLICT,
|
||||||
REVIEW_CLEAR_DECISIONS,
|
REVIEW_CLEAR_DECISIONS,
|
||||||
|
|
@ -246,6 +249,63 @@ async function invokeIpcWithResult<T>(channel: string, ...args: unknown[]): Prom
|
||||||
return result.data as T;
|
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.
|
// Keep latest zoom factor cached even before renderer UI subscribes.
|
||||||
let currentZoomFactor = 1;
|
let currentZoomFactor = 1;
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ const ChatHistoryItemInner = ({
|
||||||
registerToolRef,
|
registerToolRef,
|
||||||
}: ChatHistoryItemProps): JSX.Element | null => {
|
}: ChatHistoryItemProps): JSX.Element | null => {
|
||||||
const enterClass = isNew ? 'chat-message-enter-animate' : '';
|
const enterClass = isNew ? 'chat-message-enter-animate' : '';
|
||||||
|
const transitionStyle: React.CSSProperties = { transitionDuration: '3000ms' };
|
||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'user': {
|
case 'user': {
|
||||||
|
|
@ -75,8 +76,8 @@ const ChatHistoryItemInner = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={registerChatItemRef(item.group.id)}
|
ref={registerChatItemRef(item.group.id)}
|
||||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||||
style={hl.style}
|
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
|
||||||
>
|
>
|
||||||
<UserChatGroup userGroup={item.group} />
|
<UserChatGroup userGroup={item.group} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,8 +94,8 @@ const ChatHistoryItemInner = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={registerChatItemRef(item.group.id)}
|
ref={registerChatItemRef(item.group.id)}
|
||||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||||
style={hl.style}
|
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
|
||||||
>
|
>
|
||||||
<SystemChatGroup systemGroup={item.group} />
|
<SystemChatGroup systemGroup={item.group} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -115,8 +116,8 @@ const ChatHistoryItemInner = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={registerAIGroupRef(item.group.id)}
|
ref={registerAIGroupRef(item.group.id)}
|
||||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||||
style={hl.style}
|
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
|
||||||
>
|
>
|
||||||
<AIChatGroup
|
<AIChatGroup
|
||||||
aiGroup={item.group}
|
aiGroup={item.group}
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isElectron) return;
|
if (!isElectron) return;
|
||||||
|
// IMPORTANT: do NOT auto-fetch on mount.
|
||||||
void fetchCliStatus();
|
// 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(
|
const interval = setInterval(
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -142,7 +147,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isElectron, fetchCliStatus]);
|
}, [isElectron, cliStatus, fetchCliStatus]);
|
||||||
|
|
||||||
const handleInstall = useCallback(() => {
|
const handleInstall = useCallback(() => {
|
||||||
installCli();
|
installCli();
|
||||||
|
|
@ -200,7 +205,31 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"
|
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,
|
isMainWorktree: true,
|
||||||
source: 'unknown',
|
source: 'unknown',
|
||||||
sessions: [],
|
sessions: [],
|
||||||
|
totalSessions: 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -481,6 +482,7 @@ const ProjectsGrid = ({
|
||||||
const {
|
const {
|
||||||
repositoryGroups,
|
repositoryGroups,
|
||||||
repositoryGroupsLoading,
|
repositoryGroupsLoading,
|
||||||
|
repositoryGroupsError,
|
||||||
fetchRepositoryGroups,
|
fetchRepositoryGroups,
|
||||||
selectRepository,
|
selectRepository,
|
||||||
globalTasks,
|
globalTasks,
|
||||||
|
|
@ -491,6 +493,7 @@ const ProjectsGrid = ({
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
repositoryGroups: s.repositoryGroups,
|
repositoryGroups: s.repositoryGroups,
|
||||||
repositoryGroupsLoading: s.repositoryGroupsLoading,
|
repositoryGroupsLoading: s.repositoryGroupsLoading,
|
||||||
|
repositoryGroupsError: s.repositoryGroupsError,
|
||||||
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
||||||
selectRepository: s.selectRepository,
|
selectRepository: s.selectRepository,
|
||||||
globalTasks: s.globalTasks,
|
globalTasks: s.globalTasks,
|
||||||
|
|
@ -503,14 +506,17 @@ const ProjectsGrid = ({
|
||||||
const hasFetchedTasksRef = React.useRef(false);
|
const hasFetchedTasksRef = React.useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (repositoryGroups.length === 0) {
|
if (repositoryGroups.length === 0 && !repositoryGroupsLoading) {
|
||||||
void fetchRepositoryGroups();
|
void fetchRepositoryGroups();
|
||||||
}
|
}
|
||||||
if (!hasFetchedTasksRef.current) {
|
}, [repositoryGroups.length, repositoryGroupsLoading, fetchRepositoryGroups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (repositoryGroups.length > 0 && !hasFetchedTasksRef.current && !repositoryGroupsLoading) {
|
||||||
hasFetchedTasksRef.current = true;
|
hasFetchedTasksRef.current = true;
|
||||||
void fetchAllTasks();
|
void fetchAllTasks();
|
||||||
}
|
}
|
||||||
}, [repositoryGroups.length, fetchRepositoryGroups, fetchAllTasks]);
|
}, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]);
|
||||||
|
|
||||||
const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
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()) {
|
if (filteredRepos.length === 0 && searchQuery.trim()) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">
|
<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)}
|
{truncateMiddle(worktree.name, 28)}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
|
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
{worktree.sessions.length}
|
{worktree.totalSessions ?? worktree.sessions.length}
|
||||||
</span>
|
</span>
|
||||||
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
|
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -263,12 +263,13 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
const items =
|
const items =
|
||||||
viewMode === 'grouped'
|
viewMode === 'grouped'
|
||||||
? repositoryGroups.filter((r) => r.totalSessions > 0)
|
? 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) => {
|
return items.map((item) => {
|
||||||
const sessionCount =
|
const sessionCount =
|
||||||
viewMode === 'grouped'
|
viewMode === 'grouped'
|
||||||
? (item as (typeof repositoryGroups)[0]).totalSessions
|
? (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 =
|
const path =
|
||||||
viewMode === 'grouped'
|
viewMode === 'grouped'
|
||||||
? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path
|
? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path
|
||||||
|
|
@ -292,7 +293,9 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
// Worktree state
|
// Worktree state
|
||||||
const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
|
const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
|
||||||
const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId);
|
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 hasMultipleWorktrees = worktrees.length > 1;
|
||||||
const worktreeGroupingResult = useMemo(() => groupWorktreesBySource(worktrees), [worktrees]);
|
const worktreeGroupingResult = useMemo(() => groupWorktreesBySource(worktrees), [worktrees]);
|
||||||
const mainWorktree = worktreeGroupingResult.mainWorktree;
|
const mainWorktree = worktreeGroupingResult.mainWorktree;
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ export const GlobalTaskList = ({
|
||||||
path: r.worktrees[0]?.path,
|
path: r.worktrees[0]?.path,
|
||||||
}))
|
}))
|
||||||
: projects
|
: projects
|
||||||
.filter((p) => p.sessions.length > 0)
|
.filter((p) => (p.totalSessions ?? p.sessions.length) > 0)
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
value: p.path,
|
value: p.path,
|
||||||
label: p.name,
|
label: p.name,
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@ export const LaunchTeamDialog = ({
|
||||||
path: wt.path,
|
path: wt.path,
|
||||||
name: wt.name,
|
name: wt.name,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
|
totalSessions: 0,
|
||||||
createdAt: wt.createdAt ?? Date.now(),
|
createdAt: wt.createdAt ?? Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,20 @@ export const ChangeReviewDialog = ({
|
||||||
rejectAllChunks(view);
|
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
|
// Per-file callbacks for ContinuousScrollView
|
||||||
const handleHunkAccepted = useCallback(
|
const handleHunkAccepted = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ declare global {
|
||||||
// module-level side effect guarded by a global flag.
|
// module-level side effect guarded by a global flag.
|
||||||
if (!window.__claudeTeamsUiDidInit) {
|
if (!window.__claudeTeamsUiDidInit) {
|
||||||
window.__claudeTeamsUiDidInit = true;
|
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();
|
initializeNotificationListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,20 +81,43 @@ export function initializeNotificationListeners(): () => void {
|
||||||
// Components also fire these from useEffect — loading guards in each action
|
// Components also fire these from useEffect — loading guards in each action
|
||||||
// prevent duplicate IPC calls (whichever caller starts first wins).
|
// prevent duplicate IPC calls (whichever caller starts first wins).
|
||||||
void (async () => {
|
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.
|
// Config: fast (in-memory read) — needed for theme before first paint.
|
||||||
|
log('fetchConfig:start');
|
||||||
|
const configStartedAt = Date.now();
|
||||||
await useStore.getState().fetchConfig();
|
await useStore.getState().fetchConfig();
|
||||||
|
log(`fetchConfig:done ms=${Date.now() - configStartedAt}`);
|
||||||
|
|
||||||
// Remaining fetches have no data dependency on each other — run in parallel
|
// Remaining fetches have no data dependency on each other — run in parallel
|
||||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
// 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([
|
await Promise.all([
|
||||||
// Repository groups: heavy — full project directory scan for sidebar.
|
run('fetchRepositoryGroups', () => useStore.getState().fetchRepositoryGroups()),
|
||||||
useStore.getState().fetchRepositoryGroups(),
|
run('fetchAllTasks', () => useStore.getState().fetchAllTasks()),
|
||||||
// Global tasks: moderate — reads team task files for sidebar.
|
run('fetchTeams', () => useStore.getState().fetchTeams()),
|
||||||
useStore.getState().fetchAllTasks(),
|
run('fetchNotifications', () => useStore.getState().fetchNotifications()),
|
||||||
// Team summaries: moderate — reads team config files.
|
|
||||||
useStore.getState().fetchTeams(),
|
|
||||||
// Notification count: light — reads from in-memory store in main process.
|
|
||||||
useStore.getState().fetchNotifications(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
log(`init:done ms=${Date.now() - startedAt}`);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// CLI status check is non-critical for initial render (spawns child processes
|
// CLI status check is non-critical for initial render (spawns child processes
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ export interface CliInstallerSlice {
|
||||||
installCli: () => void;
|
installCli: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cliStatusInFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Slice Creator
|
// Slice Creator
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -67,17 +69,25 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
cliCompletedVersion: null,
|
cliCompletedVersion: null,
|
||||||
|
|
||||||
fetchCliStatus: async () => {
|
fetchCliStatus: async () => {
|
||||||
set({ cliStatusLoading: true, cliStatusError: null });
|
if (!api.cliInstaller) return;
|
||||||
try {
|
if (cliStatusInFlight) return cliStatusInFlight;
|
||||||
const status = await api.cliInstaller.getStatus();
|
|
||||||
set({ cliStatus: status });
|
cliStatusInFlight = (async () => {
|
||||||
} catch (error) {
|
set({ cliStatusLoading: true, cliStatusError: null });
|
||||||
const message = error instanceof Error ? error.message : 'Failed to check CLI status';
|
try {
|
||||||
logger.error('Failed to fetch CLI status:', error);
|
const status = await api.cliInstaller.getStatus();
|
||||||
set({ cliStatusError: message });
|
set({ cliStatus: status });
|
||||||
} finally {
|
} catch (error) {
|
||||||
set({ cliStatusLoading: false });
|
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: () => {
|
installCli: () => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,22 @@ import type { RepositoryGroup } from '@renderer/types/data';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
const logger = createLogger('Store:repository');
|
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
|
// Slice Interface
|
||||||
|
|
@ -53,12 +69,25 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
|
||||||
fetchRepositoryGroups: async () => {
|
fetchRepositoryGroups: async () => {
|
||||||
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
||||||
if (get().repositoryGroupsLoading) return;
|
if (get().repositoryGroupsLoading) return;
|
||||||
|
const startedAt = Date.now();
|
||||||
set({ repositoryGroupsLoading: true, repositoryGroupsError: null });
|
set({ repositoryGroupsLoading: true, repositoryGroupsError: null });
|
||||||
try {
|
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
|
// Already sorted by most recent session in the scanner
|
||||||
set({ repositoryGroups: groups, repositoryGroupsLoading: false });
|
set({ repositoryGroups: groups, repositoryGroupsLoading: false });
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms >= 2000) {
|
||||||
|
logger.warn(`fetchRepositoryGroups slow ms=${ms} count=${groups.length}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
logger.warn(
|
||||||
|
`fetchRepositoryGroups failed ms=${ms}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
set({
|
set({
|
||||||
repositoryGroupsError:
|
repositoryGroupsError:
|
||||||
error instanceof Error ? error.message : 'Failed to fetch repository groups',
|
error instanceof Error ? error.message : 'Failed to fetch repository groups',
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
fetchAllTasks: async () => {
|
fetchAllTasks: async () => {
|
||||||
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
||||||
if (get().globalTasksLoading) return;
|
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
|
// 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).
|
// even when the task list is empty (avoids flickering skeleton on every watcher event).
|
||||||
const isInitialLoad = !get().globalTasksInitialized;
|
const isInitialLoad = !get().globalTasksInitialized;
|
||||||
|
|
@ -371,11 +374,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const wasFirst = isFirstFetchAllTasks;
|
const wasFirst = isFirstFetchAllTasks;
|
||||||
isFirstFetchAllTasks = false;
|
isFirstFetchAllTasks = false;
|
||||||
try {
|
try {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[Perf:Renderer] fetchAllTasks:invoke');
|
||||||
|
}
|
||||||
const tasks = await withTimeout(
|
const tasks = await withTimeout(
|
||||||
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
||||||
TEAM_FETCH_TIMEOUT_MS,
|
TEAM_FETCH_TIMEOUT_MS,
|
||||||
'fetchAllTasks'
|
'fetchAllTasks'
|
||||||
);
|
);
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn(`[Perf:Renderer] fetchAllTasks:received count=${tasks.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!wasFirst) {
|
if (!wasFirst) {
|
||||||
const notifyOnClarifications =
|
const notifyOnClarifications =
|
||||||
|
|
@ -396,6 +405,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
globalTasksInitialized: true,
|
globalTasksInitialized: true,
|
||||||
globalTasksError: null,
|
globalTasksError: null,
|
||||||
});
|
});
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[Perf:Renderer] fetchAllTasks:setState:done');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
globalTasksLoading: false,
|
globalTasksLoading: false,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor, MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
|
@ -87,12 +87,20 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
|
||||||
const removed = members.filter((m) => m.removedAt);
|
const removed = members.filter((m) => m.removedAt);
|
||||||
const usedColors = new Set<string>();
|
const usedColors = new Set<string>();
|
||||||
|
|
||||||
|
const paletteSize = MEMBER_COLOR_PALETTE.length;
|
||||||
let nextFallback = 0;
|
let nextFallback = 0;
|
||||||
for (const member of active) {
|
for (const member of active) {
|
||||||
let color = member.color;
|
let color = member.color;
|
||||||
if (!color || usedColors.has(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))) {
|
while (usedColors.has(getMemberColor(nextFallback))) {
|
||||||
nextFallback++;
|
nextFallback++;
|
||||||
|
if (nextFallback - searchStart >= paletteSize) {
|
||||||
|
// All palette colors exhausted — reuse by cycling
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
color = getMemberColor(nextFallback);
|
color = getMemberColor(nextFallback);
|
||||||
nextFallback++;
|
nextFallback++;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,91 @@
|
||||||
/**
|
/**
|
||||||
* Default color palette for team members.
|
* Default color palette for team members — 64 contrasting colors.
|
||||||
* Used during team creation and for preview in the UI.
|
* Designed for high contrast on dark backgrounds.
|
||||||
* Colors cycle by index: member[i] gets MEMBER_COLOR_PALETTE[i % length].
|
* 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 {
|
export function getMemberColor(index: number): string {
|
||||||
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
|
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';
|
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', () => {
|
describe('ReviewApplierService', () => {
|
||||||
it('previewReject avoids write-update snippet-level replacement', async () => {
|
it('previewReject avoids write-update snippet-level replacement', async () => {
|
||||||
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
||||||
|
|
@ -35,4 +50,65 @@ describe('ReviewApplierService', () => {
|
||||||
expect(preview.hasConflicts).toBe(false);
|
expect(preview.hasConflicts).toBe(false);
|
||||||
expect(preview.preview).toBe(original);
|
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