From 43b18d492060128124c97b73be39a8c843cea2b9 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 3 Mar 2026 17:43:29 +0200 Subject: [PATCH] feat: implement file read timeout handling and size validation across team services - Introduced a new utility function `readFileUtf8WithTimeout` to handle file reading with a specified timeout, improving robustness against long read operations. - Added size validation for various team-related files (e.g., config, inbox, processes) to prevent issues with oversized files. - Updated multiple services (TeamConfigReader, TeamDataService, TeamInboxReader, TeamKanbanManager, TeamMembersMetaStore, TeamProvisioningService, TeamSentMessagesStore, TeamTaskReader) to utilize the new file reading method and enforce size limits. - Enhanced error handling to gracefully manage read timeouts and invalid file states, improving overall system stability. Made-with: Cursor --- src/main/services/team/TeamConfigReader.ts | 287 ++++++++++-------- src/main/services/team/TeamDataService.ts | 16 +- src/main/services/team/TeamInboxReader.ts | 57 +++- src/main/services/team/TeamKanbanManager.ts | 11 +- .../services/team/TeamMembersMetaStore.ts | 9 +- .../services/team/TeamProvisioningService.ts | 108 +++++-- .../services/team/TeamSentMessagesStore.ts | 13 +- src/main/services/team/TeamTaskReader.ts | 25 +- src/main/utils/fsRead.ts | 40 +++ .../components/sidebar/GlobalTaskList.tsx | 6 +- .../components/team/TeamDetailView.tsx | 18 +- src/renderer/components/team/TeamListView.tsx | 4 +- src/renderer/store/slices/teamSlice.ts | 157 ++++++++-- .../services/team/TeamInboxReader.test.ts | 33 +- .../services/team/TeamKanbanManager.test.ts | 31 +- .../team/TeamProvisioningServiceRelay.test.ts | 30 +- 16 files changed, 636 insertions(+), 209 deletions(-) create mode 100644 src/main/utils/fsRead.ts diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 6a7c5314..5abe2db4 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -1,3 +1,4 @@ +import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; @@ -12,6 +13,8 @@ const logger = createLogger('Service:TeamConfigReader'); const TEAM_LIST_CONCURRENCY = process.platform === 'win32' ? 4 : 12; const LARGE_CONFIG_BYTES = 512 * 1024; const CONFIG_HEAD_BYTES = 64 * 1024; +const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full config reads +const PER_TEAM_READ_TIMEOUT_MS = 5_000; const MAX_SESSION_HISTORY_IN_SUMMARY = 2000; const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; @@ -34,6 +37,16 @@ async function mapLimit( return results; } +function withReadTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Team config read timeout')), ms); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + async function readFileHead(filePath: string, maxBytes: number): Promise { const handle = await fs.promises.open(filePath, 'r'); try { @@ -82,125 +95,15 @@ export class TeamConfigReader { TEAM_LIST_CONCURRENCY, async (entry): Promise => { const teamName = entry.name; - const configPath = path.join(teamsDir, teamName, 'config.json'); try { - let config: TeamConfig | null = 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: TeamConfig['projectPathHistory'] | undefined; - let sessionHistory: TeamConfig['sessionHistory'] | undefined; - - let stat: fs.Stats | null = null; - try { - stat = await fs.promises.stat(configPath); - } catch { - stat = null; - } - - if (stat && stat.isFile() && stat.size > LARGE_CONFIG_BYTES) { - const head = await readFileHead(configPath, CONFIG_HEAD_BYTES); - 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 fs.promises.readFile(configPath, 'utf8'); - config = JSON.parse(raw) as TeamConfig; - 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(-MAX_PROJECT_PATH_HISTORY_IN_SUMMARY) - : undefined; - sessionHistory = Array.isArray(config.sessionHistory) - ? config.sessionHistory.slice(-MAX_SESSION_HISTORY_IN_SUMMARY) - : undefined; - deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined; - } - - if (typeof displayName !== 'string' || displayName.trim() === '') { - logger.debug(`Skipping team dir with invalid config name: ${teamName}`); - return null; - } - - // Case-insensitive dedup: key is lowercase name, value keeps the original casing - const memberMap = new Map(); - - const mergeMember = (m: TeamMember): void => { - const name = m.name?.trim(); - if (!name) return; - const key = name.toLowerCase(); - const existing = memberMap.get(key); - memberMap.set(key, { - name: existing?.name ?? name, - role: m.role?.trim() || existing?.role, - color: m.color?.trim() || existing?.color, - }); - }; - - if (config && Array.isArray(config.members)) { - for (const member of config.members) { - if (member && typeof member.name === 'string') { - mergeMember(member); - } - } - } - - // Also read members.meta.json — UI-created teams store members there, - // and CLI-created teams may have additional members added via the UI. - try { - const metaMembers = await this.membersMetaStore.getMembers(teamName); - for (const member of metaMembers) { - if (!member.removedAt) { - mergeMember(member); - } - } - } catch { - // best-effort — don't fail listing if meta file is broken - } - - const members = Array.from(memberMap.values()); - const summary: TeamSummary = { - 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 } : {}), - }; - return summary; - } catch { - logger.debug(`Skipping team dir without valid config: ${teamName}`); + return await withReadTimeout( + this.readTeamSummary(teamsDir, teamName), + PER_TEAM_READ_TIMEOUT_MS + ); + } catch (err) { + const reason = err instanceof Error ? err.message : 'unknown'; + logger.warn(`Skipping team dir (${reason}): ${teamName}`); return null; } } @@ -209,16 +112,162 @@ export class TeamConfigReader { return perTeam.filter((t): t is TeamSummary => t !== null); } + private async readTeamSummary(teamsDir: string, teamName: string): Promise { + const configPath = path.join(teamsDir, teamName, 'config.json'); + + try { + let config: TeamConfig | null = 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: TeamConfig['projectPathHistory'] | undefined; + let sessionHistory: TeamConfig['sessionHistory'] | undefined; + + let stat: fs.Stats | null = null; + try { + stat = await fs.promises.stat(configPath); + } catch { + stat = null; + } + + // Skip non-regular files (pipes, sockets, etc.) — readFile could hang on them + if (!stat?.isFile()) { + logger.debug(`Skipping team dir with missing/non-file config: ${teamName}`); + return null; + } + + // Safety: refuse to touch extremely large configs. Even "head" parsing can be misleading, + // and full reads/parses can stall the main process. + if (stat.size > MAX_CONFIG_READ_BYTES) { + logger.warn( + `Skipping team dir with oversized config.json (${stat.size} bytes): ${teamName}` + ); + return null; + } + + if (stat.size > LARGE_CONFIG_BYTES) { + // Defensive: avoid any reads from very large configs during listing. + // If the team is real, it can still be opened later via getConfig(). + displayName = teamName; + } else { + const raw = await readFileUtf8WithTimeout(configPath, PER_TEAM_READ_TIMEOUT_MS); + config = JSON.parse(raw) as TeamConfig; + 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(-MAX_PROJECT_PATH_HISTORY_IN_SUMMARY) + : undefined; + sessionHistory = Array.isArray(config.sessionHistory) + ? config.sessionHistory.slice(-MAX_SESSION_HISTORY_IN_SUMMARY) + : undefined; + deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined; + } + + if (typeof displayName !== 'string' || displayName.trim() === '') { + logger.debug(`Skipping team dir with invalid config name: ${teamName}`); + return null; + } + + // Case-insensitive dedup: key is lowercase name, value keeps the original casing + const memberMap = new Map(); + + const mergeMember = (m: TeamMember): void => { + const name = m.name?.trim(); + if (!name) return; + const key = name.toLowerCase(); + const existing = memberMap.get(key); + memberMap.set(key, { + name: existing?.name ?? name, + role: m.role?.trim() || existing?.role, + color: m.color?.trim() || existing?.color, + }); + }; + + if (config && Array.isArray(config.members)) { + for (const member of config.members) { + if (member && typeof member.name === 'string') { + mergeMember(member); + } + } + } + + // Also read members.meta.json — UI-created teams store members there, + // and CLI-created teams may have additional members added via the UI. + try { + const metaMembers = await this.membersMetaStore.getMembers(teamName); + for (const member of metaMembers) { + if (!member.removedAt) { + mergeMember(member); + } + } + } catch { + // best-effort — don't fail listing if meta file is broken + } + + const members = Array.from(memberMap.values()); + const summary: TeamSummary = { + 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 } : {}), + }; + return summary; + } catch { + logger.debug(`Skipping team dir without valid config: ${teamName}`); + return null; + } + } + async getConfig(teamName: string): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { - const raw = await fs.promises.readFile(configPath, 'utf8'); + const stat = await fs.promises.stat(configPath); + // Safety: refuse special files and huge/binary configs + if (!stat.isFile()) { + return null; + } + if (stat.size > MAX_CONFIG_READ_BYTES) { + logger.warn( + `Refusing to load oversized config.json (${stat.size} bytes) for team: ${teamName}` + ); + return null; + } + + const raw = await readFileUtf8WithTimeout(configPath, PER_TEAM_READ_TIMEOUT_MS); const config = JSON.parse(raw) as TeamConfig; if (typeof config.name !== 'string' || config.name.trim() === '') { return null; } return config; - } catch { + } catch (error) { + if (error instanceof FileReadTimeoutError) { + logger.warn(`[getConfig] ${error.message}`); + return null; + } return null; } } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 5b0372a4..95ca45e3 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1,3 +1,4 @@ +import { readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { encodePath, extractBaseDir, @@ -57,6 +58,7 @@ const logger = createLogger('Service:TeamDataService'); const MIN_TEXT_LENGTH = 30; const MAX_LEAD_TEXTS = 50; const PROCESS_HEALTH_INTERVAL_MS = 2_000; +const MAX_PROCESSES_FILE_BYTES = 2 * 1024 * 1024; export class TeamDataService { private processHealthTimer: ReturnType | null = null; @@ -379,7 +381,11 @@ export class TeamDataService { const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); let raw: unknown[]; try { - const content = await fs.promises.readFile(processesPath, 'utf8'); + const stat = await fs.promises.stat(processesPath); + if (!stat.isFile() || stat.size > MAX_PROCESSES_FILE_BYTES) { + continue; + } + const content = await readFileUtf8WithTimeout(processesPath, 5_000); const parsed: unknown = JSON.parse(content); raw = Array.isArray(parsed) ? (parsed as unknown[]) : []; } catch { @@ -418,7 +424,11 @@ export class TeamDataService { const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); let raw: unknown[]; try { - const content = await fs.promises.readFile(processesPath, 'utf8'); + const stat = await fs.promises.stat(processesPath); + if (!stat.isFile() || stat.size > MAX_PROCESSES_FILE_BYTES) { + return []; + } + const content = await readFileUtf8WithTimeout(processesPath, 5_000); const parsed: unknown = JSON.parse(content); raw = Array.isArray(parsed) ? (parsed as unknown[]) : []; } catch { @@ -476,7 +486,7 @@ export class TeamDataService { // Update processes.json to set stoppedAt let raw: unknown[]; try { - const content = await fs.promises.readFile(processesPath, 'utf8'); + const content = await readFileUtf8WithTimeout(processesPath, 5_000); const parsed: unknown = JSON.parse(content); raw = Array.isArray(parsed) ? (parsed as unknown[]) : []; } catch { diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index aec1e9e1..b4e9cc5e 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -1,9 +1,32 @@ +import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; import * as fs from 'fs'; import * as path from 'path'; import type { InboxMessage } from '@shared/types'; +const MAX_INBOX_FILE_BYTES = 10 * 1024 * 1024; // 10MB — skip corrupt/oversized inbox files +const INBOX_READ_CONCURRENCY = process.platform === 'win32' ? 4 : 12; + +async function mapLimit( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results = new Array(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 TeamInboxReader { async listInboxNames(teamName: string): Promise { const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes'); @@ -28,11 +51,19 @@ export class TeamInboxReader { let raw: string; try { - raw = await fs.promises.readFile(inboxPath, 'utf8'); + const stat = await fs.promises.stat(inboxPath); + // Avoid hangs on non-regular files (FIFO, sockets) and unbounded memory usage on huge files. + if (!stat.isFile() || stat.size > MAX_INBOX_FILE_BYTES) { + return []; + } + raw = await readFileUtf8WithTimeout(inboxPath, 5_000); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return []; } + if (error instanceof FileReadTimeoutError) { + return []; + } throw error; } @@ -85,21 +116,19 @@ export class TeamInboxReader { async getMessages(teamName: string): Promise { const members = await this.listInboxNames(teamName); - const chunks = await Promise.all( - members.map(async (member) => { - try { - const msgs = await this.getMessagesFor(teamName, member); - for (const msg of msgs) { - if (!msg.to) { - msg.to = member; - } + const chunks = await mapLimit(members, INBOX_READ_CONCURRENCY, async (member) => { + try { + const msgs = await this.getMessagesFor(teamName, member); + for (const msg of msgs) { + if (!msg.to) { + msg.to = member; } - return msgs; - } catch { - return [] as InboxMessage[]; } - }) - ); + return msgs; + } catch { + return [] as InboxMessage[]; + } + }); const merged = chunks.flat(); merged.sort((a, b) => { diff --git a/src/main/services/team/TeamKanbanManager.ts b/src/main/services/team/TeamKanbanManager.ts index 98647faf..3bc53951 100644 --- a/src/main/services/team/TeamKanbanManager.ts +++ b/src/main/services/team/TeamKanbanManager.ts @@ -1,3 +1,4 @@ +import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { createLogger } from '@shared/utils/logger'; @@ -9,6 +10,7 @@ import { atomicWriteAsync } from './atomicWrite'; import type { KanbanColumnId, KanbanState, UpdateKanbanPatch } from '@shared/types'; const logger = createLogger('Service:TeamKanbanManager'); +const MAX_KANBAN_STATE_BYTES = 512 * 1024; function createDefaultState(teamName: string): KanbanState { return { @@ -45,11 +47,18 @@ export class TeamKanbanManager { let raw: string; try { - raw = await fs.promises.readFile(statePath, 'utf8'); + const stat = await fs.promises.stat(statePath); + if (!stat.isFile() || stat.size > MAX_KANBAN_STATE_BYTES) { + return createDefaultState(teamName); + } + raw = await readFileUtf8WithTimeout(statePath, 5_000); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return createDefaultState(teamName); } + if (error instanceof FileReadTimeoutError) { + return createDefaultState(teamName); + } throw error; } diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 14705ba0..66ff8f43 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -1,3 +1,4 @@ +import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; import * as fs from 'fs'; import * as path from 'path'; @@ -40,6 +41,9 @@ export class TeamMembersMetaStore { const metaPath = this.getMetaPath(teamName); try { const stat = await fs.promises.stat(metaPath); + if (!stat.isFile()) { + return []; + } if (stat.isFile() && stat.size > MAX_META_FILE_BYTES) { return []; } @@ -48,11 +52,14 @@ export class TeamMembersMetaStore { } let raw: string; try { - raw = await fs.promises.readFile(metaPath, 'utf8'); + raw = await readFileUtf8WithTimeout(metaPath, 5_000); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return []; } + if (error instanceof FileReadTimeoutError) { + return []; + } throw error; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 464ec419..acbbdd3f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,6 +1,7 @@ /* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { killProcessTree, spawnCli } from '@main/utils/childProcess'; +import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { encodePath, extractBaseDir, @@ -65,6 +66,9 @@ const PREFLIGHT_AUTH_MAX_RETRIES = 2; const KEYCHAIN_TIMEOUT_MS = 5000; const FS_MONITOR_POLL_MS = 2000; const TASK_WAIT_FALLBACK_MS = 15_000; +const TEAM_JSON_READ_TIMEOUT_MS = 5_000; +const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024; +const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024; const execFileAsync = promisify(execFile); @@ -183,6 +187,37 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function tryReadRegularFileUtf8( + filePath: string, + opts: { timeoutMs: number; maxBytes: number } +): Promise { + let stat: fs.Stats; + try { + stat = await fs.promises.stat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + return null; + } + + if (!stat.isFile() || stat.size > opts.maxBytes) { + return null; + } + + try { + return await readFileUtf8WithTimeout(filePath, opts.timeoutMs); + } catch (error) { + if (error instanceof FileReadTimeoutError) { + return null; + } + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + return null; + } +} + let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null; let shellEnvResolvePromise: Promise | null = null; @@ -1420,10 +1455,11 @@ export class TeamProvisioningService { // Verify config.json exists — team must already be provisioned const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json'); - let configRaw: string; - try { - configRaw = await fs.promises.readFile(configPath, 'utf8'); - } catch { + const configRaw = await tryReadRegularFileUtf8(configPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (!configRaw) { throw new Error(`Team "${request.teamName}" not found — config.json does not exist`); } @@ -2066,14 +2102,12 @@ export class TeamProvisioningService { const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`); await withInboxLock(inboxPath, async () => { - let raw: string; - try { - raw = await fs.promises.readFile(inboxPath, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return; - } - throw error; + const raw = await tryReadRegularFileUtf8(inboxPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_INBOX_MAX_BYTES, + }); + if (!raw) { + return; } let parsed: unknown; @@ -2689,7 +2723,13 @@ export class TeamProvisioningService { } for (const probe of probes) { try { - const raw = await fs.promises.readFile(probe.configPath, 'utf8'); + const raw = await tryReadRegularFileUtf8(probe.configPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (!raw) { + continue; + } const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === 'object') { const candidate = parsed as { name?: unknown }; @@ -2984,7 +3024,13 @@ export class TeamProvisioningService { private async updateConfigProjectPath(teamName: string, cwd: string): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { - const raw = await fs.promises.readFile(configPath, 'utf8'); + const raw = await tryReadRegularFileUtf8(configPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (!raw) { + throw new Error('config.json unreadable'); + } const config = JSON.parse(raw) as Record; config.projectPath = cwd; @@ -3019,7 +3065,13 @@ export class TeamProvisioningService { const MAX_PROJECT_PATH_HISTORY = 500; const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { - const raw = await fs.promises.readFile(configPath, 'utf8'); + const raw = await tryReadRegularFileUtf8(configPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (!raw) { + throw new Error('config.json unreadable'); + } const config = JSON.parse(raw) as Record; const sessionHistory = Array.isArray(config.sessionHistory) @@ -3224,7 +3276,13 @@ export class TeamProvisioningService { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const backupPath = `${configPath}.prelaunch.bak`; try { - const backupRaw = await fs.promises.readFile(backupPath, 'utf8'); + const backupRaw = await tryReadRegularFileUtf8(backupPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (!backupRaw) { + return; + } await atomicWriteAsync(configPath, backupRaw); logger.info(`[${teamName}] Restored config.json from prelaunch backup after launch failure`); } catch { @@ -3278,7 +3336,14 @@ export class TeamProvisioningService { const canonicalPath = path.join(inboxDir, canonicalFile); let canonicalRaw: string; try { - canonicalRaw = await fs.promises.readFile(canonicalPath, 'utf8'); + const raw = await tryReadRegularFileUtf8(canonicalPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_INBOX_MAX_BYTES, + }); + if (!raw) { + continue; + } + canonicalRaw = raw; } catch { // If cannot read, skip cleanup for this base. continue; @@ -3297,7 +3362,14 @@ export class TeamProvisioningService { const dupPath = path.join(inboxDir, dupFile); let dupRaw: string; try { - dupRaw = await fs.promises.readFile(dupPath, 'utf8'); + const raw = await tryReadRegularFileUtf8(dupPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_INBOX_MAX_BYTES, + }); + if (!raw) { + continue; + } + dupRaw = raw; } catch { continue; } diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index c91b5a66..267ca833 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -1,3 +1,4 @@ +import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; @@ -8,6 +9,7 @@ import { atomicWriteAsync } from './atomicWrite'; import type { InboxMessage } from '@shared/types'; const MAX_MESSAGES = 200; +const MAX_SENT_MESSAGES_FILE_BYTES = 2 * 1024 * 1024; const logger = createLogger('TeamSentMessagesStore'); export class TeamSentMessagesStore { @@ -20,11 +22,20 @@ export class TeamSentMessagesStore { let raw: string; try { - raw = await fs.promises.readFile(filePath, 'utf8'); + const stat = await fs.promises.stat(filePath); + // Avoid hangs on non-regular files (FIFO, sockets) and huge/binary files. + if (!stat.isFile() || stat.size > MAX_SENT_MESSAGES_FILE_BYTES) { + return []; + } + raw = await readFileUtf8WithTimeout(filePath, 5_000); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return []; } + if (error instanceof FileReadTimeoutError) { + logger.error(`Timed out reading sent messages for ${teamName}`); + return []; + } // Bug #4: graceful degradation instead of crashing logger.error(`Failed to read sent messages for ${teamName}: ${String(error)}`); return []; diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 59166f54..17edf43b 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -1,3 +1,4 @@ +import { readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTasksBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; @@ -6,6 +7,7 @@ import * as path from 'path'; import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types'; const logger = createLogger('Service:TeamTaskReader'); +const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024; export class TeamTaskReader { /** @@ -63,7 +65,12 @@ export class TeamTaskReader { const taskPath = path.join(tasksDir, file); try { - const raw = await fs.promises.readFile(taskPath, 'utf8'); + const fileStat = await fs.promises.stat(taskPath); + if (!fileStat.isFile() || fileStat.size > MAX_TASK_FILE_BYTES) { + logger.debug(`Skipping suspicious task file: ${taskPath}`); + continue; + } + const raw = await readFileUtf8WithTimeout(taskPath, 5_000); const parsed = JSON.parse(raw) as Record; // Skip internal CLI tracking entries (spawned subagent bookkeeping) const metadata = parsed.metadata as Record | undefined; @@ -77,19 +84,18 @@ export class TeamTaskReader { : typeof parsed.title === 'string' ? parsed.title : ''; - // Resolve createdAt: prefer JSON field, fallback to fs.stat + // Resolve createdAt: prefer JSON field, fallback to fs.stat (reuse fileStat from above) let createdAt: string | undefined; let updatedAt: string | undefined; if (typeof parsed.createdAt === 'string') { createdAt = parsed.createdAt; } try { - const stat = await fs.promises.stat(taskPath); if (!createdAt) { - const bt = stat.birthtime.getTime(); - createdAt = (bt > 0 ? stat.birthtime : stat.mtime).toISOString(); + const bt = fileStat.birthtime.getTime(); + createdAt = (bt > 0 ? fileStat.birthtime : fileStat.mtime).toISOString(); } - updatedAt = stat.mtime.toISOString(); + updatedAt = fileStat.mtime.toISOString(); } catch { /* leave undefined */ } @@ -197,7 +203,12 @@ export class TeamTaskReader { const taskPath = path.join(tasksDir, file); try { - const raw = await fs.promises.readFile(taskPath, 'utf8'); + const fileStat = await fs.promises.stat(taskPath); + if (!fileStat.isFile() || fileStat.size > MAX_TASK_FILE_BYTES) { + logger.debug(`Skipping suspicious task file: ${taskPath}`); + continue; + } + const raw = await readFileUtf8WithTimeout(taskPath, 5_000); const parsed = JSON.parse(raw) as Record; // Skip internal CLI tracking entries const metadata = parsed.metadata as Record | undefined; diff --git a/src/main/utils/fsRead.ts b/src/main/utils/fsRead.ts new file mode 100644 index 00000000..2e0e6ad3 --- /dev/null +++ b/src/main/utils/fsRead.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs'; + +function isAbortError(error: unknown): boolean { + return ( + !!error && + typeof error === 'object' && + 'name' in error && + typeof (error as { name?: unknown }).name === 'string' && + (error as { name: string }).name === 'AbortError' + ); +} + +export class FileReadTimeoutError extends Error { + constructor( + public readonly filePath: string, + public readonly timeoutMs: number + ) { + super(`Timed out after ${timeoutMs}ms reading ${filePath}`); + this.name = 'FileReadTimeoutError'; + } +} + +export async function readFileUtf8WithTimeout( + filePath: string, + timeoutMs: number +): Promise { + 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)) { + throw new FileReadTimeoutError(filePath, timeoutMs); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 36925236..3dd7d3b5 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -91,6 +91,7 @@ export const GlobalTaskList = ({ const { globalTasks, globalTasksLoading, + globalTasksInitialized, fetchAllTasks, projects, viewMode, @@ -100,6 +101,7 @@ export const GlobalTaskList = ({ useShallow((s) => ({ globalTasks: s.globalTasks, globalTasksLoading: s.globalTasksLoading, + globalTasksInitialized: s.globalTasksInitialized, fetchAllTasks: s.fetchAllTasks, projects: s.projects, viewMode: s.viewMode, @@ -295,7 +297,7 @@ export const GlobalTaskList = ({ {/* Content */}
- {globalTasksLoading && globalTasks.length === 0 && ( + {globalTasksLoading && !globalTasksInitialized && (
{[1, 2, 3].map((i) => (
@@ -303,7 +305,7 @@ export const GlobalTaskList = ({
)} - {!globalTasksLoading && !hasContent && ( + {globalTasksInitialized && !hasContent && (
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 4e9dc7cf..a97b3404 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -135,6 +135,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [launchDialogOpen, setLaunchDialogOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false); const contentRef = useRef(null); + const provisioningBannerRef = useRef(null); + const wasProvisioningRef = useRef(false); // Set inert on background content when editor overlay is open (a11y focus trap) useEffect(() => { @@ -259,6 +261,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele })) ); + useEffect(() => { + const wasProvisioning = wasProvisioningRef.current; + wasProvisioningRef.current = isTeamProvisioning; + if (!wasProvisioning && isTeamProvisioning) { + provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [isTeamProvisioning]); + const [kanbanSearch, setKanbanSearch] = useState(''); const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); const [messagesFilter, setMessagesFilter] = useState({ @@ -690,7 +700,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return (
- +
+ +
@@ -897,7 +909,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
) : null} - +
+ +
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index a3b33453..a3ae6dba 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -403,11 +403,11 @@ export const TeamListView = (): React.JSX.Element => { const existingNames = teams.map((t) => t.teamName); const uniqueName = generateUniqueName(teamName, existingNames); const members = (data.members ?? []) - .filter((m) => !m.removedAt) + .filter((m) => !m.removedAt && m.agentType !== 'team-lead') .map((m) => { let role = m.role; if (!role && m.agentType && m.agentType !== 'general-purpose') { - role = m.agentType === 'team-lead' ? 'lead' : m.agentType; + role = m.agentType; } return { name: m.name, role }; }); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index db0e31be..dd041845 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -8,10 +8,21 @@ import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; const logger = createLogger('teamSlice'); const TEAM_GET_DATA_TIMEOUT_MS = 30_000; +const TEAM_FETCH_TIMEOUT_MS = 30_000; function nowIso(): string { return new Date().toISOString(); } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'disconnected', 'cancelled']); + +function isPendingProvisioningRunId(runId: string): boolean { + return runId.startsWith('pending:'); +} + function withTimeout(promise: Promise, ms: number, label: string): Promise { let timer: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { @@ -24,6 +35,32 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }); } +async function pollProvisioningStatus( + getState: () => TeamSlice, + runId: string, + opts?: { maxAttempts?: number; initialDelayMs?: number } +): Promise { + const maxAttempts = opts?.maxAttempts ?? 12; + let delayMs = opts?.initialDelayMs ?? 150; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const state = getState(); + const current = state.provisioningRuns[runId]; + if (current && TERMINAL_PROVISIONING_STATES.has(current.state)) { + return; + } + try { + const progress = await state.getProvisioningStatus(runId); + if (TERMINAL_PROVISIONING_STATES.has(progress.state)) { + return; + } + } catch { + // best-effort polling; don't fail launch because status fetch is flaky + } + await sleep(delayMs); + delayMs = Math.min(1500, Math.round(delayMs * 1.5)); + } +} + import type { AppState } from '../types'; import type { AddMemberRequest, @@ -125,6 +162,7 @@ export interface TeamSlice { teamsError: string | null; globalTasks: GlobalTask[]; globalTasksLoading: boolean; + globalTasksInitialized: boolean; globalTasksError: string | null; globalTaskDetail: GlobalTaskDetailState | null; openGlobalTaskDetail: (teamName: string, taskId: string) => void; @@ -214,7 +252,7 @@ export interface TeamSlice { createTeam: (request: TeamCreateRequest) => Promise; launchTeam: (request: TeamLaunchRequest) => Promise; cancelProvisioning: (runId: string) => Promise; - getProvisioningStatus: (runId: string) => Promise; + getProvisioningStatus: (runId: string) => Promise; onProvisioningProgress: (progress: TeamProvisioningProgress) => void; subscribeProvisioningProgress: () => void; unsubscribeProvisioningProgress: () => void; @@ -229,6 +267,7 @@ export const createTeamSlice: StateCreator = (set, teamsError: null, globalTasks: [], globalTasksLoading: false, + globalTasksInitialized: false, globalTasksError: null, selectedTeamName: null, selectedTeamData: null, @@ -283,7 +322,11 @@ export const createTeamSlice: StateCreator = (set, set({ teamsLoading: true, teamsError: null }); } try { - const teams = await unwrapIpc('team:list', () => api.teams.list()); + const teams = await withTimeout( + unwrapIpc('team:list', () => api.teams.list()), + TEAM_FETCH_TIMEOUT_MS, + 'fetchTeams' + ); const teamByName: Record = {}; const teamBySessionId: Record = {}; for (const team of teams) { @@ -318,7 +361,9 @@ export const createTeamSlice: StateCreator = (set, fetchAllTasks: async () => { // Guard: prevent concurrent fetches (component mount + centralized init chain) if (get().globalTasksLoading) return; - const isInitialLoad = get().globalTasks.length === 0; + // Show skeleton only on the very first fetch — not on subsequent refreshes + // even when the task list is empty (avoids flickering skeleton on every watcher event). + const isInitialLoad = !get().globalTasksInitialized; if (isInitialLoad) { set({ globalTasksLoading: true, globalTasksError: null }); } @@ -326,7 +371,11 @@ export const createTeamSlice: StateCreator = (set, const wasFirst = isFirstFetchAllTasks; isFirstFetchAllTasks = false; try { - const tasks = await unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()); + const tasks = await withTimeout( + unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()), + TEAM_FETCH_TIMEOUT_MS, + 'fetchAllTasks' + ); if (!wasFirst) { const notifyOnClarifications = @@ -341,10 +390,16 @@ export const createTeamSlice: StateCreator = (set, } } - set({ globalTasks: tasks, globalTasksLoading: false, globalTasksError: null }); + set({ + globalTasks: tasks, + globalTasksLoading: false, + globalTasksInitialized: true, + globalTasksError: null, + }); } catch (error) { set({ globalTasksLoading: false, + globalTasksInitialized: true, globalTasksError: isInitialLoad ? error instanceof IpcError ? error.message @@ -533,7 +588,11 @@ export const createTeamSlice: StateCreator = (set, // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). try { - const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName)); + const data = await withTimeout( + unwrapIpc('team:getData', () => api.teams.getData(teamName)), + TEAM_GET_DATA_TIMEOUT_MS, + `refreshTeamData(${teamName})` + ); // Re-check after async: the user might have navigated away. if (get().selectedTeamName !== teamName) { return; @@ -546,14 +605,14 @@ export const createTeamSlice: StateCreator = (set, if (get().selectedTeamName !== teamName) { return; } - set({ - selectedTeamError: - error instanceof IpcError + const msg = + error instanceof IpcError + ? error.message + : error instanceof Error ? error.message - : error instanceof Error - ? error.message - : 'Failed to refresh team data', - }); + : 'Failed to refresh team data'; + logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`); + set({ selectedTeamError: msg }); } }, @@ -777,6 +836,23 @@ export const createTeamSlice: StateCreator = (set, } return { provisioningError: null, provisioningRuns: cleaned }; }); + + // Optimistic progress entry: ensures banner shows even if IPC progress is delayed/missed. + const pendingRunId = `pending:${request.teamName}:${Date.now()}`; + set((state) => ({ + provisioningRuns: { + ...state.provisioningRuns, + [pendingRunId]: { + runId: pendingRunId, + teamName: request.teamName, + state: 'spawning', + message: 'Starting Claude CLI process...', + startedAt: floor, + updatedAt: floor, + }, + }, + activeProvisioningRunId: pendingRunId, + })); try { if (typeof api.teams.createTeam !== 'function') { throw new Error( @@ -788,7 +864,12 @@ export const createTeamSlice: StateCreator = (set, activeProvisioningRunId: response.runId, provisioningError: null, }); - await get().getProvisioningStatus(response.runId); + try { + await get().getProvisioningStatus(response.runId); + } catch { + // ignore — polling below will retry + } + void pollProvisioningStatus(get, response.runId); return response.runId; } catch (error) { set({ @@ -826,13 +907,35 @@ export const createTeamSlice: StateCreator = (set, } return { provisioningError: null, provisioningRuns: cleaned }; }); + + // Optimistic progress entry: ensures banner shows even if IPC progress is delayed/missed. + const pendingRunId = `pending:${request.teamName}:${Date.now()}`; + set((state) => ({ + provisioningRuns: { + ...state.provisioningRuns, + [pendingRunId]: { + runId: pendingRunId, + teamName: request.teamName, + state: 'spawning', + message: 'Starting Claude CLI process...', + startedAt: floor, + updatedAt: floor, + }, + }, + activeProvisioningRunId: pendingRunId, + })); try { const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request)); set({ activeProvisioningRunId: response.runId, provisioningError: null, }); - await get().getProvisioningStatus(response.runId); + try { + await get().getProvisioningStatus(response.runId); + } catch { + // ignore — polling below will retry + } + void pollProvisioningStatus(get, response.runId); return response.runId; } catch (error) { set({ @@ -852,6 +955,7 @@ export const createTeamSlice: StateCreator = (set, api.teams.getProvisioningStatus(runId) ); get().onProvisioningProgress(progress); + return progress; }, cancelProvisioning: async (runId: string) => { @@ -864,14 +968,25 @@ export const createTeamSlice: StateCreator = (set, // Ignore late progress from a previous run (common after stop→launch). return; } - set((state) => ({ - provisioningRuns: { + set((state) => { + const nextRuns: Record = { ...state.provisioningRuns, [progress.runId]: progress, - }, - activeProvisioningRunId: progress.runId, - provisioningError: progress.state === 'failed' ? (progress.error ?? null) : null, - })); + }; + // When real progress arrives, drop any pending placeholder runs for this team. + if (!isPendingProvisioningRunId(progress.runId)) { + for (const [runId, run] of Object.entries(nextRuns)) { + if (isPendingProvisioningRunId(runId) && run.teamName === progress.teamName) { + delete nextRuns[runId]; + } + } + } + return { + provisioningRuns: nextRuns, + activeProvisioningRunId: progress.runId, + provisioningError: progress.state === 'failed' ? (progress.error ?? null) : null, + }; + }); if (progress.state === 'ready' || progress.state === 'disconnected') { void get().fetchTeams(); diff --git a/test/main/services/team/TeamInboxReader.test.ts b/test/main/services/team/TeamInboxReader.test.ts index e3ae2556..545f6e5b 100644 --- a/test/main/services/team/TeamInboxReader.test.ts +++ b/test/main/services/team/TeamInboxReader.test.ts @@ -7,6 +7,19 @@ const hoisted = vi.hoisted(() => { // Normalize path separators so tests pass on Windows (backslash → forward slash) const norm = (p: string): string => p.replace(/\\/g, '/'); + const stat = vi.fn(async (filePath: string) => { + const data = files.get(norm(filePath)); + if (data === undefined) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return { + isFile: () => true, + size: Buffer.byteLength(data, 'utf8'), + }; + }); + const readdir = vi.fn(async (dirPath: string) => { const entries = dirs.get(norm(dirPath)); if (!entries) { @@ -27,15 +40,21 @@ const hoisted = vi.hoisted(() => { return data; }); - return { files, dirs, readdir, readFile }; + return { files, dirs, stat, readdir, readFile }; }); -vi.mock('fs', () => ({ - promises: { - readdir: hoisted.readdir, - readFile: hoisted.readFile, - }, -})); +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promises: { + ...actual.promises, + stat: hoisted.stat, + readdir: hoisted.readdir, + readFile: hoisted.readFile, + }, + }; +}); vi.mock('../../../../src/main/utils/pathDecoder', () => ({ getTeamsBasePath: () => '/mock/teams', diff --git a/test/main/services/team/TeamKanbanManager.test.ts b/test/main/services/team/TeamKanbanManager.test.ts index ec3a8389..40596dca 100644 --- a/test/main/services/team/TeamKanbanManager.test.ts +++ b/test/main/services/team/TeamKanbanManager.test.ts @@ -6,6 +6,19 @@ const hoisted = vi.hoisted(() => { // Normalize path separators so tests pass on Windows (backslash → forward slash) const norm = (p: string): string => p.replace(/\\/g, '/'); + const stat = vi.fn(async (filePath: string) => { + const data = files.get(norm(filePath)); + if (data === undefined) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return { + isFile: () => true, + size: Buffer.byteLength(data, 'utf8'), + }; + }); + const readFile = vi.fn(async (filePath: string) => { const data = files.get(norm(filePath)); if (data === undefined) { @@ -18,14 +31,20 @@ const hoisted = vi.hoisted(() => { const atomicWrite = vi.fn(async (filePath: string, data: string) => { files.set(norm(filePath), data); }); - return { files, readFile, atomicWrite }; + return { files, stat, readFile, atomicWrite }; }); -vi.mock('fs', () => ({ - promises: { - readFile: hoisted.readFile, - }, -})); +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promises: { + ...actual.promises, + stat: hoisted.stat, + readFile: hoisted.readFile, + }, + }; +}); vi.mock('../../../../src/main/utils/pathDecoder', () => ({ getTeamsBasePath: () => '/mock/teams', diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index deea380a..8eb8cd73 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -7,6 +7,19 @@ const hoisted = vi.hoisted(() => { // Normalize path separators so tests pass on Windows (backslash → forward slash) const norm = (p: string): string => p.replace(/\\/g, '/'); + const stat = vi.fn(async (filePath: string) => { + const data = files.get(norm(filePath)); + if (data === undefined) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return { + isFile: () => true, + size: Buffer.byteLength(data, 'utf8'), + }; + }); + const readFile = vi.fn(async (filePath: string) => { const data = files.get(norm(filePath)); if (data === undefined) { @@ -26,6 +39,7 @@ const hoisted = vi.hoisted(() => { return { files, + stat, readFile, atomicWrite, setAtomicWriteShouldFail: (next: boolean) => { @@ -34,11 +48,17 @@ const hoisted = vi.hoisted(() => { }; }); -vi.mock('fs', () => ({ - promises: { - readFile: hoisted.readFile, - }, -})); +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promises: { + ...actual.promises, + stat: hoisted.stat, + readFile: hoisted.readFile, + }, + }; +}); vi.mock('../../../../src/main/services/team/atomicWrite', () => ({ atomicWriteAsync: hoisted.atomicWrite,