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
This commit is contained in:
iliya 2026-03-03 17:43:29 +02:00
parent faf042d640
commit 43b18d4920
16 changed files with 636 additions and 209 deletions

View file

@ -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<T, R>(
return results;
}
function withReadTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_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<string> {
const handle = await fs.promises.open(filePath, 'r');
try {
@ -82,125 +95,15 @@ export class TeamConfigReader {
TEAM_LIST_CONCURRENCY,
async (entry): Promise<TeamSummary | null> => {
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<string, TeamSummaryMember>();
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<TeamSummary | null> {
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<string, TeamSummaryMember>();
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<TeamConfig | null> {
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;
}
}

View file

@ -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<typeof setInterval> | 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 {

View file

@ -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<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 TeamInboxReader {
async listInboxNames(teamName: string): Promise<string[]> {
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<InboxMessage[]> {
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) => {

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function tryReadRegularFileUtf8(
filePath: string,
opts: { timeoutMs: number; maxBytes: number }
): Promise<string | null> {
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<NodeJS.ProcessEnv> | 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<void> {
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<string, unknown>;
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<string, unknown>;
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;
}

View file

@ -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 [];

View file

@ -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<string, unknown>;
// Skip internal CLI tracking entries (spawned subagent bookkeeping)
const metadata = parsed.metadata as Record<string, unknown> | 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<string, unknown>;
// Skip internal CLI tracking entries
const metadata = parsed.metadata as Record<string, unknown> | undefined;

40
src/main/utils/fsRead.ts Normal file
View file

@ -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<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)) {
throw new FileReadTimeoutError(filePath, timeoutMs);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}

View file

@ -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 */}
<div className="flex-1 overflow-y-auto">
{globalTasksLoading && globalTasks.length === 0 && (
{globalTasksLoading && !globalTasksInitialized && (
<div className="space-y-2 p-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-[48px] animate-pulse rounded bg-surface-raised" />
@ -303,7 +305,7 @@ export const GlobalTaskList = ({
</div>
)}
{!globalTasksLoading && !hasContent && (
{globalTasksInitialized && !hasContent && (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-text-muted">
<ListTodo className="size-8 opacity-40" />
<span className="text-[12px]">

View file

@ -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<HTMLDivElement>(null);
const provisioningBannerRef = useRef<HTMLDivElement>(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<MessagesFilterState>({
@ -690,7 +700,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return (
<div className="size-full overflow-auto p-4">
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
<TeamProvisioningBanner teamName={teamName} />
<div ref={provisioningBannerRef}>
<TeamProvisioningBanner teamName={teamName} />
</div>
<div className="space-y-3">
<div className="h-24 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
<div className="h-48 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
@ -897,7 +909,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div>
) : null}
<TeamProvisioningBanner teamName={teamName} />
<div ref={provisioningBannerRef}>
<TeamProvisioningBanner teamName={teamName} />
</div>
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
<div className="mb-3 rounded-md border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">

View file

@ -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 };
});

View file

@ -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<void> {
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<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_resolve, reject) => {
@ -24,6 +35,32 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
});
}
async function pollProvisioningStatus(
getState: () => TeamSlice,
runId: string,
opts?: { maxAttempts?: number; initialDelayMs?: number }
): Promise<void> {
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<string>;
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
cancelProvisioning: (runId: string) => Promise<void>;
getProvisioningStatus: (runId: string) => Promise<void>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
onProvisioningProgress: (progress: TeamProvisioningProgress) => void;
subscribeProvisioningProgress: () => void;
unsubscribeProvisioningProgress: () => void;
@ -229,6 +267,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
teamsError: null,
globalTasks: [],
globalTasksLoading: false,
globalTasksInitialized: false,
globalTasksError: null,
selectedTeamName: null,
selectedTeamData: null,
@ -283,7 +322,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<string, TeamSummary> = {};
const teamBySessionId: Record<string, TeamSummary> = {};
for (const team of teams) {
@ -318,7 +361,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
api.teams.getProvisioningStatus(runId)
);
get().onProvisioningProgress(progress);
return progress;
},
cancelProvisioning: async (runId: string) => {
@ -864,14 +968,25 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// Ignore late progress from a previous run (common after stop→launch).
return;
}
set((state) => ({
provisioningRuns: {
set((state) => {
const nextRuns: Record<string, TeamProvisioningProgress> = {
...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();

View file

@ -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<typeof import('fs')>();
return {
...actual,
promises: {
...actual.promises,
stat: hoisted.stat,
readdir: hoisted.readdir,
readFile: hoisted.readFile,
},
};
});
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
getTeamsBasePath: () => '/mock/teams',

View file

@ -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<typeof import('fs')>();
return {
...actual,
promises: {
...actual.promises,
stat: hoisted.stat,
readFile: hoisted.readFile,
},
};
});
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
getTeamsBasePath: () => '/mock/teams',

View file

@ -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<typeof import('fs')>();
return {
...actual,
promises: {
...actual.promises,
stat: hoisted.stat,
readFile: hoisted.readFile,
},
};
});
vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
atomicWriteAsync: hoisted.atomicWrite,