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:
parent
faf042d640
commit
43b18d4920
16 changed files with 636 additions and 209 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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
40
src/main/utils/fsRead.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue